Creating Backends
Overview¶
A ReactPy backend is the piece that owns the HTTP routes, serves the client bundle, accepts the websocket, and drives a Layout against it. ReactPy and ReactPyMiddleware are two backends. This page is about writing the third: what the framework actually does for you, which parts you have to reproduce, and where the quiet traps are. It is also the fastest way to understand the request lifecycle, even if you never ship a custom backend.
You will learn
- The four jobs a backend performs, in the order a browser makes them necessary
- The three ASGI routes ReactPy needs, and the exact shapes of the messages on each
- How to run a
Layoutby hand withserve_layout() - Why
Connectionis yours to construct, and whatcarriermeans
What a backend owes you¶
Strip away the executors and there are exactly four responsibilities. Everything else — hot reloading, config validation, HTML assembly — is convenience on top.
- Serve the client bundle over HTTP, so the page can
importit. - Serve web modules over HTTP, so the client can
importanyimportSourceyou emitted. - Return a document containing a mount element and a
mountReactPy(...)call. - Accept a websocket and run one
Layoutagainst it, pumping messages in both directions.
The whole of the protocol is two message types. A rendered update goes down as {"type": "layout-update", "path": ..., "model": ...}; an event comes up as {"type": "layout-event", "target": ..., "data": [...]}. Protocol Structure documents them in full — this page is about plumbing, not the schema.
The job, in one file¶
This is a complete, working backend: no framework, no executor. It was run, and the counter incremented in a real browser.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 | |
1 | |
Run it with anything that speaks ASGI:
uvicorn main:app --port 8000
Read it top to bottom and the four jobs map onto four spots: static_app and modules_app are jobs 1 and 2, index_html() is job 3, and serve_component() is job 4. The rest is __call__ dispatching on scope["type"].
The routes¶
| Route | Serves | Supplied by |
|---|---|---|
{path_prefix}static/ | index.js and its chunks | reactpy/static/ inside the wheel |
{path_prefix}modules/ | authored JavaScript modules | config.REACTPY_WEB_MODULES_DIR |
{path_prefix}<name>/ (websocket) | the layout for <name> | your components mapping |
The trailing slash on the websocket path is not decoration. The stock executors match ^{path_prefix}(?P<dotted_path>[a-zA-Z0-9_.]+)/$, and the client builds that URL by concatenating pathPrefix and componentPath. A custom backend can match anything — but then componentPath has to agree with it, and pathPrefix must start and end with /. See The URL is built by concatenation for why a missing slash fails loudly in development and silently in production.
Serving the bundle¶
Two directories, both plain static files:
import reactpy
from pathlib import Path
from servestatic import ServeStaticASGI
from reactpy.config import REACTPY_WEB_MODULES_DIR
static_app = ServeStaticASGI(
not_found, root=Path(reactpy.__file__).parent / "static", prefix="/reactpy/static/"
)
modules_app = ServeStaticASGI(
not_found, root=REACTPY_WEB_MODULES_DIR.current, prefix="/reactpy/modules/"
)
servestatic is what the executors use, and it is a normal ASGI app — swap in your own static handler if you have one. Two details matter more than the choice of library.
Resolve REACTPY_WEB_MODULES_DIR.current at request time or shortly after, never at import time. It is an Option, and the executors repoint it. Reading it too early hands you a directory that later ceases to be the live one.
The <script type="module"> must be a module. The bundle uses static import for its two sibling chunks, so an ordinary <script> fails before any of your code runs, usually with a bare SyntaxError: Cannot use import statement outside a module in the console.
Serve the bundle from your own path at your peril
The client imports its chunks by a path derived from where index.js was loaded. Serve it under a prefix that does not also serve static/, and the chunk requests 404 while index.js itself loaded fine — which reads as a partial success and is easy to misdiagnose. Keep static/ a child of your mount prefix, exactly as the executors do.
Driving a layout¶
This is the part that is genuinely yours. Everything else is file serving.
The two primitives are Layout (core/layout.py) and serve_layout (core/serve.py). The latter is about twenty lines: it opens the layout, then runs one loop sending await layout.render() out and one loop calling await layout.deliver(event) on each received message — with the deliver not awaited to completion, so a slow handler never blocks the next event.
You supply send and recv coroutines:
async def send_model(message):
await send({"type": "websocket.send", "text": orjson.dumps(message).decode()})
async def recv():
return await queue.get()
Four things go wrong here, and all four are worth knowing before you hit them.
1. The first receive() is websocket.connect¶
await send({"type": "websocket.accept"}) completes the handshake, but the websocket.connect event is still sitting in the incoming stream. A pump loop that treats every event as a message will orjson.loads() a dict with no text key and die, typically with TypeError or KeyError: 'text', right at connection time. Skip it:
async def pump():
while True:
event = await receive()
if event["type"] == "websocket.disconnect":
return
if event["type"] == "websocket.connect":
continue
await queue.put(orjson.loads(event["text"]))
This is the single most common way a hand-written backend fails, because everything looks right until the client connects: the page serves, the bundle loads, and the socket opens and closes immediately. The console shows [ReactPy] Disconnected! followed by an endless reconnect loop.
2. recv() must not return None¶
If your recv returns None — which is what an unguarded await receive() does on disconnect — serve_layout calls layout.deliver(None) and raises inside a task group, surfacing as an ExceptionGroup that names neither your code nor the cause. Return a valid message or raise; never None.
3. ResponseWebSocket has no send_text, and receive() needs raw=True¶
asgi_tools.ResponseWebSocket is what the built-in executors use — ReactPyWebsocket subclasses it — and it works fine as a backend's socket. Two API details differ from raw ASGI, and getting either wrong fails inside an anyio task group with an ExceptionGroup that names neither your code nor the cause:
async with ResponseWebSocket(scope=scope, receive=receive, send=send) as ws:
async def pump():
while True:
event = await ws.receive(raw=True) # the ASGI dict, not the payload
if event["type"] == "websocket.disconnect":
return
if event["type"] != "websocket.receive":
continue
await queue.put(orjson.loads(event["text"]))
async def send_model(message):
await ws.send(orjson.dumps(message).decode()) # not send_text(...)
- There is no
send_text. Onasgi_tools.response.ResponseWebSocketthe methods aresend(msg)— which wraps astras{"type": "websocket.send", "text": ...}andbytesas"bytes": ...— plussend_json(data). Send text frames. The stocksend_jsonemits{"type": "websocket.send", "bytes": json_dumps(data)}, i.e. a binary frame, and that is precisely why the executors do not use it:executors/asgi/middleware.py,ReactPyWebsocket.send_jsonoverrides it toself._send({"type": "websocket.send", "text": orjson.dumps(data).decode()}). The client never setssocket.binaryType(static/index.js), so a binary frame arrives as aBlob, andJSON.parse(blob)throwsSyntaxError: ... "[object Blob]" is not valid JSON— verified in headless Chrome. Reusing a stockasgi_toolswebsocket object instead of overridingsend_jsontherefore produces a socket that connects, accepts, and then parses nothing. receive()returns the payload by default, not the ASGI event. Withoutraw=Trueyou get astr, andevent["type"]then raisesTypeError: string indices must be integers. Passraw=True.- Under
async with, the handshake is already done — do not sendwebsocket.acceptyourself. That is why thewebsocket.connectskip above tests for!= "websocket.receive"rather than namingconnect.
Raw send/receive from the ASGI callable, as the main example does, is the interface guaranteed to be present on any host; that is the reason it is the default here.
4. One layout per connection, and never share one¶
A Layout owns the hook state for a single client. Creating it before the request, or caching it by component name, means every browser tab shares one set of use_state values — one user's click changes another's screen. Build the Layout inside the connection handler, as above, and let it be garbage-collected with the socket.
The mirror-image bug is at the component level: a module-level element rendered by every layout is shared too, and mutation by one client leaks to all of them. That is covered in VDOM Mutations.
Connection and carrier¶
Wrap the component in ConnectionContext so the connection hooks work:
from reactpy.core.hooks import ConnectionContext
from reactpy.types import Connection, Location
connection = Connection(
scope=scope,
location=Location(path="/", query_string=""),
carrier=None,
)
layout = Layout(ConnectionContext(my_component(), value=connection))
Without that wrapper, use_connection(), use_location(), and use_scope() all raise RuntimeError: No backend established a connection. — even inside a perfectly healthy socket.
Connection is a three-field dataclass, and as the backend author you decide what goes in it:
scope— pass the websocket scope through verbatim.use_scope()returns it, and users expect headers and query strings to be there.location— the stock ASGI executors do not usescope["path"]. The browser reports where it is, and the executor parses it out of the websocket query string:?path=%2Fdocs%2F&qs=a%3D1becomesLocation(path="/docs/", query_string="a=1"). If you hard-codepath="/", everyuse_location()on your app reports the root regardless of the URL in the address bar. Parse it the same way if your app routes on it.carrier— whatever mediates the connection. Nothing in ReactPy reads it; the executors put their websocket object there so your components can reach it. Put your own in, and document what it is, because User Connection tells people to check its type rather than assume.Noneis fine if nothing needs it.
Connection is a read-only view by design
There is no deliver, no send, no close on it. Components talk to the client through state, effects, and event handlers — not by pushing messages. Your backend is the only code allowed to touch the socket, precisely because letting a component write to the wire bypasses the protocol and desynchronizes the client. If you find yourself wanting a push channel, read Communicating Data Between Server and Client first.
When to write one¶
Almost never, and it is worth naming the honest cases:
- Embedding ReactPy in an ASGI app you cannot wrap.
ReactPyMiddlewarealready covers this — it takes your existing app as its first argument. Reach for a custom backend only when the middleware's routing gets in your way, since it owns{path_prefix}*and nothing else. - A non-ASGI host. ReactPy's core is host-agnostic:
Layoutandserve_layoutneed only two coroutines, so a socket server on another loop is a routing problem, not a rewrite. This is the case the design genuinely supports. - Owning the document. If you generate HTML yourself and want ReactPy components inside it, you need the mount markup and the import map. You usually want
server_side_component_htmlfor that rather than a new backend — see Serving a component from your own route. - Understanding it. Writing the backend once is the clearest way to learn where the boundary between layout and transport is. It is a good exercise even when the result is deleted.
What it does not buy you: performance. serve_layout in your own code is the same loop. And you take on the pieces the executors handle quietly — config validation, the import-map injection, pyscript_setup, static caching, reconnect options, and the error paths in the four sections above.
Summary¶
- A backend serves the bundle, serves web modules, returns a mount document, and runs one
Layoutper websocket. - The message shapes are fixed:
layout-updatedown,layout-eventup. - Skip the
websocket.connectevent in your pump; it is the most common cause of an instantly-dying socket. - Speak raw ASGI on the socket; framework websocket wrappers do not have the methods
serve_layoutneeds. - Build the
Layoutper connection, and wrap the component inConnectionContextor the connection hooks raise. - You populate
Connection; the stock executors parselocationfrom the query string, not the scope path. - Components get a read-only view of the connection by design — that is the protocol's integrity, not an omission.
Related¶
- Protocol Structure — the two message types,
target, andseq - Client API — what
mountReactPyexpects from your URL layout - ReactPy and ReactPyMiddleware — the two built-in backends
- User Connection, User Scope, User Location — what your
Connectionfeeds - Communicating Data Between Server and Client — the supported alternatives to pushing