Skip to main content

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

  1. Stable keys, never indexes. key=item.id on 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.
  2. 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).
  3. Allowlist both ends. Python allowed_types={...} rejects unknown node types; JS isKnownComponent renders only functions (filters MUI's colors, createTheme, etc.). Never render registry[arbitraryString].
  4. One render per tick, send only on change. tick() then if ops:. Hash-gating makes idle ticks free.
  5. Inputs debounce client-side (150ms default). Server never sees keystroke storms; use flush() on submit.
  6. Caller-owned seq, snapshot on gaps. The tick loop numbers patches; the store drops stale/duplicates and answers gaps with one snapshot round-trip.
  7. Keep render(state) pure and cheap. No DB, no IO: derive from the already-computed tick state. One Layout per session.
  8. Safe event defaults, names like the DOM. preventDefault is true unless opted out (a reload is session death); stopPropagation stays opt-in; throttleMs rate-caps sliders and guards double-submit. Same names as the browser, defaults chosen for the socket.
  9. Mark renders @pure, version host state. Pure renders skip render CPU when the version is unchanged (unmarked always re-render: correct by default). strict=True double-renders in dev to catch nondeterminism.
  10. Component-local UI state via hooks. use_state/use_memo inside @component bodies keeps collapsed flags and drafts out of tick state; setters schedule through invalidate(). Distinct key= per instance, hooks unconditional and order-stable, or it raises loudly. Effect cleanups that raise abort the render instead of being swallowed.
  11. Async handlers via adispatch. Handlers may be async def (e.g. await db.save() on click); sync dispatch refuses them loudly instead of silently dropping the coroutine. Pattern: await adispatch(...), then invalidate().

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/order maps for huge reorderable lists, keystroke ackSeq if 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)

Source distribution for flyrail 0.1.0
File Size Uploaded
flyrail-0.1.0.tar.gz 37.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for flyrail 0.1.0
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page