Skip to content

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 Layout by hand with serve_layout()
  • Why Connection is yours to construct, and what carrier means

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.

  1. Serve the client bundle over HTTP, so the page can import it.
  2. Serve web modules over HTTP, so the client can import any importSource you emitted.
  3. Return a document containing a mount element and a mountReactPy(...) call.
  4. Accept a websocket and run one Layout against 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
"""A ReactPy backend with no framework: routing, static files, and Layout."""

import asyncio
from pathlib import Path

import orjson
from asgi_tools import ResponseHTML
from servestatic import ServeStaticASGI

import reactpy
from reactpy import component, html, use_state
from reactpy.config import REACTPY_WEB_MODULES_DIR
from reactpy.core.hooks import ConnectionContext
from reactpy.core.layout import Layout
from reactpy.core.serve import serve_layout
from reactpy.types import Connection, Location

PATH_PREFIX = "/reactpy/"
STATIC_PATH = f"{PATH_PREFIX}static/"
MODULES_PATH = f"{PATH_PREFIX}modules/"

STATIC_DIR = Path(reactpy.__file__).parent / "static"


@component
def counter():
    count, set_count = use_state(0)
    return html.div(
        html.h1(f"Count: {count}"),
        html.button({"onClick": lambda event: set_count(count + 1)}, "Increment"),
    )


def index_html() -> str:
    mount = (
        "mountReactPy({mountElement: document.getElementById('app'),"
        "  pathPrefix:'" + PATH_PREFIX + "', componentPath:'counter/'});"
    )
    return (
        "<!doctype html><html><head><meta charset='utf-8'><title>Custom backend</title>"
        "<script type='module'>"
        f"import {{ mountReactPy }} from '{STATIC_PATH}index.js';"
        + mount
        + "</script></head><body><div id='app'>Loading...</div></body></html>"
    )


class Backend:
    def __init__(self) -> None:
        self.components = {"counter": counter}
        self.static_app = ServeStaticASGI(
            self.not_found, root=STATIC_DIR, prefix=STATIC_PATH
        )
        self.modules_app = ServeStaticASGI(
            self.not_found, root=REACTPY_WEB_MODULES_DIR.current, prefix=MODULES_PATH
        )

    async def not_found(self, scope, receive, send) -> None:
        await ResponseHTML("Resource not found on this server.", status_code=404)(
            scope, receive, send
        )

    async def __call__(self, scope, receive, send) -> None:
        path = scope["path"]
        if scope["type"] == "http" and path.startswith(STATIC_PATH):
            return await self.static_app(scope, receive, send)
        if scope["type"] == "http" and path.startswith(MODULES_PATH):
            return await self.modules_app(scope, receive, send)
        if scope["type"] == "http" and path == "/":
            return await ResponseHTML(index_html())(scope, receive, send)
        if scope["type"] == "websocket" and path.startswith(PATH_PREFIX):
            return await self.serve_component(scope, receive, send)
        return await self.not_found(scope, receive, send)

    async def serve_component(self, scope, receive, send) -> None:
        name = scope["path"][len(PATH_PREFIX) :].strip("/")
        if name not in self.components:
            raise RuntimeError(f"Attempting to use an unregistered root {name}.")

        await send({"type": "websocket.accept"})
        queue: asyncio.Queue = asyncio.Queue(1000)

        async def pump() -> None:
            while True:
                event = await receive()
                if event["type"] == "websocket.disconnect":
                    return
                if event["type"] == "websocket.connect":
                    continue
                await queue.put(orjson.loads(event["text"]))

        async def recv():
            return await queue.get()

        async def send_model(message):
            await send(
                {"type": "websocket.send", "text": orjson.dumps(message).decode()}
            )

        task = asyncio.create_task(pump())
        connection = Connection(
            scope=scope,
            location=Location(path="/", query_string=""),
            carrier=None,
        )
        try:
            await serve_layout(
                Layout(ConnectionContext(self.components[name](), value=connection)),
                send_model,
                recv,
            )
        finally:
            task.cancel()


app = Backend()
1
# TODO

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. On asgi_tools.response.ResponseWebSocket the methods are send(msg) — which wraps a str as {"type": "websocket.send", "text": ...} and bytes as "bytes": ... — plus send_json(data). Send text frames. The stock send_json emits {"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_json overrides it to self._send({"type": "websocket.send", "text": orjson.dumps(data).decode()}). The client never sets socket.binaryType (static/index.js), so a binary frame arrives as a Blob, and JSON.parse(blob) throws SyntaxError: ... "[object Blob]" is not valid JSON — verified in headless Chrome. Reusing a stock asgi_tools websocket object instead of overriding send_json therefore produces a socket that connects, accepts, and then parses nothing.
  • receive() returns the payload by default, not the ASGI event. Without raw=True you get a str, and event["type"] then raises TypeError: string indices must be integers. Pass raw=True.
  • Under async with, the handshake is already done — do not send websocket.accept yourself. That is why the websocket.connect skip above tests for != "websocket.receive" rather than naming connect.

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 use scope["path"]. The browser reports where it is, and the executor parses it out of the websocket query string: ?path=%2Fdocs%2F&qs=a%3D1 becomes Location(path="/docs/", query_string="a=1"). If you hard-code path="/", every use_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. None is 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. ReactPyMiddleware already 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: Layout and serve_layout need 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_html for 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 Layout per websocket.
  • The message shapes are fixed: layout-update down, layout-event up.
  • Skip the websocket.connect event 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_layout needs.
  • Build the Layout per connection, and wrap the component in ConnectionContext or the connection hooks raise.
  • You populate Connection; the stock executors parse location from 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.
Authors: Archmonger