Architecture
This page is the one-sitting orientation. The authoritative,
detailed document is ARCHITECTURE.md at the repository root;
when the two disagree, that file (and above it, the code) wins.
In one sentence
Drafter is a Python library whose "server" is a pair of objects living inside the visitor's browser: a deliberately ignorant DOM bridge and a client server that owns all the logic.
The idea
Three run modes. start_server (src/drafter/launch.py)
dispatches on where it finds itself:
- Web mode: running inside Pyodide in a browser. This is
where the real app lives;
run_client_bridgewires up theClientBridgeandClientServer. - Compile mode:
compile_site(src/drafter/builder/build.py) emits a static site for deployment. - App mode: ordinary CPython. A Starlette/uvicorn dev
server (
src/drafter/app/app_server.py) serves a single "true page" that boots Pyodide, ships the student's source to it, and reruns the program there. Hot reload rides a WebSocket watched bywatchfiles.
The student-visible consequence is the two-run model: top-level
code executes once in CPython (tests, prints) and again in the
browser, where start_server becomes the wiring call.
The bridge/server split. In the browser, two objects divide the work:
- The ClientBridge (
src/drafter/bridge/) touches the DOM: it renders content it is handed, collects form values, listens for events, and reports them. It is kept deliberately stupid about what the site means. - The ClientServer (
src/drafter/client_server/) owns routing, state, history, and testing. It receives request-like messages from the bridge and answers with response-like payloads. It is not a network server; it is a class the bridge calls.
The default instance is the lazily created module-level
MAIN_SERVER (client_server/commands.py); a registry supports
multiple independent embedded apps on one page, which is how the
documentation's own demos work.
Components and rendering. Student code builds component
trees (src/drafter/components/); each component produces a
RenderPlan describing its tag, attributes, and children.
Custom-element components (Timer, Map, Camera, and friends)
render as <drafter-*> tags whose behavior lives in TypeScript
(js/src/components/), with a Python-side ComponentContract
declaring the events they emit and the payload fields routes may
receive. Capture-style components keep a hidden form field
(data-transform="json-decode") updated with their value, so
route parameters receive them through the normal form pipeline.
Parameters and conversion. The router
(src/drafter/router/) matches submitted form fields to route
parameters by name and converts values using the parameter's
annotation, through a registry of converters (scalars, datetime
types, files, images, the map/location dataclasses). Friendly,
two-tier error messages (src/drafter/data/error_explainer.py)
are a design commitment, not a nicety.
DOM structure. src/drafter/site/site.py is the single
source of truth for element ids, all suffixed --. User content
renders inside #drafter-body--; the debug panel
(#drafter-debug--, TypeScript under js/src/debug/) is a
sibling of the form, which is why styling body leaks into the
debugger but styling the app container does not.
See it
The life of one button click, in web mode:
visitor clicks Button("Feed", "feed")
-> ClientBridge catches the submit event,
collects form fields (applying data-transform decodings)
-> ClientServer receives the request envelope
-> router resolves the "feed" route, binds state + parameters
(converting values per annotations)
-> the route function runs; returns Page / Fragment / Update /
Redirect
-> ClientServer records history, runs verification,
answers with a response payload
-> ClientBridge patches the DOM (full page or targeted
fragment), notifies the debug panel
Nothing in that loop crosses a network.
What this means for your code
- Logic belongs in the ClientServer side; the bridge should stay ignorant. If a change makes the bridge understand site semantics, it is probably in the wrong place.
- New custom-element components must keep their TypeScript
implementation in sync with the Python
ComponentContract; the contract is what the router trusts. - The
--id suffix andDRAFTER_TAG_IDSare the only sanctioned way to reference structural elements. - The two-run model means library code cannot assume it is in a
browser or in CPython; check with helpers like
is_pyodide()rather than importing browser-only modules at module load.
Where people get confused
- The dev server in app mode serves the page and the source; it does not run routes. All routing happens in the browser, which is why "server logs" for route calls do not exist.
ClientServerresponses look like HTTP but never touch a socket; grepping for a web framework in the request path finds nothing because there is none.- Skulpt appears throughout the codebase as a second engine. It is legacy, semi-supported, and outside the validation baseline; Pyodide is the default and the future.
Go deeper
ARCHITECTURE.mdat the repository root: the full document (configuration system, builder, persistence, multi-instance embedding, hosting).- Writing documentation: the docs pipeline, which exercises the multi-instance embedding.
- Python in the browser: the student-facing version of the runtime story.