Skip to content

Protocol Structure

Overview

ReactPy renders on the server and paints in the browser. A single websocket carries four message types: the server sends "layout-update" patches, the client sends "layout-event" interactions, and a handler which wants to read the browser goes back and forth over "event-proxy-request" / "event-proxy-response".

server --{"type": "layout-update", "path": ..., "model": ...}--------------->  client
server <--{"type": "layout-event", "target": ..., "data": [...]}------------  client
server --{"type": "event-proxy-request", "eventId": ..., "path": ...}------->  client
server <--{"type": "event-proxy-response", "requestId": ..., "value": ...}-  client

The last two are new, and they change what an event is. Nothing about the DOM event is serialized at all: the client keeps the live object in a registry of its own and sends the server a handle. What the server can do with that handle — and what happens when the browser never answers — is the subject of The event proxy round trip.


The connection

Every mounted component opens exactly one websocket. The client builds it from the pathPrefix and componentPath it was handed at mount time (static/index.js, mountReactPy):

ws://<host><pathPrefix><componentPath>?path=<page path>&qs=<page query string>

With the default REACTPY_PATH_PREFIX of "/reactpy/" and a component registered as "main.counter/", mounting on /page?tab=1 opens:

ws://127.0.0.1:8000/reactpy/main.counter/?path=%2Fpage&qs=%3Ftab%3D1

Two things about that URL are load-bearing:

  • The trailing slash after the dotted path is required. The server matches the websocket path against

    ^{path_prefix}(?P<dotted_path>[a-zA-Z0-9_.]+)/$
    

    which ends in /$. Without the slash the route never matches, the request falls through to your own ASGI app, and the handshake is refused. Verified from a page: ws://…/reactpy/main.counter/?path=/ opens, ws://…/reactpy/main.counter?path=/ fails with Unexpected response code, and the console logs [ReactPy] Failed to connect!.

  • path and qs are how the component learns its URL. ReactPy runs on the server, where the browser's location does not exist. The client copies window.location.pathname into path and window.location.search into qs; the server parses them back out (executors/asgi/middleware.py, _location_from_websocket_query_string) into the Location that use_location() returns. They are percent-encoded and carried in the websocket's own query string, so a server that normalises the handshake URL cannot silently rewrite them.

Reconnection does not re-read the mount

When the socket drops, the client waits and reconnects. Each attempt refreshes path and qs from the current page, but it never re-reads componentPath or the connect options — those were captured when the element mounted. A client-side navigation that changes which component belongs on the page needs a fresh mount, not a reconnect.

The client retries with exponential backoff: REACTPY_RECONNECT_INTERVAL (750 ms) times REACTPY_RECONNECT_BACKOFF_MULTIPLIER (1.25) per attempt, capped at REACTPY_RECONNECT_MAX_INTERVAL (60 s), for REACTPY_RECONNECT_MAX_RETRIES (150) tries. Those are arguments to mountReactPy(...); see ReactPyMiddleware for the bootstrap that emits them.


The server-to-client message

One message type goes down the wire. Its keys are fixed (reactpy.types.LayoutUpdateMessage):

Key Value Meaning
"type" "layout-update" Always this literal.
"path" str A JSON Pointer into the client's copy of the model. "" means replace everything.
"model" dict The value to place at that pointer.

The model is JSON, not a VdomDict. Six key names are legal (reactpy/types.py, ALLOWED_VDOM_KEYS), and only tagName is required:

Key Value Notes
"tagName" str The DOM tag, or "" for a wrapper element.
"children" list Nested models or plain strings.
"attributes" dict Sent to the client as written, and written to the DOM essentially verbatim. Two ReactPy-specific exceptions: key is read for reconciliation and consumed, never becoming an attribute, and _reactpy_ack_seq is deleted by the client before render.
"eventHandlers" dict Handler name to target spec, described below.
"inlineJavaScript" dict Handler name to JavaScript source string.
"importSource" dict Marks an element as coming from a JavaScript module.

Any other key name is rejected by VdomDict while the element is being built (reactpy/types.py):

ValueError: Invalid keys: {'bogus'}.

The wire schema in core/vdom.py, VDOM_JSON_SCHEMA is a looser thing: it lists these six plus "error" (which the layout injects when a component raises), and it is only consulted when REACTPY_CHECK_VDOM_SPEC or REACTPY_DEBUG is on.

Here is a real mount message for a counter, captured off the wire:

{
  "type": "layout-update",
  "path": "",
  "model": {
    "tagName": "",
    "children": [
      {
        "tagName": "",
        "children": [
          {
            "tagName": "",
            "children": [
              {
                "tagName": "div",
                "children": [
                  {"tagName": "h1", "children": ["count is 0"]},
                  {
                    "tagName": "button",
                    "eventHandlers": {
                      "onClick": {
                        "target": "/0/0/0/1:onClick",
                        "preventDefault": false,
                        "stopPropagation": false
                      }
                    },
                    "children": ["Add"]
                  }
                ]
              }
            ]
          }
        ]
      }
    ]
  }
}

Wrapper elements

Read from the top, that model is three elements with "tagName": "" before the div appears. ReactPy needs somewhere to put a component's output that is not itself a DOM node, and it does not reuse your tag for the purpose.

Do not depend on how many there are. The count is an implementation detail that shifts with structure: the same component produced one wrapper rendered bare through Layout, and three behind the ASGI executor's context provider. Never index the tree by position, and never assume the root has a tag name. The client renders a wrapper as a fragment, so it costs you nothing on screen.

Patches, not re-renders

The server does not resend the tree. When only an inner component's state changes, it sends the subtree rooted where that component renders:

{
  "type": "layout-update",
  "path": "/children/0/children/0",
  "model": {
    "tagName": "",
    "children": [
      {
        "tagName": "div",
        "children": [
          {"tagName": "h1", "children": ["count is 1"]},
          {
            "tagName": "button",
            "eventHandlers": {
              "onClick": {
                "target": "/0/0/0/1:onClick",
                "preventDefault": false,
                "stopPropagation": false
              }
            },
            "children": ["Add"]
          }
        ]
      }
    ]
  }
}

That is the whole reply to a click: one-third the depth of the mount, and the only text that changed is inside it. The pointer indexes the model, and path: "" replaces the root — which is what a state change in the topmost component produces.

A patch replaces rather than merges, so whatever subtree it carries is complete: keys you would expect to be sticky, eventHandlers among them, are resent inside it. Nothing is deleted by a patch; a smaller subtree simply arrives with fewer children.

Errors arrive as a model

A component that raises during render does not kill the socket. The layout sends an element whose tagName is empty and whose error is the message, which the client prints in a <pre> in place of the component:

{
  "type": "layout-update",
  "path": "",
  "model": {
    "tagName": "",
    "error": "ValueError: Expected a VDOM element dict, not {'noTag': True}"
  }
}

The schema ties the two together: when error is present, tagName must be empty. Without REACTPY_DEBUG=1 the same element carries "error": "" — an empty string, which the client renders as nothing, so the component silently disappears instead of showing a blank box.


The client-to-server message

One message type goes up (reactpy.types.LayoutEventMessage):

Key Value Meaning
"type" "layout-event" Always this literal.
"target" str The handler's target, copied from the model.
"data" list The arguments for the handler. Usually one event handle.
"seq" int Optional. A client-assigned counter, per element.

A real click on the button above:

{
  "type": "layout-event",
  "target": "/0/0/0/1:onClick",
  "data": [{"__reactpy_event__": {"id": "lzk4q1-0"}, "type": "click"}],
  "seq": 1
}

How data is built

The client does not serialize the DOM event. It stores the live object in a registry and sends a handle — {"__reactpy_event__": {"id": ...}} — plus "type", the one plain value the client attaches because the server needs it to name the event and it is too cheap to be worth a round trip (vdom.tsx). Anything else the handler wants is fetched, per property, over the proxy messages described below.

That replaced the old event-to-object package, which walked the event and its target: enumerable properties, plus value, checked, files, type and name read off the element even though they are accessors, plus dataset, plus every named control of a form on submit. A single <input> used to put the whole element on the wire — around ninety keys, of which a handler reads one. What goes over the wire for an event now is a handle and a type: on the order of a hundred and fifty bytes.

The consequence for you: the second element of data is whatever the event was dispatched with, and the first is the handle — a dict of the marker and nothing but the id, never a copy of the event. Handlers are still free to take more than one argument — vdom.tsx builds the list from the event plus any extra values the dispatch supplied — so index data positionally rather than counting on a specific key surviving.

Unpacking, and why the two handler forms differ

Layout._process_event_queue turns data into arguments and calls the handler — but not the same way for both kinds of handler (core/events.py, to_event_handler_function):

function(*data)   # a plain coroutine: the list is unpacked
function(data)    # an EventHandler: the list itself

So async def f(event) receives the event when passed directly, while async def f(data) must index into the list once wrapped in EventHandler. Converting a working handler just to add prevent_default breaks it in a way the traceback does not explain. Both forms are coroutines: EventHandler.__init__ and to_event_handler_function each run the value through check_is_async_handler, so a sync function or a lambda raises at element-build time. Common Props shows both shapes side by side.

The event proxy round trip

A handler which reads await event.target.value makes the server ask the browser. Layout.set_event_proxy_sender wires the backend to the connection, and the request that goes down is one per property (_event_proxy.py):

{
  "type": "event-proxy-request",
  "requestId": "f5a9b645f0a145e984b7c2d8e19f753c",
  "eventId": "lzk4q1-0",
  "path": ["target", "value"],
  "call": false,
  "args": [],
  "reply": true,
  "maxDepth": 10
}

The two ids come from different sides and mean different things. "requestId" is a fresh uuid4().hex per request, minted by the server so it can match the answer to whoever asked. "eventId" is the client's own registry key for the object behind the handle — an opaque string such as "lzk4q1-0" from EventProxyRegistry.register, never a handler target — so build it from the handle you were given and do not try to guess one.

The client answers with the value, or with a failure it can name:

{
  "type": "event-proxy-response",
  "requestId": "f5a9b645f0a145e984b7c2d8e19f753c",
  "ok": true,
  "value": "typed text",
  "error": null,
  "code": null
}

Two requests raised in the same synchronous stretch — a batch via await event.read(...), or an asyncio.gather of reads — are flushed together as {"type": "event-proxy-request", "requests": [ ... ]} and answered the same way with a "responses" array. Requests which genuinely depend on each other (await one, then the next) cost one round trip each.

A client that never answers does not hang the handler. After REACTPY_EVENT_PROXY_TIMEOUT seconds the read raises EventProxyTimeout. Strikes are counted by the connection's EventProxyBackend — one backend per layout, so one per connection — and one unanswered message costs one strike however many properties it was asking for; after REACTPY_EVENT_PROXY_MAX_TIMEOUTS consecutive unanswered round trips, live access is switched off for that connection and every later read raises EventProxyUnavailable at once rather than stalling. Binding a new sender — a reconnect — clears the strikes and gives the client another chance. REACTPY_EVENT_PROXY_MAX_DEPTH bounds how deep a returned object is expanded. See Configuring ReactPy.

The client is the one that decides what may be read. It refuses the members that would let a server reach outside the event — window, document, parentNode, eval, setTimeout, and friends (BLOCKED_MEMBERS in event-proxy.ts) — and it also refuses __reactpy_event__, __reactpy_ref__, and anything matching /^on[a-z]/i, so a handler cannot read the handle back off its own event or fish for another one. A refusal is an error on the response, not a value.

The client also decides how long an event stays readable, and it enforces that two different ways. An event which the server never once asked about is forgotten after LIVE_OBJECT_TTL_MS (60 s); one the server has read is exempt, because the handler may simply be slow. What bounds the page overall is MAX_LIVE_OBJECTS (512): past that, the event the server has gone longest without reading is dropped. Either way the id keeps working as an address and keeps explaining why it failed — "not-found" for one that aged out unread, "evicted" for one dropped to make room while this side was still reading it — which is the difference between EventProxyNotFound and EventProxyEvicted. An "evicted" points at the page rather than at your handler.

Build a non-Python client from this page and you must implement both proxy message types as well, or every await on an event raises. The stock client does, which is why a handler written against it works.

The target string

"/0/0/0/1:onClick" is two parts joined by a colon:

  • Before the colon, the element's key path: one segment per level, where each segment is that child's key if it has one and its index otherwise (core/layout.py, _get_children_info and _make_element_model_state). Patch path\ s, by contrast, are always positional (.../children/<index>). The two line up only while nobody sets a key — give a child {"key": "row-3"} and its target segment becomes row-3 while its patch path stays children/2.
  • After the colon, the attribute name the handler was declared under. It is whatever key you used, which is why {"onMyThing": f} produces a target ending in :onMyThing.

Treat the whole thing as an opaque handle. It is generated per render and is not meant to be constructed by hand; two structurally identical elements get distinct targets because their index paths differ.

Events for unmounted elements are dropped, not queued

If the server has no handler for a target — because the element was removed since the client last rendered, or the target was mistyped — the event is discarded with an informational log and the socket stays open:

Ignored event - handler '/99/99:nope' does not exist or its component unmounted

ReactPy retries the lookup three times at 10 ms intervals first, to cover a handler that is briefly absent mid-render. That window is a race guard, not a queue: nothing is replayed later. A click that happens while the socket is down is held in a client-side send buffer and flushed if the connection reopens — but once REACTPY_RECONNECT_MAX_RETRIES is exhausted the client stops trying, and everything buffered stays unsent.

seq and typed input

Typing needs a protocol answer that a plain request/response does not give. Each keystroke sends an event carrying a counter the client incremented for that element. The server records the highest number it has finished handling, per event name for that target, and on the next render of an <input>, <select>, or <textarea> stamps that whole mapping into the element's attributes under _reactpy_ack_seq — not one number for the element, because a single counter would let one event's progress stand in for another's (core/layout.py, _inject_event_ack_seq):

{
  "type": "layout-update",
  "path": "/children/0/children/0",
  "model": {
    "tagName": "",
    "children": [
      {
        "tagName": "input",
        "attributes": {"value": "hello world", "_reactpy_ack_seq": {"onInput": 1}},
        "eventHandlers": {
          "onInput": {
            "target": "/0/0/0:onInput",
            "preventDefault": false,
            "stopPropagation": false
          }
        }
      }
    ]
  }
}

The client compares that number against its own counter to decide whether the server has caught up with the latest keystrokes. If the server is still behind, the client keeps what the user typed instead of overwriting the box with a stale value. Without it, a slow handler erases text mid-word — the classic complaint about server-rendered inputs.

The value lives in a dedicated attribute rather than among your own so it cannot collide with, or leak into, a real DOM attribute. It is added only to those three tags, and only once at least one event has arrived for the element.

Debounce and throttle do travel on the wire

They change when events leave the browser, not what is in them — but the layout still has to tell the client about them. core/layout.py, _serialize_event_handler copies both into the handler's model entry whenever they are not None, and vdom.tsx reads them back off that entry. @event(handler, debounce=250, throttle=500) arrives as:

{
  "onClick": {
    "target": "/0:onClick",
    "preventDefault": false,
    "stopPropagation": false,
    "debounce": 250,
    "throttle": 500
  }
}

Both are omitted entirely when None, which is what keeps the common case minimal. On arrival the client validates each with isValidDebounce — finite, non-negative integer — and an invalid one is dropped with a console.warn naming the handler rather than an error, leaving the handler live but un-debounced. Build a non-Python client from this page and you must carry these two fields; skipping them silently loses the throttling. See Common Props.

One further key may appear in that entry, and it is new with this release: "stopImmediatePropagation". It is a boolean like its two neighbours, but unlike preventDefault and stopPropagation — which are always sent — it is omitted rather than sent as false: _serialize_event_handler in core/layout.py adds the key only when the handler asked for it, so read its absence as false. @event(stop_immediate_propagation=True), or a detected event.stopImmediatePropagation() in the handler's body, is what puts it there. The client then calls stopImmediatePropagation() on the event before running the handler, so the other listeners on that same element are skipped too (vdom.tsx).


Watching it yourself

The protocol is plain JSON text frames, so an ordinary websocket client is a sufficient observer. Start this app:

 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
from starlette.applications import Starlette
from starlette.responses import HTMLResponse
from starlette.routing import Route

from reactpy import component, html, use_state
from reactpy.executors.asgi import ReactPyMiddleware
from reactpy.executors.utils import server_side_component_html


@component
def counter():
    count, set_count = use_state(0)

    async def increment(_event):
        set_count(count + 1)

    return html.div(
        html.h1(f"count is {count}"),
        html.button({"onClick": increment}, "Add"),
    )


async def home(request):
    return HTMLResponse(
        "<!doctype html>"
        "<html><body>"
        + server_side_component_html("root", "", "main.counter/")
        + "</body></html>"
    )


my_app = ReactPyMiddleware(
    Starlette(routes=[Route("/", home)]),
    root_components=["main.counter"],
)
1
# TODO

Then read the mount message, send a click, and read the patch:

 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
import asyncio
import json

import websockets

URL = "ws://127.0.0.1:8000/reactpy/main.counter/?path=%2F"


async def main():
    patches = asyncio.Queue()

    async with websockets.connect(URL) as socket:
        mount = json.loads(await socket.recv())
        print("1. the mount update:", mount["path"] or "(root)")
        print(json.dumps(mount["model"], indent=2))

        target = find_handler_target(mount["model"])
        print("\n2. sending a click to", target)
        await socket.send(
            json.dumps(
                {
                    "type": "layout-event",
                    "target": target,
                    # The client keeps the DOM event and sends a handle, not a copy
                    # of it. "type" is the one plain value sent along up front.
                    "data": [
                        {"__reactpy_event__": {"id": "lzk4q1-0"}, "type": "click"}
                    ],
                    "seq": 1,
                }
            )
        )

        # One reader for the whole connection: answer proxy requests as they arrive
        # and hand everything else to the caller. Two consumers on one websocket
        # would race for each frame. With a handler which only calls a state setter
        # — the counter this observes — no proxy request is ever sent, and this task
        # simply forwards patches.
        reading = asyncio.create_task(read(socket, patches))
        try:
            patch = await asyncio.wait_for(patches.get(), timeout=10)
            print("\n3. the reply is a patch at path:", repr(patch["path"]))
            print(json.dumps(patch["model"], indent=2))
        finally:
            reading.cancel()


async def read(socket, patches):
    async for raw in socket:
        message = json.loads(raw)
        if message.get("type") == "event-proxy-request":
            await answer_requests(socket, message)
        else:
            await patches.put(message)


async def answer_requests(socket, message):
    """Reply to every property path the server is asking about"""
    requests = message.get("requests", [message])
    for request in requests:
        print("   the server asks for", request["path"])
    if "requests" in message:
        await socket.send(
            json.dumps(
                {
                    "type": "event-proxy-response",
                    "responses": [answer(request) for request in requests],
                }
            )
        )
    else:
        await socket.send(json.dumps(answer(requests[0])))


def answer(request):
    """The value behind one property path, in the shape the client must use"""
    return {
        "type": "event-proxy-response",
        "requestId": request["requestId"],
        "ok": True,
        "value": "typed text" if request["path"][-1] == "value" else None,
        "error": None,
        "code": None,
    }


def find_handler_target(model):
    """Return the target of the first event handler found in a model."""
    if isinstance(model, dict):
        for spec in (model.get("eventHandlers") or {}).values():
            return spec["target"]
        for child in model.get("children", []):
            found = find_handler_target(child)
            if found:
                return found
    return None


asyncio.run(main())
1
# TODO

Both were run against each other, which is where the mount message, the target, and the patch above come from. The counter's handler only calls a setter, so it never reads a property and the proxy messages never appear — the observer answers them, and gets nothing to answer, which is the "a read you never make costs nothing" rule from Responding to Events showing up on the wire. The two proxy frames further up the page were captured from a handler which does read, against the same middleware. Nothing about the protocol is private to ReactPy's client — given a mount element and a correct target, you can drive a server-rendered component from any language.

The server trusts the client completely

Nothing checks that target names a real handler, that data describes a real event, or that the sender is the page that mounted the component. Whatever your handler reads out of data, it read because the browser asserted it. Treat it as user input: await event.target.value is a string an attacker chose, at any length. The one thing which does not travel in the message at all is the ASGI scope: the server built that itself when the socket was accepted, which is why Use Connection and User Scope are the trusted side of the picture — even there, remember that the headers and peer address it reports are what the client chose to send.

The proxy did not weaken this; it moved the trust question rather than answering it. EventProxyBackend._route takes "value" from a response verbatim — no schema, no type check — so a crafted client can make any read return anything, including a list where a str was expected. And a call: true request runs a method in the page, which is why the client, not the server, holds the veto: BLOCKED_MEMBERS and the /^on[a-z]/i rule are the page's defence against a hostile server. Read that as what it is — the two sides do not trust each other, and only the page defends the page. Anything the client volunteers without being asked ("type") is the same kind of assertion as the rest of data.


Failure modes

Symptom Cause
Handshake refused, [ReactPy] Failed to connect! Missing trailing slash in componentPath, or a path_prefix that differs between the bootstrap and the server.
Socket opens then closes; server logs RuntimeError: Attempting to use an unregistered root component <path>. The dotted path is not in root_components. The socket is accepted before the component is resolved, so the failure is visible only in the server log.
Server log: Unknown message type: <type> Anything other than "layout-event" or "event-proxy-response". The server accepts those two and drops the rest, leaving the connection open.
Server log: Ignored event - handler ... does not exist or its component unmounted Stale or wrong target.
Console: Received message without type or Received message without handler A frame the client has no handler for. Ignored; the connection stays up.
A <pre> with an exception message appears in the page The component raised. See Errors arrive as a model.
Component renders once and never updates The handler never fires — usually a snake_case event name such as on_click, so no event is ever sent.
[ReactPy] Disconnected! then Reconnecting in … The socket dropped and backoff is in effect. If Connection max retries exhausted! follows, the page needs a reload.

  • Common Props — the attribute names that become eventHandlers and inlineJavaScript.
  • Usage — writing the model by hand as a plain dict.
  • ReactPyMiddleware — the paths the server claims, and the bootstrap that opens the socket.
  • Use Connection and Use Location — the server-side view of the two query parameters.
Authors: Archmonger