flyrail
Bring-your-own-frontend server-driven UI with a reactpy-style Python API. Python declares the UI, your React+MUI app renders it, your socket carries it, your tick drives it.
Why this exists
reactpy owns the whole React tree and its own socket protocol. rjsf
owns form rendering but not arbitrary layouts. flyrail splits the problem:
- Python (
flyrail/): declarative element helpers, per-session handler registry, per-component memoisation, gated keyed-list diffing, slot fast-path. Zero dependencies. - JS (
js/,flyrail-renderer): namespace component registry (import * as MUI), patch applier, client store with resync, debounced inputs. Zero dependencies (React is a peer).
See docs/features.md for what each capability buys you, with a runnable example each.
Message flow
tick: render(state) -> tree -> diff -> [] | patch ops -> {chan:ui,type:patch,seq,ops}
click: {chan:ui,type:action,handlerId,event?} -> dispatch -> mutate state -> next tick emits
hot: set_slot(name, value) -> {chan:ui,type:slot} (bypasses diff)
gap: seq skip -> resync-request -> snapshot(state, seq) -> {chan:ui,type:snapshot,tree}
Python quickstart
from flyrail import Layout, Stack, Text, Button
from flyrail.transport import ui_envelope_patch
class Panel:
def render(self, s):
return Stack(
Text(f"speed: {s.speed}"),
Button("Stop", on_click=self.stop), # like reactpy
)
def stop(self, state, event):
state.speed = 0
layout = Layout(Panel().render, allowed_types={"Stack", "Text", "Button"})
seq = 0
def on_tick(state):
global seq
if ops := layout.tick(state): # [] on idle ticks: send nothing
seq += 1
broadcast(ui_envelope_patch(ops, seq))
def on_ws(msg, state):
if msg.get("chan") == "ui" and msg.get("type") == "action":
layout.dispatch(msg["handlerId"], state, msg.get("event"))
elif msg.get("type") == "resync-request":
broadcast(layout.snapshot(state, seq + 1))
JS quickstart
import * as MUI from '@mui/material';
import { createRenderer } from 'flyrail-renderer';
import { createStore } from 'flyrail-renderer/store';
const { ServerNode } = createRenderer({ ...MUI }); // auto-registered, no switch
const store = createStore({ onResync: (req) => ws.send(JSON.stringify(req)) });
ws.onmessage = (e) => store.ingest(JSON.parse(e.data));
// render store.getTree() via <ServerNode node={tree} send={...} />
Hosting (pick one)
# 1. Fixed tick you own: Driver replaces the hand-rolled seq counter.
from flyrail import Driver
driver = Driver(layout)
def on_tick(state, version):
if env := driver.flush(state, version):
broadcast(env)
# 2. Async host: raw ASGI app, one session per connection. FastAPI:
# from fastapi import WebSocket; await create_ws_app(Panel().render)(scope, receive, send)
# (or mount in any ASGI framework; no fastapi dependency in this package)
from flyrail import create_ws_app
app = create_ws_app(Panel().render, state_factory=Sim,
on_message=lambda data, state: handle_telemetry(data))
# 3. Naive host (no loop at all): invalidate + flush around every message.
driver.invalidate()
if env := driver.flush(state):
send(env)
Best practices
- Stable keys, never indexes.
key=item.idon every list child, both sides: Python handler ids embed the key, React reconciles by it. Reorder without keys = full remount + lost focus. Keys must be unique among siblings: duplicates share one handler id (last registration wins) and components reject them, plain nodes do not. - Slots for tick-rate values. Table rows, labels, progress:
Slot("rows")set_slot()bypass the tree diff. Structure goes through patches (rare), values through slots (every tick).
- Allowlist both ends. Python
allowed_types={...}rejects unknown node types; JSisKnownComponentrenders only functions (filters MUI'scolors,createTheme, etc.). Never renderregistry[arbitraryString]. - One render per tick, send only on change.
tick()thenif ops:. Hash-gating makes idle ticks free. - Inputs debounce client-side (150ms default). Server never sees
keystroke storms; use
flush()on submit. - Caller-owned seq, snapshot on gaps. The tick loop numbers patches; the store drops stale/duplicates and answers gaps with one snapshot round-trip.
- Keep
render(state)pure and cheap. No DB, no IO: derive from the already-computed tick state. OneLayoutper session. - Safe event defaults, names like the DOM.
preventDefaultis true unless opted out (a reload is session death);stopPropagationstays opt-in;throttleMsrate-caps sliders and guards double-submit. Same names as the browser, defaults chosen for the socket. - Mark renders
@pure, version host state. Pure renders skip render CPU when the version is unchanged (unmarked always re-render: correct by default).strict=Truedouble-renders in dev to catch nondeterminism. - Component-local UI state via hooks.
use_state/use_memoinside@componentbodies keeps collapsed flags and drafts out of tick state; setters schedule throughinvalidate(). Distinctkey=per instance, hooks unconditional and order-stable, or it raises loudly. Effect cleanups that raise abort the render instead of being swallowed. - Async handlers via
adispatch. Handlers may beasync def(e.g.await db.save()on click); syncdispatchrefuses them loudly instead of silently dropping the coroutine. Pattern:await adispatch(...), theninvalidate().
Docs
- How it works — architecture, tick/action/resync sequences, render pipeline, hook slots, scheduling, message catalog.
Layout
flyrail/ Python core (stdlib only)
core.py Stack/Text/Button/TextField/Slot, @component, @pure
hooks.py use_state/use_memo keyed slots
layout.py registry + diff + dispatch + slots + snapshot
driver.py dirty-flag scheduling for tick/async/naive hosts
asgi.py framework-free websocket sessions
transport.py multiplex envelope + tick sketch
js/ flyrail-renderer (zero-dep ESM + tsx)
protocol.mjs envelopes, applyOps, debounce (node-tested)
store.mjs seq tracking, gap->resync, slots (node-tested)
ServerNode.tsx MUI-bound renderer (imports protocol.mjs)
example/ hmi_demo.py runnable narrative of the wire
tests/ focused unittest suites + public-API loopback
Tests
PYTHONPATH=. python3 -m unittest discover -s tests -v # 67 tests
node --test js/protocol.test.mjs js/store.test.mjs # 24 tests
PYTHONPATH=. python3 example/hmi_demo.py # narrated wire demo
Non-goals / roadmap
- Not a form validator (use your backend validation + error slots), not a JSON-Schema renderer (see rjsf), not a full reactpy replacement (sync core with no async effects; bring your own frontend instead of an owned tree).
- Roadmap: vitest + React Testing Library for
ServerNode,byId/ordermaps for huge reorderable lists, keystrokeackSeqif loss-less input sync is ever needed, registry packaging.
Metadata
Release files for flyrail 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| flyrail-0.1.0.tar.gz | 37.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| flyrail-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 58.3 kB
Release files / flyrail-0.1.0.tar.gz
| Download URL | flyrail-0.1.0.tar.gz |
|---|---|
| Size | 37.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
522d599cbddbaadfbf0780b0b6d5f10b10429b8085777391ceb004a022843fdb
|
|
BLAKE2b-256 checksum How to use checksums |
463eb1196723c8d6f4ab8ba1a0029de39bc9d0718883a83f60240e9ddbd79126
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.22
|
Release files / flyrail-0.1.0-py3-none-any.whl
| Download URL | flyrail-0.1.0-py3-none-any.whl |
|---|---|
| Size | 20.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
57239fa28a8408aadbf6cb5169f1ac2f9c38a9de35e5e6dac7c47e5071aa4eb5
|
|
BLAKE2b-256 checksum How to use checksums |
e4942f9dbde8177dabebaafe2c3de90392a5e4b45d0f1a3ad12046bcb7d4858f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.22
|