@playfast/reform-remote
v1.0.1
Published
Run a reform scene's logic on the server and stream its rendered UI to a thin client over any duplex transport.
Maintainers
Readme
@playfast/reform-remote
Run a reform scene's logic on the server; render its UI on a thin client over a transport.
A fourth consumer of a reform Scene, alongside
@playfast/reform-react,
@playfast/reform-react-native, and
@playfast/reform-proof. State, events, reducers,
async/remote data, and compositions all run server-side; the client receives a serialized
tree of rendered UI contracts and renders them with local presentations. The wire carries only
data — UI-tree patches one way, trigger invocations the other — so there is no API layer to write.
This builds on reform's existing seams: the ui contract already separates logic from
presentation, the CaptureSink already serializes the rendered surface headlessly (the same
mechanism proofs use), and the schema-first ui form (ui(name, { props, events })) carries
the wire schemas that make props and trigger payloads typed and runtime-validated at the seam.
See REMOTE_UI.md for the design.
Install
npm install @playfast/reform-remote @playfast/reform effect reactAdd a transport adapter for the wire you want — inMemoryTransportPair ships here; for real
sockets pair with @playfast/reform-remote-node,
@playfast/reform-remote-bun, or
@playfast/reform-remote-web.
Key concepts
| Concept | What it does |
| --- | --- |
| makeRemoteServer(scene) | Renders a Scene to a WireTree; render()/renderDiff() emit full tree/patches; invoke(handle, payload) fires a trigger. |
| renderWireTree(tree, { views, invoke }) | Folds a WireTree back into React using local presentations. |
| remoteContract({...}) / remoteViews<C>({...}) | The trpc-style typesafe seam — server declares the contract, client implements exactly it. |
| serve({ scene, transport }) / connect({ transport, views }) | Bind both ends to any RemoteTransport. |
| inMemoryTransportPair() | In-process duplex RemoteTransport (the simplest concrete adapter). |
| <RemoteUI transport views /> / useRemoteUI | React binding that owns connect, subscription, re-render, teardown. |
| useConnectionStatus(reporter) | Reads a transport's live StatusReporter status. |
How it fits together
server client
┌────────────────────────┐ ┌──────────────────────────┐
Scene ─► makeRemoteServer ─► WirePatch[] ──► Wire.apply ─► renderWireTree
▲ (renders, (folds tree) (local views)
│ encodes props, │
│ registers triggers) ▼
└──────────── invoke(handle, payload) ◄──── event callback fires ─┘makeRemoteServer(scene)— renders the scene to aWireTree, encoding each contract's props via its schema and registering triggers behind stable${nodeId}:${event}handles.render()/renderDiff()produce a full tree / patches;invoke(handle, payload)decodes the payload and fires the trigger (aHigh-priority dispatch into the Bus).renderWireTree(tree, { views, invoke })— turns aWireTreeback into React using the presentations inviews(the bundled vocabulary), reconstituting event props as callbacks.remoteContract({...})/remoteViews<AppContract>({...})— the trpc-style typesafe seam. The SERVER declares its UI shape ONCE and exportstypeofit; the CLIENT imports only that TYPE and implements it.remoteViews<AppContract>type-checks the client's view set to implement EXACTLY the server's contracts — a missing, extra, or wrong-contract view is a compile error. Each view is a plainUi.make(props, slots, events all typed from its contract); the render name + props schema come from the contract, so they can't drift. The result is a brandedRemoteViewSet<AppContract>thatconnect/renderWireTree/<RemoteUI>accept (an unbrandedRecordis rejected — the contract check is end-to-end, with no widening):// server (or shared) — the AppRouter analog: export const AppContract = remoteContract({ TodoApp: TodoAppUi, TodoItem: TodoItemUi }) export type AppContract = typeof AppContract // client — imports the TYPE only: import type { AppContract } from '…/contracts' const TodoItem = Ui.make(TodoItemUi, (props, _slots, events) => ( <li onClick={() => events.toggle({})}>{props.text}</li> // props/events fully typed )) export const views = remoteViews<AppContract>({ TodoApp: /* … */, TodoItem })serve({ scene, transport })/connect({ transport, views })— bind both ends to anyRemoteTransport(WebSocket, postMessage, in-memory). The server sends aSnapshot(full tree, replace) on connect andPatches(apply) thereafter, so a reconnecting client re-syncs cleanly.inMemoryTransportPair()— the in-process duplex adapter the tests driveserve/connectover without a socket; the simplest concreteRemoteTransport.<RemoteUI transport views />/useRemoteUI(transport, views)— the React binding. It owns the whole client side —connect, the patch subscription, re-render on each frame, and teardown on unmount — so the call site holds nouseSyncExternalStoreorbinding.node(). Pair it withuseConnectionStatus(transport)to read a transport's live status (e.g. a reconnecting badge):const App = () => { const status = useConnectionStatus(transport) // 'connecting' | 'open' | 'reconnecting' | 'closed' return ( <> {status !== 'open' ? <div className={`conn ${status}`}>{status}…</div> : null} <RemoteUI transport={transport} views={views} /> </> ) }
Transport adapters
| Package | Role | Built on |
| --- | --- | --- |
| inMemoryTransportPair (here) | in-process duplex | — |
| @playfast/reform-remote-node | WebSocket server | ws |
| @playfast/reform-remote-bun | WebSocket server | Bun.serve |
| @playfast/reform-remote-web | WebSocket client (factory, auto-reconnect) | global WebSocket |
License
MIT
