@playfast/reform-remote
v1.4.0
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). A composition declared withComposition.liveScopedhas itsactivatemounted on the committed wire node — the same lifetime stale trigger handles are revoked on. It runs once when that node first enters the committed tree, stays mounted for as long as the node is in it (whether a frame rendered the node again or kept it, and everything under it, from the last frame; props changes included, keeping the scope built from the first render's props, exactly as the local compose host does), and is interrupted when the node leaves the tree ordispose()closes the server. The full rendersetRenderVerify(true)adds is never committed, so it mounts nothing. The render that first makes a surface live does not observe its own activation's work; the next one does.renderSceneToWire(scene)is a one-shot render with no lifetime to interrupt an activation on, so it mounts none — put work that must happen once per runtime rather than once per mounted surface in the scene'sboot:.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
