Student-friendly full stack web development library.
Project description
Drafter
A simple Python library for making websites, following good software engineering principles.
Development
This is the v2 rewrite, so you can use the following
git checkout v2-pyodide
Setup (Python via uv)
- Prereqs: Python 3.8+ (recommended 3.11+), Node.js 18+ with npm, and uv. On Windows PowerShell:
winget install astral-sh.uv
- Clone:
git clone https://github.com/drafter-edu/drafter.git
cd drafter
- Create the Python environment and install deps (reads pyproject/uv.lock):
uv sync
- Install JS deps once (for builds/watchers):
cd js
npm install
cd ..
- Run an example (uses uv’s virtual env automatically):
uv run examples\shop.py
If you need the Skulpt engine explicitly, pass --engine skulpt.
watch JS assets
To iterate on the JS client and have changes flow into the Python package automatically:
- In one terminal, run the JS watcher. This rebuilds the TypeScript bridge on every save and copies the output into
src/drafter/assets:
cd js
npm install
npm run dev
- In another terminal, run the JS precompiler. This builds the Skulpt version of the Python Drafter library on every save and copies the output into
src/drafter/assets:
cd js
npm run precompile:watch
```
3. In another terminal, run a local Drafter app so you can see live reloads. For example, using one of the examples:
```powershell
uv run examples\simplest.py
Notes:
- The watcher writes bundles to
src/drafter/assetswhich the dev server serves from/assetsand is already included in the server's file-watcher, so connected browsers will auto-reload. - If you maintain a local Skulpt build, set
SKULPT_DIRin a top-level.envto copyskulpt.jsandskulpt-stdlib.jsdirectly intosrc/drafter/assetson startup (npm run devcalls this automatically).
run tests
- JS tests:
cd js
npm run test
- Python tests (uses uv env):
uv run pytest --verbose --color=yes -vv
Organization
When you run a Drafter program that has a start_server call, then it will actually trigger the launch.py script's logic that will either run the application differently depending on whether it is in Skulpt/Pyodide (web) mode or normal Python (app) mode.
If a user runs the program directly, then when it reaches start_server, it will start a local development server (the AppServer) using Starlette.
This server will serve a single page with a div that contains the DRAFTER_ROOT.
The page will sets up Skulpt/Pyodide and a hot-reload connection to the server.
Finally, the page will also load the user's code into Skulpt/Pyodide and run it, which should rerun this process from the beginning, but instead triggering the alternative path where we run in web mode (see below). Effectively, this is running the program twice. The first time is "server side" with no real implications (other than starting the server, unit tests, print statements, etc.). The second time is "client side" in Skulpt/Pyodide, where the user's code is actually run.
If the build command is used from the command line script, then the AppBuilder will instead generate static HTML, CSS, and JS files that can be deployed to any static hosting service.
The main index.html file will create the DRAFTER_ROOT div, set up Skulpt/Pyodide and load the user's code into it.
It will try to render the student's initial page to prepopulate as much meta information as it can, as well as an HTML preview that can be shown for SEO contexts.
The AppBuilder and AppServer are together both referred to as AppBackend.
If the user is running the program directly in Skulpt/Pyodide, then when it reaches the start_server call, it will instead trigger the launch.py script's logic to setup the ClientBridge and get the main ClientServer. Note that the ClientServer is not a real server; it is just a class that handles requests from the ClientBridge and generates responses.
The ClientBridge is responsible for populating the DOM, tracking user interactions, and sending requests from the client side, while the ClientServer is responsible for processing requests, managing state, and generating responses on the server side.
The area that the user sees is the Site, which is a frame encapsulating the Form, the Body (composed of PageContent), the DebugInfo, and additional elements that are needed (e.g., audio players).
The Site is a container that holds all the elements of the user interface and is populated by the ClientBridge based on the responses it gets from the ClientServer.
The ClientBridge is stupid when it comes to the site, and doesn't really understand what it has; as much of that logic as possible is pushed into the ClientServer.
The ClientServer generally sends information to the ClientBridge, which then performs updates on the page (e.g., sending info to the DebugInfo panel, updating the PageContent, adding new JS/CSS, etc.).
The DOM structure of the site is as follows:
- There's a top-level div tag with id
drafter-root--that contains ALL content (except for top-level script tags needed for loading the actual true initial page). - There's a div tag with id
drafter-site--that contains the entire site. - Inside that is a
drafter-form--div that contains the main app (indrafter-frame--), followed by thedrafter-debug--div.- The frame makes the app look like it is in a browser window.
- The frame is only visible in development mode; otherwise, only its content is visible.
- Inside the frame is a
drafter-header--div, adrafter-body--div, and adrafter-footer--div.- The header and footer are only visible in development mode.
- The header has things like the site title and quick links for resetting state, going to the about page, etc.
- The footer has quick information like the current route, status, etc.
- The first child of the
drafter-body--is aformtag with iddrafter-form--. - Subsequent children of the body can be additional tags that are outside the form (e.g., modals, audio players, etc.).
- When the page content is rendered, it replaces content within the header/body/footer that is inside of the Form (without replacing the form itself).
The structure can be summarized as:
- Root > Site > Form > Frame > Header
- Root > Site > Form > Frame > Body > (Page's content goes here)
- Root > Site > Form > Frame > Footer
- Root > Site > DebugInfo
┌─True─Site─────────────────────────────────────────────┐
│ ┌True─Head──────────────────────────────────────────┐ │
│ └───────────────────────────────────────────────────┘ │
│ ┌True─Body──────────────────────────────────────────┐ │
│ │┌─Root────────────────────────────────────────────┐│ │
│ ││┌─Site──────────────────────────────────────────┐││ │
│ │││ ┌─Padding-h─────────────────────────────────┐ │││ │
│ │││ └───────────────────────────────────────────┘ │││ │
│ │││ ┌─Form──────────────────────────────────────┐ │││ │
│ │││ │ ┌─Padding-v─────────────────────────────┐ │ │││ │
│ │││ │ └───────────────────────────────────────┘ │ │││ │
│ │││ │ ┌─Frame─────────────────────────────────┐ │ │││ │
│ │││ │ │ ┌─Header───────────────────────────┐ │ │ │││ │
│ │││ │ │ └──────────────────────────────────┘ │ │ │││ │
│ │││ │ │ ┌─Body─────────────────────────────┐ │ │ │││ │
│ │││ │ │ └──────────────────────────────────┘ │ │ │││ │
│ │││ │ │ ┌─Footer───────────────────────────┐ │ │ │││ │
│ │││ │ │ └──────────────────────────────────┘ │ │ │││ │
│ │││ │ └───────────────────────────────────────┘ │ │││ │
│ │││ │ ┌─Padding-v─────────────────────────────┐ │ │││ │
│ │││ │ └───────────────────────────────────────┘ │ │││ │
│ │││ └───────────────────────────────────────────┘ │││ │
│ │││ ┌─Padding-h─────────────────────────────────┐ │││ │
│ │││ └───────────────────────────────────────────┘ │││ │
│ │││ ┌─Debug─Panel───────────────────────────────┐ │││ │
│ │││ └───────────────────────────────────────────┘ │││ │
│ ││└───────────────────────────────────────────────┘││ │
│ │└─────────────────────────────────────────────────┘│ │
│ │┌─True─Scripts─Footer─────────────────────────────┐│ │
│ │└─────────────────────────────────────────────────┘│ │
│ └───────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────┘
We'll use a request/response model to update the page content, based around events.
When the user interacts with the page (clicks a link, submits a form, etc.), the ClientBridge will send a request to the ClientServer with the relevant information:
- The action that led to the request (e.g., "click", "back", "forward", "reset button")
- The URL path being requested (which will match to a route function)
- The form data (which will be unpacked into named parameters, if matched)
- Input forms
- Files
- The
Argumentobjects
- Extra event information, passed as its own dataclass instance in a specially named
eventparameter.- Clicked button
- Scroll position
- Etc.
The ClientServer will process the request and choose an appropriate route handler by using the visit method.
The provided function will be called, providing the current State and the request information (via named parameters).
That function is expected to return a ResponsePayload, which can be any of various subclasses: Page (the most common), Fragment, Update, Redirect, Download, Progress, ErrorPage. That Page gets post-processed and wrapped in a Response along with metadata (e.g., status code, errors, headers, etc.) and sent to the ClientBridge. The Response is always sent back to the ClientBridge, even in error cases. Think of the Payload as being "the thing we will show the user", while the Response is "the meta information for the system." So most error metadata will be in the Response, while the Payload might still be a Page that shows a friendly error message.
Note that route functions usually expect state: State as their first parameter, but you can also do page: Page instead if you want to get the current state of the Page object (which includes the State). The ClientServer will automatically provide the appropriate object based on the function signature. So the complete list of "special" route parameter names:
state: Any: The current State object; only if this is the first parameter.page: Page: The current Page object (which includes the State); only if this is the first parameter.event: Event: The event information as a dataclass instance.kwargs: dict: Any other form fields that were not matched to named parameters will be provided as a dictionary in this parameter.request: Request: The full raw Request object, if desired.
The ClientBridge will then unwrap the page contents and update the DOM faithfully according to whatever it got from the Response, changing the contents of the form and replacing the click handler. It should update the browser history as appropriate, and run any scripts that were included in the response. If it had any errors or warnings, it should display those in the debug panel.
A ResponsePayload has a few key methods that should be implemented to fit into the lifecycle:
verify: Before being sent to the client, theResponsePayloadis verified to ensure it is valid and can be rendered properly. This might include checking for required fields, ensuring that links are valid, etc.render: The payload is rendered in theClientServerby calling itsrendermethod, which produces HTML. Typically, for aPage, this involves rendering all of its components and assembling them into a complete HTML document fragment. Note that this must be done recursively, so that each component renders its children, and so on.get_messages: The payload can also generate any additional scripts that need to be run before or after the main content is inserted; these are sent along as part of theResponseand executed by theClientBridge. This is all facilitated by thechannelsof theClientBridgeandClientServer, which allow sending messages back and forth; this is also useful for things like controlling page-level audio. So aResponsePayloadgenerates its messages, and these messages are sent to theClientBridgeto be executed at the appropriate time.format: The payload is turned into a string that can be used to recreate the payload, essentially the same asrepr. This is useful for debugging and logging purposes.get_state_updates: If the payload needs to make any changes to theState(e.g., updating fields, adding history entries, etc.), it can provide those updates via this method. TheClientServerwill apply these updates to the currentStateafter processing the request but before sending the response back to the client.
After the response is successfully (or unsuccessfully) processed, the ClientBridge sends notifications to the Audit system.
How is the first page handled? When the server starts up, it will create an initial State object. While Starlette or the compiler is going through its initial setup run of the code, it will find the index route and execute it to generate initial page content (this can be disabled if needed). This information is provided in the generated template that the server serves to the client, so that SEO crawlers can see the initial content.
When the ClientBridge connects, it will immediately request the index page (unless a specific other page was requested first, via query arguments), which will be run on the ClientServer side to generate the actual page content for the user.
How are errors and warnings handled? At any point during the process, we can generate either errors or warnings, and they get attached to the eventual Response AND also sent out through Telemetry. In some cases, we may want to short-circuit the normal flow and return an error response immediately (e.g., if a route handler raises an exception). In other cases, we may want to accumulate warnings and send them along with a successful response. There are many kinds of errors, all of which are documented in the drafter.data.error module.
How is History handled? Whenever a Request/Response completes, the Bridge notifies the PersistentStorage to store relevant data. That system uses the current Session ID in order to persist data that can then be restored if the page is reloaded or navigated back to. It has to keep track of everything in such a way that it can play it back later. This includes the State, the accessed pages, the arguments used, and any files that were uploaded. These updates should happen asynchronously wherever possible.
When the user navigates back or forward without leaving the site (e.g., since we hijacked the back button), the ClientBridge sends a request with the appropriate State ID, and the ClientServer retrieves that State and generates the corresponding page content. This allows for seamless navigation through the user's history of interactions with the site. When a tab gets opened, we check to see if the sessionStorage has a session ID for this tab. This can happen when the user navigates away to a different server and then comes back (e.g., via the back button). If there is no session ID, we generate a new one and store it in sessionStorage. This allows us to maintain continuity across page reloads and navigations within the same tab.
How are streaming responses handled? How are long-running tasks handled? In both cases, we can yield Progress payloads from route handlers, which will be sent to the ClientBridge as they are generated. The ClientBridge can then update the page to show progress indicators or partial results as they come in. This means that a single request can actually result in multiple responses being sent to the client over time.
From the student developer's perspective, they are building a Site, which can have multiple Routes. A Route is a decorated function that takes in the current State and any relevant parameters, and returns a Page (or other ResponsePayload). A Site also has metadata like title, description, favicon, language, etc.
-
How do users create "dynamic" route functions? They don't. Instead, they should focus on parameterizing their route functions appropriately. This still allows for dynamic behavior using response payloads like
Fragment, where you attach a route to a component that can then be triggered through some other kind of event. For example:- A textbox has an
on_changeevent that is meant to do live validation of the text that the user is typing. Theon_changeevent can be linked to a route function that takes in the current state and the text value, and returns aFragmentthat updates the validation message below the textbox. Rough pseudocode:
@route def validate_name(state: State, name: str) -> Fragment: if len(name) < 3: return Fragment(state, "#name_validation", "Invalid") else: return Fragment(state, "#name_validation", "Valid") @route def index(state: State) -> Page: return Page(state, [ TextBox("name_input", on_change=validate_name), Div("name_validation", "") ])
- A textbox has an
The target parameter accepts Target classes that specify where and how the content should be injected. For a Page, the target is always the drafter-body-- div, which is essentially the main content area of the page. For a Fragment, the target can be any CSS selector that identifies an element on the page; the content will be injected into that element. You can also control how many matches (one or all), whether the content replaces the children or the entire node, etc.
How does updating the page content work? The Page payload has an HTML string that is going to be injected into the page at the drafter-body-- div. The Fragment payload has an HTML string that is going to be injected into a specific component on the page, identified by the target provided. So essentially a Page is just a Fragment with a predefined target location.
A URL is a string that represents a unique Route function in the Site. It should follow the naming conventions of a Python function (e.g., lowercase letters, numbers, underscores, no spaces or special characters). Eventually, we might support slashes for things like classes or modules (which would probably translate to periods).
Things that have to be kept in the server:
- State
- Initial state (for resetting)
- Accessed pages, args
- History of state
- History of request parameters, which includes files.
The debug information present in the frame:
- Quick link to reset the state and return to index
- Link to the About page
- Status information:
- Any errors and warnings, nicely formatted
- Current route information, as given by the
request.visitevents - Request/Response dump, including the metadata and actual contents, time taken.
- Current state dump, buttons to save/load state in localStorage or download/upload JSON (
state.*events) - Current page information (
request.*events) - All available routes (
request.addevents)- As a flat list
- As a graph
- Page load history (
request.*events)- Pages, state, args, timestamps, etc.
- VCR playback controls
- Automatically produced tests
- Test status information (
request.visitevents)- There should be an interactive menu for building up good tests
- An inconvenient download button for downloading "regression tests". Make this more of a "once your site is done" sort of thing, instead of encouraging them to do it all the time. I would like them to think critically about their tests instead of just spamming them out.
- Button to activate codemirror instance that let's us do a REPL type thing?
- Test production button
- Compile site button
The EventBus is a pub/sub system that allows different parts of the application to communicate with each other without being tightly coupled. Various components can publish events to the bus, and other components can subscribe to those events to receive notifications when they occur (mostly the Monitor).
Telemetry entails logging events like page loads, errors, warnings, state, performance metrics, user interactions, etc. Essentially, any debug information from the server should
be logged as TelemetryEvent, and certain kinds of client interactions. To help figure out where the data came from, there is also TelemetryCorrelation. Think of the TelemetryEvent as the envelope around the actual event data (which are subclasses of the BaseEvent).
The Monitor has visualizers that can handle the rendering logic for different contexts: raw information to be printed on stdout, logs for storing on disk, analytics for displaying in the debug panel.
The Audit module has a bunch of helper functions for publishing TelemetryEvent to the main EventBus in a consistent manner.
Essentially:
- The
ClientBridgehandles all DOM manipulation and user interaction on the client side. - The
ClientServerprocesses requests, manages state, and generates responses on the server side - The
Page(and otherResponsePayloads) represent the content and structure of the pages being served, and are created by the user-developer. - The
Requestwraps user interaction data for transmission from client to server, and is created by theClientBridge. - The
Responsewraps theResponsePayloadwith metadata for transmission between client and server, and is created by theClientServer.
How is open and read handled? Skulpt should first check builtinFiles, then localStorage (or IndexedDB if configured), and then ask its server using fetch. If its server (Starlette or Github Pages) can't find it, it should 404. If it's using write mode, then it should try to write to localStorage first, and then ask its server to write it (which will cause an error unless we're on a real server that supports it).
Images will assume to be available via the server.
Reentrant pages: If a route function has default parameters, then the URL can be called without those parameters, and the defaults will be used. This allows for reentrant URLs that can be bookmarked or shared.
File handling: When a file gets uploaded to the server, it will get stored in memory (or IndexedDB/localStorage if needed) and associated with the current state. When navigating back and forth, the file will be restored as needed. Note that files past a certain size may not be storable in localStorage, so we may need to have a strategy for handling large files (e.g., using IndexedDB, prompting the user to re-upload, substituting a smaller file).
PersistentStore is a class that lives in the ClientBridge which abstracts away the details of where data is stored (in-memory, localStorage, IndexedDB, etc.). It provides a simple interface for saving and loading data, and can be configured to use different storage backends as needed. The server can send commands to the ClientBridge to store or retrieve data using this PersistentStore.
State Data
Drafter organizes its functionality around Route functions. A Route generates a Page. Pages provide functionality to connect to other Routes, usually via Buttons/Links (other mechanisms include things like on_change events, timers, etc.).
There's four fundamental kinds of data to be handled in Drafter:
- App
State: the current state of the application, which can be updated and passed around to route functions. This is the main way to keep track of information across different pages and interactions. - Page Arguments:
Arguments defined inPages content, that will be passed to any connecting routes. They are stored in the HTML as hidden input fields (so that someday we might have local Forms, and these will "just work"). Their names are prepended with a special string to differentiate them from regular form fields, because their values are JSON encoded. - Page Fields: Form Fields (e.g., text inputs, file uploads, etc.) that are defined in a
Pageand will be passed to any connecting routes. They are stored in the HTML as regular form fields, so they will be included in the form data when a request is made. - Route Arguments:
Arguments that are attached to a specific route connection, which will be passed to the connected route function when triggered. These are stored as data attributes on the relevant DOM element (e.g., a button), and are also JSON encoded to allow for complex data structures. - Event Information: information about the event that triggered the route (e.g., click, scroll, etc.), which will be passed to the connected route function when triggered.
- Config Information: configuration parameters that are set at the start of the server and can be accessed throughout the application.
- Local Storage: data that is stored in the client's browser and can be accessed across sessions.
- Remote Storage: data that is stored on a server and can be accessed across sessions and devices.
All of these get passed in as parameters to a connected route function.
Summary of Execution Timeline
Fundamentally, the user writes a python script that starts with from drafter import *, defines server in various ways, and then calls start_server(initial_state). This user application can be run in three possible ways:
- From the command line like
python -m drafter user_script.py - From the command line like
drafter user_script.py - From the command line like
python user_script.py
All three work roughly the same: the package gets imported, configuration is processed (which comes from sys.args, os.environs, or a config file) into a singleton, the default web server object (not the starlette, but the framework's server) and other core pieces of infrastructure. Then, it starts (1/2) or resumes (3) executing the user's user_script.py; in the case of starting, that will also harmlessly from drafter import *.
If the do_main function is called by either drafter __.py or python -m drafter __.py, then the students' code is taken as an argument and executed, which will eventually reach the start_server call in the students' code.
If the Drafter module is imported, then the students' code will naturally be executing after we're done importing. Eventually the start_server will be reached.
Key insight from the above: instead of do_main, we should instead be doing all the configuration in a new configuration.py top-level module. That creates a Runtime that knows what we've decided to do. The launch.py handles the actual launching of the server or building of the site, and it will use the Runtime to determine what to do. Then cli.py basically just runs a students' code file.
- Bootstrap Phase
- The Drafter library is imported
- The
SystemConfigurationsingleton will be initialized- Bootstrap configuration is processed including environment variables, command line arguments, and config files. This configuration is used to determine how to proceed with the rest of the launch process, including whether we are in
start_servermode orcompile_sitemode. - The rest of the configuration fields (e.g.,
ClientServerConfiguration,AppServerConfiguration,AppBuilderConfiguration) are created and initialized - The _SYSTEM singleton is provided to the rest of the system
- Bootstrap configuration is processed including environment variables, command line arguments, and config files. This configuration is used to determine how to proceed with the rest of the launch process, including whether we are in
- Pre-initialization Phase
- The default
ClientServeris created and assigned toMAIN_SERVER
- The default
- Pre-Initialized Phase
- If a user runs a Drafter program that has a
start_servercall...- The user's code is executed naturally
- If the user ran
drafter ___orpython -m drafter ___:- The user's code is executed via
runpy
- The user's code is executed via
- If a user runs a Drafter program that has a
- Launch Phase
- We update the system configuration using the arguments to
start_server. - If we're in
start_servermode...- The
launch.pyscript starts up the Starlette server - If the pre-render flag is set, then we pre-render the initial page.
- The initial True Page gets served to the browser
- The True Page sets up the WebSocket connection back to the App Server
- The True Page sets up the Skulpt/Pyodide environment
- The True Page sets up the RawConfigFileData based on ClientServerConfiguration
- The True Page executes the student's code (go to Initialization).
- The
- If we're in
compile_sitemode...- The
AppBuildergenerates the static files for the site, including the initial page with pre-rendered content. - The generated files can then be deployed to any static hosting service.
- When a user visits the site, the True Page sets up the Skulpt/Pyodide environment
- The True Page sets up the RawConfigFileData based on ClientServerConfiguration
- The True Page executes the student's code (go to Initialization).
- The
- We update the system configuration using the arguments to
- Initialization Phase
- Drafter is imported
- The
SystemConfigurationsingleton is initialized - The
MAIN_SERVER(ClientServer) is created.
- Initialized Phase
- The rest of the students' code is executed, adding routes to the
MAIN_SERVERas it goes, until it reaches thestart_servercall. - The
launch.pyscript callsrun_client_bridge
- The rest of the students' code is executed, adding routes to the
- Configuring Phase
- The
ClientServeris configured (ClientServer.do_configuration), processing its dynamic configuration (see below).
- The
- Rendering Phase
- The
ClientServerrenders the Site (ClientServer.do_render): - The
run_client_bridgecall creates theClientBridge - The
ClientBridgesets up the Debug Menu. - The
ClientBridgeloads the rendered site - The
ClientBridgeattaches an event handler to theClientServer's event bus - The
ClientBridgeattaches event handlers for page interactivity - The
ClientBridgesets up page-wide navigation handlers (e.g.,popstate,drafter-navigate) - The
ClientBridgesets up a hotkey binding for the Debug Menu to be toggled on/off.
- The
- Starting Phase
- The
ClientServeris started (ClientServer.do_start): - The state is updated based on the initial state.
- The router is registered with system routes that are missing
- The
- Started Phase
- The
ClientBridgecreates the initialRequest - The
ClientBridgeinitiates a visit with the initialRequestto theClientServer
- The
- Visiting Phase
- The
ClientServergets the route function based on the request - The
ClientServerexecutes the route function and generates a aPayload- The
ClientServerdelegates argument preparation to itsRouter - Then the
ClientServersafely executes the route function, catching any exceptions and converting them intoErrorPagepayloads as needed.
- The
- The
ClientSerververifies thePayload - The
ClientServerrenders thePayloadto generate the new HTML body - The
ClientSererformats thePayloadto generate a string representation for logging/debugging purposes - The
ClientServerupdates its state based on thePayload - The
ClientServergenerates any messages that need to be sent to theClientBridge - The
ClientServerreturns a successful response with the information above.
- The
- Committing Phase
- The
ClientBridgereceives theResponsefrom theClientServer - The
ClientBridgeremoves all the page-specific content currently in place. - The
ClientBridgeinjects "Before" channel content (e.g., styles, scripts) that came with theResponse. - The
ClientBridgeupdates the page:- The
ClientBridgenotifies the debug panel of the new route - The
ClientBridgeupdates the body content if any is given - The
ClientBridgemounts the navigation handlers
- The
- The
ClientBridgeinjects "After" channel content (e.g., styles, scripts) that came with theResponse. - If it was a redirect route, then we handle the redirect now.
- We first check to make sure we are not in a loop
- We make a new request from the response
- We send the request, and start over from the top, as we did before.
- The
- Idle Phase
- The page is now fully loaded, and we are waiting for user interaction.
- Navigating Phase
- The user interacts with the page such that an event handler is triggered (e.g., clicks a button, presses the back button, triggers a special event handler for a component, etc.)
- The
ClientBridgeprepare a newRequestobject with the relevant information (e.g., route, args, event data) - The
ClientBridgesends theRequestto theClientServervia the connection established by theClientBridge(a "Visit") - Go to (11) Visiting Phase.
A complicated substep is the argument preparation:
- The
Routermakes a fresh version of the arguments - The
Routerpreprocesses the buttons, which are labeled in a special way.- TODO: Check if this is actually still necessary?
- The
Routerinspects the signature of the route function - The
Routerconverts hidden form parameters - The
Routerflattens any keyword arguments that are lists - The
Routerinjects the current state into the arguments if there is astateparameter - The
Routerremoves excess arguments - The
Routerconverts arguments to their destination types - The
Routerverifies that all expected parameters are present - The
Routerbuilds a string representation of the arguments for logging/debugging purposes
Error and warning Handling
Here's when different kinds of errors can occur:
- Core infrastructure during initial entire page load (e.g., Skulpt setup)
- Route resolution errors (e.g., no matching route, argument parsing errors)
- Errors during route execution (e.g., exceptions raised in route handlers) in student code
Error severity varies too:
- Errors are things that prevent the page from loading or functioning properly, and should be shown to the user in a friendly way.
- Warnings are things that might indicate a potential issue or suboptimal code, but don't necessarily prevent the page from functioning.
- Debug information is more detailed information that is primarily for the developer's benefit and might not be relevant to the user.
Open Question: Is there nuance within errors and warnings? For example, are there "critical" errors that should be shown in an alert popup, while less critical errors can be shown in the debug panel or as a banner on the page?
Here are the places that we can show errors to the user:
- The drafter page content area, where we can show friendly error messages that are styled to fit the site. This is the most common place for errors to be shown, and is where we would show things like "404: Page not found" or "500: Internal server error", as well as any custom error pages that the user might create.
- The entire page, if Drafter's infrastructure fails to load at all, in a panic dialogue
- The debug panel, where we can show error events.
- A hidden dialogue that can be revealed with a hotkey, which shows the full error information including stack traces, request/response dumps, etc. This is important for deployed sites that want to still show some details
- The browser console, where we can log errors for debugging purposes. This is generally for error details that are more serious and might indicate a bug in the framework itself, rather than just an error in the user's code.
- An
alertpopup, which can be used for critical errors that require immediate attention. This should be used sparingly, as it can be disruptive to the user experience. Might just be a toast. - The original system console
- A file that gets written to disk
Error Details
Some kinds of errors can be given extra details that will provide more help:
- Unknown route should show the list of routes available, and use string distance to find routes that the user might have intended.
- Missing required parameter should indicate which parameter is missing and what type it should be.
- Invalid parameter type should indicate the expected type and the received type.
- Errors during the route should indicate the exact line number and context within the user's code where the error occurred.
Configuration
- Bootstrap Phase: BootstrapConfiguration
- Pre-initialization Phase: Static ClientServerConfiguration
- Launch Phase:
- AppServerConfiguration or AppBuilderConfiguration
- Dynamic ClientServerConfiguration (if running student code)
- Now can create the actual True Page contents as needed
- Write RawConfigFileData for the True Page
- Initialization Phase: Static ClientServerConfiguration
- Configuring Phase: Dynamic ClientServerConfiguration
The configuration settings can come from a few different places; here they are in order of precedence (dynamic will override static, and highest will override lower):
- "Static" configs:
- Defaults defined in the code
- Environment variables (some of which might be query string parameters)
- Command line arguments
- A configuration file (provided by either 1. the environment variables, or 2. command line arguments)
- A raw string of RawConfigFileData provided to the True Page via the template context (essentially the determined configuration at the time of launch)
- "Dynamic" configs:
- Imperative configuration functions in the code (e.g.,
set_site_title()) - Arguments passed to the
start_serverfunction
- Imperative configuration functions in the code (e.g.,
At runtime, there are two main sources of configuration information:
- The "current" configuration, which is stored in the
Site - The "default" configuration, which is stored in the
ClientServer'sconfigurationfield.
Here's the configuration timeline:
- When the Drafter module first boots up, its static configuration is determined by merging those config sources together. This becomes the default configuration and is stored in the
ClientServer'sconfigurationfield. The current configuration will beNonefor now. - Further configs before the server starts will modify the default configuration.
- When the
ClientServeris rendered, the default configuration is copied to become the current configuration and stored in theSite, which is what is actually used to render the page and control the behavior of the site. - While the
ClientServeris running, any dynamic configs will modify the current configuration. A boolean keyword parameterupdate_defaultcan be provided to also update the default configuration at the same time if desired. - The user can
resetthe site, which will copy the default configuration back to the current configuration, resetting dynamic changes made AFTER the server started.
The ClientServerConfiguration dataclass is meant to be isomorphic between the default and current configurations, so that they can be easily copied back and forth.
Separate from this are the interfaces for the various configuration systems, which must map to the ClientServerConfiguration in order to actually affect the behavior of the system. For example, environment variables and command line arguments might be very different from the arguments provided to the start_server function, but they all need to be translated into the appropriate fields in the ClientServerConfiguration dataclass.
The server.default_configuration and server.site.configuration are not meant to be accessed directly.
In particular, this is because simply modifying their fields' contents will NOT trigger changes in the
deployed site.
You have to call the reconfigure method on the ClientServer in order to actually trigger any changes.
Interested parts of the ClientBridge can subscribe to configuration change events on the EventBus in order to know when to update things like the page title, favicon, etc. whenever the configuration changes.
The Compilation pipeline is used to build a static version of the site that can be deployed to any static hosting service. It takes the user's code and compiles it into a format that can be run in the browser (e.g., using Skulpt or Pyodide), and also generates the necessary HTML, CSS, and JS files to serve the site. The AppBuilder class is responsible for this process, and it uses the same underlying logic as the AppServer to ensure that the compiled version of the site behaves consistently with the development version.
ClientBridge architecture (current)
The bridge is split into five distinct pieces:
ClientBridge: Orchestrates startup, response handling, debug panel updates, and configuration-driven UI toggles.SiteRenderer: Owns DOM setup and updates, including body/fragment replacement and before/after channel content.NavigationController: Owns request creation, browser history integration, initial load navigation, and redirect loop protection.EventManager: Owns click/submit/custom event wiring, data collection for events, and hotkey registration.RuntimeAdapter: Encapsulates runtime-specific behavior (Skulpt/Pyodide interop, event wrapping, form/file handling).BrowserHistory: Keeps track of requests through pushstate/popstate
All of this is kicked off in the run_client_bridge function, which manages the ClientBridge instance.
File System
A key element of Drafter application are file systems. The Host has its own file system that can map directly to the disk, whereas the Client needs a virtual file system.
The Client's file system is a combination of five possible destinations:
- The in-memory file system
- The localStorage file system
- The IndexedDB file system
- The read-only file system provided by the server (for static assets)
- The writeable file system provided by the server (managed through a customized module for database-style writing in Firebase)
The Host's file system is a simpler thing:
- The actual disk file system
- The in-memory file system
Things that the Client file system needs to handle:
- Reading the student's initial code
- When the student uses
openorimport - Pyodide's locally mounted version of the Drafter library and the rest of its standard library
- Images that the user is linking
- Files that the user has uploaded, which need to be stored and associated with the current state.
- More complex push/pop state
- Official Drafter Assets that are bundled with the site, such as CSS, JS, images
- Note that some of these can be linked via a CDN or other solution
Things that the Host file system also needs to handle:
- Writing out logs, errors, and other debug information to disk
- Reading in the student's code and any relevant assets during development
- When building, we need to write files to disk
- When serving, we need to read files from disk in order to serve them to the client
Rather than trying to catch errors with pushstate/popstate, I think we should probably just use a documentId model where we refer to something stored in localStorage or IndexedDB, and then we can have a garbage collection strategy for cleaning up old documents that are no longer needed. This also allows us to store more complex data structures that might not fit in the URL or be suitable for encoding as query parameters.
A key problem is making files available from the Host to the Client. Ideally, we would like it to be trivial for the user to automatically get access to adjacent and nested files, but then also provide a solution for users to explicitly include files as needed. The similar rules should apply for requirements.txt and automatically detecting imports (which eventually need to work across all student files).
When the website is:
- Compiled for deployment, I think we run an operation to make a manifest of all available files (including some concept of an
ignore-list), and that is provided to the file system. - Served from the Local App Server, we can just read from a dynamic endpoint URL that serves files from the disk.
- Served from a remote server, we read the same endpoint URL but it is actually being served statically.
How many file system classes are there, and where do they live?
- The ClientBridge definitely needs access to the virtual file system.
- The debug area definitely needs access to the file system.
- The student code should be able to access the file system, so we need to be wrapping
openand handling imports correctly.- We probably just provide our own version of
open, and as long as theyfrom drafter import *, then they get access to it. - Import might not be too bad?
- For the Host-side execution, we can just use the normal file system and Python's import machinery.
- For the Client-side Pyodide execution, they should be able to use normal imports as long as the files are available in the virtual file system. We just need to make sure that the file system is populated correctly based on the student's code and any relevant assets.
- For the Client-side Skulpt execution, we will need to provide a custom import hook that can read from the virtual file system. We basically already do this in BlockPy.
- We probably just provide our own version of
- The ClientServer needs to access the file system in order to read files, I think?
- Either the ClientServer needs to, or the EventBus does. Someone needs to be able to write logs to disk.
- The Builder definitely needs to be able to write files to the file system during the Compilation process.
- The AppServer needs to be able to read files from the file system in order to serve them to the Client.
How should we handle paths?
- With absolute paths, I think the default behavior is to tell the student, "Stop using absolute paths that won't work". If they specify a command line flag, they can make them work normally.
- With relative paths, we should resolve them based on the students' main script location. This is as opposed to the current working directory of the server. This should be configurable explicitly as well, to use either the current working directory or an explicit path.
Currently, the configuration system only leverages the filesystem immediately before the launch, after the students' code. But we need the system to be able to access the filesystem during the students' code execution as well, in order to read files and serve them to the client. The file system interface should be able to make these decisions "on the fly" based on the current configuration settings.
Serving, Building, and Packaging
Drafter has a few core pieces, and they must be packaged appropriately to ensure that different instantiations can access what they need.
Here are ALL the parts of Drafter that have to be hosted:
- Drafter Python library: The core Python library compiled for CPython, usually installed through PyPi.
- Assets: Static files and resources required by the running web application, such as CSS, JavaScript, and images.
drafter.js: The main JavaScript file required by the web application, providing the necessary bridges and debug menus.drafter.css: The main CSS file required by the web application, providing core styling.drafter_debug.css: The CSS file providing styles for the debug menus.drafter_deploy.css: The CSS file providing styles for when the application is NOT in debug menu ("deployed").- Themes:
default.css: The base theme that most folks will end up using.none.css: An empty theme with no styling.dark.css: A dark theme with appropriate styling for low-light environments.
- Drafter Skulpt Libraries:
skulpt.js: Skulpt's coreskulpt-stdlib.js: Skulpt's standard library.skulpt-drafter.js: The Skulpt-compiled version of Drafter.drafter.skulpt.js: The JavaScript file that integrates Drafter with the Skulpt environment.
- Pyodide Drafter Libraries:
pyodide.js: Pyodide itself- Libraries hosted on the Pyodide CDN: External libraries required by the Pyodide environment.
- The Pyodide-compiled version of Drafter.
drafter.pyodide.js: The JavaScript file that integrates Drafter with the Pyodide environment.
- Compiled assets: There are some assets that get precompiled during the build process and are shipped with the application.
- Precompiled Headers: The CSS/Script content that should be embedded in the final output on page load.
- Precompiled Body: The HTML content that should be embedded in the final output on page load.
- Additional student assets: These are any assets that students need for their site like images, additional python files, css, html, javascript, etc.
Serving comes in two flavors:
- Developer: When a developer is working locally, they should generally be getting locally compiled versions of these assets.
- Student: When students are working with Drafter, we want to use the official hosted version of most of the assets.
Here are the files that we must take responsibility for packaging and publishing:
- CPython Drafter on PyPi: The current version of the library published on PyPi.
- Wasm Drafter on PyPi: The current version of the library published on PyPi for WebAssembly.
- JS Files via NPM:
- Skulpt and its associated libraries
drafter.pyodide.jsanddrafter.skulpt.jsdrafter.jsand its associated files
When we merge a new version of Drafter into the main branch, we should launch a github actions workflow to publish new versions of the library to PyPi and NPM as appropriate. Note that for skulpt, we will need to make sure the build system has a stable version of the Skulpt library available.
When serving, we can keep things:
- In memory
- In temporary folder
- On the CDN
When building, we need to either:
- Generate them into the output directory
- Link to the desired CDN locations
For most of the development process, I've been embedding the generated files into the assets directory. That seems ridiculous now. But is there some value in letting the entire Build step not depend on any external internet? Same deal with Student Serving. I still think that the assets should not be left into the python source directory. They should be added to the build output directory instead.
CI workflow steps:
cd js && npm installnpm run buildto build Drafter's JS files (tojs/dist/jsandjs/dist/css)npm run update-skulptto get a version of skulpt (place injs/dist/skulpt)npm run precompile --minifyfor building Drafter in skulpt, must provide either remote URL or local path to Skulpt (place injs/dist/skulpt)- Make sure all files are
js/dist/ cd ..to return to parent directoryuv buildnpm publishtwine upload dist/*
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file drafter-2.0.0b4.tar.gz.
File metadata
- Download URL: drafter-2.0.0b4.tar.gz
- Upload date:
- Size: 30.2 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.13 {"installer":{"name":"uv","version":"0.9.13"},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b1a8bcb3fc3a3910a420d30dbbb52c4eee30930e061f4c0f1cd4222b47d6b907
|
|
| MD5 |
679bb84230064e8ed4a2ee5320804565
|
|
| BLAKE2b-256 |
45b47a9bf7308ab2b0369cb939d0035c574dd02fcdfab24fe9e6de89222f4f8e
|
File details
Details for the file drafter-2.0.0b4-py3-none-any.whl.
File metadata
- Download URL: drafter-2.0.0b4-py3-none-any.whl
- Upload date:
- Size: 2.7 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.13 {"installer":{"name":"uv","version":"0.9.13"},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
20402d3f27360517b882f891d279b957e75a7a33ae5949168e2d7395f90c27c0
|
|
| MD5 |
eb17b9420da9225a14722313daff1dfe
|
|
| BLAKE2b-256 |
80f006b349716a420200fc4263a24d49c029fa0c0d2ba8a98114d632f08b1855
|