@playfast/reform-remote 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ <div align="center">
2
+
3
+ # `@playfast/reform-remote`
4
+
5
+ **Run a reform scene's logic on the server; render its UI on a thin client over a transport.**
6
+
7
+ </div>
8
+
9
+ ---
10
+
11
+ A fourth consumer of a reform [`Scene`](https://www.npmjs.com/package/@playfast/reform), alongside
12
+ [`@playfast/react`](https://www.npmjs.com/package/@playfast/react),
13
+ [`@playfast/react-native`](https://www.npmjs.com/package/@playfast/react-native), and
14
+ [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof). State, events, reducers,
15
+ async/remote data, and compositions all run **server-side**; the client receives a serialized
16
+ tree of rendered UI contracts and renders them with local presentations. The wire carries only
17
+ data — UI-tree patches one way, trigger invocations the other — so there is no API layer to write.
18
+
19
+ This builds on reform's existing seams: the **`ui` contract** already separates logic from
20
+ presentation, the **`CaptureSink`** already serializes the rendered surface headlessly (the same
21
+ mechanism proofs use), and the **schema-first `ui`** form (`ui(name, { props, events })`) carries
22
+ the wire schemas that make props and trigger payloads typed *and* runtime-validated at the seam.
23
+ See [`REMOTE_UI.md`](../../REMOTE_UI.md) for the design.
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ npm install @playfast/reform-remote @playfast/reform effect react
29
+ ```
30
+
31
+ Add a transport adapter for the wire you want — `inMemoryTransportPair` ships here; for real
32
+ sockets pair with [`@playfast/reform-remote-node`](https://www.npmjs.com/package/@playfast/reform-remote-node),
33
+ [`@playfast/reform-remote-bun`](https://www.npmjs.com/package/@playfast/reform-remote-bun), or
34
+ [`@playfast/reform-remote-web`](https://www.npmjs.com/package/@playfast/reform-remote-web).
35
+
36
+ ## Key concepts
37
+
38
+ | Concept | What it does |
39
+ | --- | --- |
40
+ | `makeRemoteServer(scene)` | Renders a `Scene` to a `WireTree`; `render()`/`renderDiff()` emit full tree/patches; `invoke(handle, payload)` fires a trigger. |
41
+ | `renderWireTree(tree, { views, invoke })` | Folds a `WireTree` back into React using local presentations. |
42
+ | `remoteContract({...})` / `remoteViews<C>({...})` | The trpc-style typesafe seam — server declares the contract, client implements exactly it. |
43
+ | `serve({ scene, transport })` / `connect({ transport, views })` | Bind both ends to any `RemoteTransport`. |
44
+ | `inMemoryTransportPair()` | In-process duplex `RemoteTransport` (the simplest concrete adapter). |
45
+ | `<RemoteUI transport views />` / `useRemoteUI` | React binding that owns connect, subscription, re-render, teardown. |
46
+ | `useConnectionStatus(reporter)` | Reads a transport's live `StatusReporter` status. |
47
+
48
+ ## How it fits together
49
+
50
+ ```
51
+ server client
52
+ ┌────────────────────────┐ ┌──────────────────────────┐
53
+ Scene ─► makeRemoteServer ─► WirePatch[] ──► Wire.apply ─► renderWireTree
54
+ ▲ (renders, (folds tree) (local views)
55
+ │ encodes props, │
56
+ │ registers triggers) ▼
57
+ └──────────── invoke(handle, payload) ◄──── event callback fires ─┘
58
+ ```
59
+
60
+ - **`makeRemoteServer(scene)`** — renders the scene to a `WireTree`, encoding each contract's
61
+ props via its schema and registering triggers behind stable `${nodeId}:${event}` handles.
62
+ `render()` / `renderDiff()` produce a full tree / patches; `invoke(handle, payload)` decodes the
63
+ payload and fires the trigger (a `High`-priority dispatch into the Bus).
64
+ - **`renderWireTree(tree, { views, invoke })`** — turns a `WireTree` back into React using the
65
+ presentations in `views` (the bundled vocabulary), reconstituting event props as callbacks.
66
+ - **`remoteContract({...})` / `remoteViews<AppContract>({...})`** — the trpc-style typesafe seam.
67
+ The SERVER declares its UI shape ONCE and exports `typeof` it; the CLIENT imports only that TYPE
68
+ and implements it. `remoteViews<AppContract>` type-checks the client's view set to implement
69
+ EXACTLY the server's contracts — a missing, extra, or wrong-contract view is a compile error.
70
+ Each view is a plain `Ui.make` (props, slots, events all typed from its contract); the render
71
+ name + props schema come from the contract, so they can't drift. The result is a branded
72
+ `RemoteViewSet<AppContract>` that `connect`/`renderWireTree`/`<RemoteUI>` accept (an unbranded
73
+ `Record` is rejected — the contract check is end-to-end, with no widening):
74
+ ```ts
75
+ // server (or shared) — the AppRouter analog:
76
+ export const AppContract = remoteContract({ TodoApp: TodoAppUi, TodoItem: TodoItemUi })
77
+ export type AppContract = typeof AppContract
78
+
79
+ // client — imports the TYPE only:
80
+ import type { AppContract } from '…/contracts'
81
+ const TodoItem = Ui.make(TodoItemUi, (props, _slots, events) => (
82
+ <li onClick={() => events.toggle({})}>{props.text}</li> // props/events fully typed
83
+ ))
84
+ export const views = remoteViews<AppContract>({ TodoApp: /* … */, TodoItem })
85
+ ```
86
+ - **`serve({ scene, transport })` / `connect({ transport, views })`** — bind both ends to any
87
+ `RemoteTransport` (WebSocket, postMessage, in-memory). The server sends a `Snapshot` (full tree,
88
+ replace) on connect and `Patches` (apply) thereafter, so a reconnecting client re-syncs cleanly.
89
+ - **`inMemoryTransportPair()`** — the in-process duplex adapter the tests drive `serve`/`connect`
90
+ over without a socket; the simplest concrete `RemoteTransport`.
91
+ - **`<RemoteUI transport views />` / `useRemoteUI(transport, views)`** — the React binding. It owns
92
+ the whole client side — `connect`, the patch subscription, re-render on each frame, and teardown
93
+ on unmount — so the call site holds no `useSyncExternalStore` or `binding.node()`. Pair it with
94
+ **`useConnectionStatus(transport)`** to read a transport's live status (e.g. a reconnecting badge):
95
+ ```tsx
96
+ const App = () => {
97
+ const status = useConnectionStatus(transport) // 'connecting' | 'open' | 'reconnecting' | 'closed'
98
+ return (
99
+ <>
100
+ {status !== 'open' ? <div className={`conn ${status}`}>{status}…</div> : null}
101
+ <RemoteUI transport={transport} views={views} />
102
+ </>
103
+ )
104
+ }
105
+ ```
106
+
107
+ ## Transport adapters
108
+
109
+ | Package | Role | Built on |
110
+ | --- | --- | --- |
111
+ | `inMemoryTransportPair` (here) | in-process duplex | — |
112
+ | [`@playfast/reform-remote-node`](https://www.npmjs.com/package/@playfast/reform-remote-node) | WebSocket server | `ws` |
113
+ | [`@playfast/reform-remote-bun`](https://www.npmjs.com/package/@playfast/reform-remote-bun) | WebSocket server | `Bun.serve` |
114
+ | [`@playfast/reform-remote-web`](https://www.npmjs.com/package/@playfast/reform-remote-web) | WebSocket client (factory, auto-reconnect) | global `WebSocket` |
115
+
116
+ ## Status
117
+
118
+ The full loop — scene → wire tree → diff → client render → trigger → server dispatch → re-render
119
+ — is implemented and proven over the in-memory transport (flat, nested-slot, and dynamic-list
120
+ scenes) and over real WebSockets via the adapter packages above. Remaining: `provideRemote` sugar
121
+ that gates wiring on the `WiredUi` brand per remote.
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,127 @@
1
+ import { type ReactNode } from 'react';
2
+ import { Schema } from 'effect';
3
+ import { type MadeView, Ui, type UiClass, type WireTree } from '@playfast/reform';
4
+ /**
5
+ * The client side of the remote transport: fold the server's patches with
6
+ * `Wire.apply`, then render the resulting `WireTree` with locally registered
7
+ * presentations. The presentations are the SAME `Ui.make` views the local
8
+ * `@playfast/react` renderer uses — one presentation, many consumers (REMOTE_UI.md):
9
+ *
10
+ * - Each wire node's view runs as a stable React COMPONENT, so a view body's own
11
+ * hooks (`useState`, effects) work exactly as under `@playfast/react`.
12
+ * - Slots are COMPONENTS that render that slot's wire children, so a view's
13
+ * `<slots.Foo/>` / `createElement(slots.Foo, props)` works unchanged (the
14
+ * per-slot props were captured server-side; the client component ignores them).
15
+ * - Data props are DECODED through the contract's own props schema — the inverse of
16
+ * the server's `Schema.encodeUnknown` — so `Option`/`Date`/branded props arrive as
17
+ * real instances, not their `{_tag,value}` wire shape.
18
+ *
19
+ * A client view is authored with `Ui.make(SomeUi, …)` — exactly like a local view —
20
+ * and registered with `remoteViews<AppContract>({ Some: SomeView, … })`. There is no
21
+ * separate `remoteView` call: a `Ui.make` view carries its own contract, so `remoteViews`
22
+ * recovers each view's wire name + props schema straight from it.
23
+ */
24
+ /**
25
+ * The dynamic presentation shape the renderer calls — slots and events erased to
26
+ * their runtime form. Views are authored with `Ui.make` (contract-typed); this is
27
+ * only the internal shape `renderWireTree` invokes them through.
28
+ */
29
+ export type RemoteView = (props: Record<string, unknown>, slots: Record<string, (slotProps?: {
30
+ readonly slotKey?: string;
31
+ }) => ReactNode>, events: Record<string, (payload: unknown) => void>) => ReactNode;
32
+ /** A contract-bound view paired with the contract name the renderer looks it up by, and
33
+ * (for a WIRED contract) the props schema used to DECODE wire props back into their
34
+ * decoded domain types — the symmetric inverse of the server's `Schema.encodeUnknown`.
35
+ * Without it, an `Option`/`Date`/branded prop would reach the view as its raw encoded
36
+ * shape (e.g. `{_tag:'Some',value}`) instead of a real `Option`. */
37
+ export interface RegisteredRemoteView {
38
+ readonly name: string;
39
+ readonly view: RemoteView;
40
+ readonly propsSchema?: Schema.Schema<Record<string, unknown>, unknown>;
41
+ }
42
+ /** The by-name presentation record the renderer walks — the unbranded runtime shape behind
43
+ * a `RemoteViewSet`. Internal: it is NOT part of the public API, so a raw record of this
44
+ * shape can never be handed to `connect`/`renderWireTree`/`<RemoteUI>` (those demand the
45
+ * branded `RemoteViewSet<C>`). A `RemoteViewSet<C>` is structurally `ViewRegistry & brand`,
46
+ * so it always widens back to this when the renderer needs the plain lookup. */
47
+ type ViewRegistry = Readonly<Record<string, RegisteredRemoteView>>;
48
+ /** Phantom brand carrying the server `RemoteContract` a view set was checked against. Typed
49
+ * as a function OF `C` (never called) so the contract appears in the type WITHOUT needing a
50
+ * runtime value of `C` — `remoteViews<C>(views)` can brand the set cast-free even though the
51
+ * client imports the contract as a TYPE only (trpc-style), never as a value. */
52
+ declare const ViewSetContract: unique symbol;
53
+ /**
54
+ * The presentation set the client renders by contract name — the `ViewRegistry` the renderer
55
+ * walks, BRANDED with the server `RemoteContract` `C` it was checked against. The brand is
56
+ * what makes the whole client chain typesafe: `connect`, `renderWireTree`, and `<RemoteUI>`
57
+ * accept only a `RemoteViewSet` *produced by* `remoteViews<C>(…)` — never an arbitrary
58
+ * `Record`, which lacks the brand — so a view set cannot reach the renderer without having
59
+ * been type-checked to implement exactly the server's shape. `C` is recovered by inference at
60
+ * each consumer, so passing a `RemoteViewSet<AppContract>` types the whole chain to that app.
61
+ *
62
+ * No default for `C`: a `RemoteViewSet` is ALWAYS bound to a concrete contract (inferred from
63
+ * `remoteViews<AppContract>(…)`), never silently widened to the `RemoteContract` bound — that
64
+ * is what keeps the safety end to end.
65
+ */
66
+ export type RemoteViewSet<C extends RemoteContract> = ViewRegistry & {
67
+ readonly [ViewSetContract]: (contract: C) => void;
68
+ };
69
+ /**
70
+ * The shared "shape" the server publishes and the client implements — the trpc `AppRouter`
71
+ * analog. It is a registry of the server's UI contracts (the `ui(...)` classes the scene's
72
+ * wire tree can emit) keyed by a label. The SERVER declares it ONCE (`remoteContract({…})`)
73
+ * and exports `typeof` it; the CLIENT imports THAT TYPE and `remoteViews<AppContract>(…)`
74
+ * checks its view set against it, so the client is forced to implement exactly the server's
75
+ * views. The wire name + props schema are read from each view's own contract at runtime, so
76
+ * the label is only the type-level join key — the contract is never needed as a value here.
77
+ *
78
+ * `UiClass<any>` is the registry's *upper bound*; the precise per-contract types are
79
+ * preserved by inferring `C` narrowly at the `remoteContract` declaration site (so
80
+ * `Ui.Contract<C[K]>` recovers the real contract, not `any`).
81
+ */
82
+ export type RemoteContract = Readonly<Record<string, UiClass<any>>>;
83
+ /**
84
+ * Declare a server's `RemoteContract` with its precise key/contract types inferred (the
85
+ * `const` type parameter keeps each value's specific `UiClass<…>` instead of widening to
86
+ * the `UiClass<any>` bound). Define it ONCE next to the scene and export `typeof` it as the
87
+ * UI requirements the client implements against:
88
+ *
89
+ * export const AppContract = remoteContract({ Shell: ShellUi, Sidebar: SidebarUi })
90
+ * export type AppContract = typeof AppContract
91
+ *
92
+ * The client then imports only the TYPE and implements it:
93
+ *
94
+ * import type { AppContract } from '…/app-contract'
95
+ * export const views = remoteViews<AppContract>({ Shell: ShellView, Sidebar: SidebarView })
96
+ */
97
+ export declare const remoteContract: <const C extends RemoteContract>(contract: C) => C;
98
+ /**
99
+ * The client view set a `RemoteContract` demands: exactly one `Ui.make` view per contract,
100
+ * each typed to THAT contract (`MadeView<Ui.Contract<C[K]>>`). A missing key, an extra key,
101
+ * or a view authored for the wrong contract is a COMPILE error — the client cannot connect
102
+ * to the server without implementing precisely its shape, the same guarantee trpc gives a
103
+ * client built from `AppRouter`.
104
+ */
105
+ export type RemoteViews<C extends RemoteContract> = {
106
+ readonly [K in keyof C]: MadeView<Ui.Contract<C[K]>>;
107
+ };
108
+ /**
109
+ * Implement a server's UI shape: `remoteViews<AppContract>({ … })` takes the server's
110
+ * `RemoteContract` as a TYPE PARAMETER (the client imports only `typeof AppContract`, never a
111
+ * value — trpc-style) and a set of `Ui.make` views type-checked to implement EXACTLY that
112
+ * contract. Each view carries its own contract (`Ui.make(SomeUi, …)`), so the wire name and
113
+ * props schema are recovered from the view itself — a renamed/retyped contract can never
114
+ * drift from its presentation, and the SAME view authored for the local renderer is reused.
115
+ * The result is a `RemoteViewSet<C>` (the branded set `connect`/`renderWireTree`/`<RemoteUI>`
116
+ * accept); an unbranded `Record` can never be substituted.
117
+ */
118
+ export declare const remoteViews: <C extends RemoteContract>(views: RemoteViews<C>) => RemoteViewSet<C>;
119
+ export interface ClientConfig<C extends RemoteContract> {
120
+ /** Presentation (+ optional props schema) per contract name — a contract-checked set. */
121
+ readonly views: RemoteViewSet<C>;
122
+ /** Deliver a trigger invocation to the server (the transport's send). */
123
+ readonly invoke: (handle: string, payload: unknown) => void;
124
+ }
125
+ export declare const renderWireTree: <C extends RemoteContract>(tree: WireTree, config: ClientConfig<C>) => ReactNode;
126
+ export {};
127
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAA2B,KAAK,SAAS,EAAU,MAAM,OAAO,CAAA;AACvE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC/B,OAAO,EACL,KAAK,QAAQ,EACb,EAAE,EACF,KAAK,OAAO,EAIZ,KAAK,QAAQ,EACd,MAAM,kBAAkB,CAAA;AAQzB;;;;;;;;;;;;;;;;;;;GAmBG;AAEH;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,CACvB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAG9B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,SAAS,CAAC,EAAE;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,KAAK,SAAS,CAAC,EAC/E,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC,KAC/C,SAAS,CAAA;AAEd;;;;qEAIqE;AACrE,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,CAAA;CACvE;AAED;;;;iFAIiF;AACjF,KAAK,YAAY,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,oBAAoB,CAAC,CAAC,CAAA;AAElE;;;iFAGiF;AACjF,QAAA,MAAM,eAAe,EAAE,OAAO,MAAsD,CAAA;AAEpF;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,aAAa,CAAC,CAAC,SAAS,cAAc,IAAI,YAAY,GAAG;IACnE,QAAQ,CAAC,CAAC,eAAe,CAAC,EAAE,CAAC,QAAQ,EAAE,CAAC,KAAK,IAAI,CAAA;CAClD,CAAA;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;AAEnE;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,CAAC,CAAC,SAAS,cAAc,EAAE,UAAU,CAAC,KAAG,CAAa,CAAA;AAE1F;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,cAAc,IAAI;IAClD,QAAQ,EAAE,CAAC,IAAI,MAAM,CAAC,GAAG,QAAQ,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CACrD,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,cAAc,EAAE,OAAO,WAAW,CAAC,CAAC,CAAC,KAAG,aAAa,CAAC,CAAC,CAqB5F,CAAA;AAED,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,cAAc;IACpD,yFAAyF;IACzF,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,CAAA;IAChC,yEAAyE;IACzE,QAAQ,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAA;CAC5D;AAoGD,eAAO,MAAM,cAAc,GAAI,CAAC,SAAS,cAAc,EACrD,MAAM,QAAQ,EACd,QAAQ,YAAY,CAAC,CAAC,CAAC,KACtB,SAOA,CAAA"}
package/dist/client.js ADDED
@@ -0,0 +1,127 @@
1
+ import { createElement, Fragment, useRef } from 'react';
2
+ import { Schema } from 'effect';
3
+ import { Ui, UiViewContract, Wire, } from '@playfast/reform';
4
+ /** Phantom brand carrying the server `RemoteContract` a view set was checked against. Typed
5
+ * as a function OF `C` (never called) so the contract appears in the type WITHOUT needing a
6
+ * runtime value of `C` — `remoteViews<C>(views)` can brand the set cast-free even though the
7
+ * client imports the contract as a TYPE only (trpc-style), never as a value. */
8
+ const ViewSetContract = Symbol.for('reform-remote/view-set-contract');
9
+ /**
10
+ * Declare a server's `RemoteContract` with its precise key/contract types inferred (the
11
+ * `const` type parameter keeps each value's specific `UiClass<…>` instead of widening to
12
+ * the `UiClass<any>` bound). Define it ONCE next to the scene and export `typeof` it as the
13
+ * UI requirements the client implements against:
14
+ *
15
+ * export const AppContract = remoteContract({ Shell: ShellUi, Sidebar: SidebarUi })
16
+ * export type AppContract = typeof AppContract
17
+ *
18
+ * The client then imports only the TYPE and implements it:
19
+ *
20
+ * import type { AppContract } from '…/app-contract'
21
+ * export const views = remoteViews<AppContract>({ Shell: ShellView, Sidebar: SidebarView })
22
+ */
23
+ export const remoteContract = (contract) => contract;
24
+ /**
25
+ * Implement a server's UI shape: `remoteViews<AppContract>({ … })` takes the server's
26
+ * `RemoteContract` as a TYPE PARAMETER (the client imports only `typeof AppContract`, never a
27
+ * value — trpc-style) and a set of `Ui.make` views type-checked to implement EXACTLY that
28
+ * contract. Each view carries its own contract (`Ui.make(SomeUi, …)`), so the wire name and
29
+ * props schema are recovered from the view itself — a renamed/retyped contract can never
30
+ * drift from its presentation, and the SAME view authored for the local renderer is reused.
31
+ * The result is a `RemoteViewSet<C>` (the branded set `connect`/`renderWireTree`/`<RemoteUI>`
32
+ * accept); an unbranded `Record` can never be substituted.
33
+ */
34
+ export const remoteViews = (views) => {
35
+ const list = Object.values(views);
36
+ const byName = Object.fromEntries(list.map((view) => {
37
+ const viewContract = view[UiViewContract];
38
+ const propsSchema = viewContract.manifest.props;
39
+ // `Node` is `ReactNode` and `ViewImpl`'s params widen to the dynamic shape the
40
+ // renderer calls, so the contract-typed view IS a `RemoteView` — no cast.
41
+ const entry = {
42
+ name: viewContract.manifest.name,
43
+ view,
44
+ ...(propsSchema !== undefined ? { propsSchema } : {}),
45
+ };
46
+ return [entry.name, entry];
47
+ }));
48
+ // Brand the record with the contract `C` it was checked against. The brand is a phantom
49
+ // function OF `C` that is never called, so it needs NO runtime value of `C` — the set is
50
+ // built cast-free even though the client only has the contract as a type. The renderer
51
+ // reads the set by string key; the brand is never read at runtime.
52
+ return Object.assign(byName, { [ViewSetContract]: (_contract) => { } });
53
+ };
54
+ /**
55
+ * A stable top-level component for one wire node — stable identity so React keeps a
56
+ * view's local state across frames (the `node.id` keys it). It decodes props, builds
57
+ * slot components, and runs the registered view INSIDE this component so the view's
58
+ * own hooks get a fiber. A child slot renders its wire children as nested `WireNodeView`s.
59
+ */
60
+ const WireNodeView = ({ node, tree, config, }) => {
61
+ // The latest render inputs, for the stable slot closures below to read. Mutated every
62
+ // render so a slot always renders against the current tree, even though its function
63
+ // identity never changes.
64
+ const latest = useRef({ node, tree, config });
65
+ latest.current = { node, tree, config };
66
+ // Slot components cached by name across renders. CRITICAL for state preservation: a parent
67
+ // view that renders a slot as `<slots.Foo/>` (the Ui.make convention) passes the slot value
68
+ // as the element TYPE — if that value were a fresh closure each render (as it was before),
69
+ // React would see a new component type every frame and UNMOUNT+REMOUNT the whole slot
70
+ // subtree, resetting every descendant view's local hook state (e.g. a modal's open/step
71
+ // `useState` would snap back, closing the modal on any unrelated background patch). Caching
72
+ // the closure by slot name gives `<slots.Foo/>` a stable type, so React reconciles the slot's
73
+ // children by id instead of remounting them. The closure reads `latest` so it still renders
74
+ // the current tree.
75
+ const slotCache = useRef({});
76
+ const registered = config.views[node.name];
77
+ if (registered === undefined)
78
+ return null;
79
+ const encoded = {};
80
+ const events = {};
81
+ for (const prop of node.props) {
82
+ if (prop._tag === 'Data')
83
+ encoded[prop.name] = prop.value;
84
+ else
85
+ events[prop.name] = (payload) => config.invoke(prop.handle, payload);
86
+ }
87
+ const props = registered.propsSchema === undefined
88
+ ? encoded
89
+ : Schema.decodeUnknownSync(registered.propsSchema)(encoded);
90
+ const children = Wire.childrenOf(tree, node.id);
91
+ const slots = {};
92
+ for (const slotName of new Set(children.map((child) => child.slot))) {
93
+ if (slotName === null)
94
+ continue;
95
+ // A slot is a COMPONENT rendering that slot's wire children — usable as `<slots.Foo/>`
96
+ // (Ui.make convention) or `slots.Foo()` (thunk convention). The function is cached so its
97
+ // identity is STABLE across renders (see `slotCache` above); it reads `latest.current` so
98
+ // each invocation renders against the current tree/config.
99
+ //
100
+ // KEYED SLOTS: when the caller passes `slotKey` (`<slots.Row slotKey={id} />`), render only
101
+ // the ONE wire child whose `key` matches — the per-item identity the server captured from
102
+ // the parent's React `key` (see WireNode.key). This is what lets a LIST slot be invoked once
103
+ // per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
104
+ // `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
105
+ // singleton slot rendered once, e.g. `<slots.Create/>`).
106
+ const cached = slotCache.current[slotName];
107
+ const stable = cached ??
108
+ ((slotProps) => {
109
+ const { node: currentNode, tree: currentTree, config: currentConfig } = latest.current;
110
+ const requestedKey = slotProps?.slotKey;
111
+ return createElement(Fragment, null, ...Wire.childrenOf(currentTree, currentNode.id)
112
+ .filter((child) => child.slot === slotName)
113
+ .filter((child) => requestedKey === undefined || child.key === requestedKey)
114
+ .map((child) => createElement(WireNodeView, {
115
+ key: child.id,
116
+ node: child,
117
+ tree: currentTree,
118
+ config: currentConfig,
119
+ })));
120
+ });
121
+ slotCache.current[slotName] = stable;
122
+ slots[slotName] = stable;
123
+ }
124
+ return registered.view(props, slots, events);
125
+ };
126
+ export const renderWireTree = (tree, config) => createElement(Fragment, null, ...Wire.roots(tree).map((node) => createElement(WireNodeView, { key: node.id, node, tree, config })));
127
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ export * as Server from './server';
2
+ export * as Client from './client';
3
+ export * as Transport from './transport';
4
+ export * as Memory from './memory';
5
+ export * as React from './react';
6
+ export { makeRemoteServer, type RemoteServer } from './server';
7
+ export { type ClientConfig, type RegisteredRemoteView, type RemoteContract, remoteContract, type RemoteView, type RemoteViews, type RemoteViewSet, remoteViews, renderWireTree, } from './client';
8
+ export { type ClientBinding, connect, type ConnectOptions, type InvokeMessage, type PatchesMessage, type RemoteTransport, serve, type ServeOptions, type ServerBinding, type ServerMessage, type SnapshotMessage, } from './transport';
9
+ export { inMemoryTransportPair, type TransportPair } from './memory';
10
+ export { RemoteUI, type RemoteUIProps, type StatusReporter, useConnectionStatus, useRemoteUI, } from './react';
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,MAAM,MAAM,UAAU,CAAA;AAClC,OAAO,KAAK,MAAM,MAAM,UAAU,CAAA;AAClC,OAAO,KAAK,SAAS,MAAM,aAAa,CAAA;AACxC,OAAO,KAAK,MAAM,MAAM,UAAU,CAAA;AAClC,OAAO,KAAK,KAAK,MAAM,SAAS,CAAA;AAEhC,OAAO,EAAE,gBAAgB,EAAE,KAAK,YAAY,EAAE,MAAM,UAAU,CAAA;AAC9D,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,oBAAoB,EACzB,KAAK,cAAc,EACnB,cAAc,EACd,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,WAAW,EACX,cAAc,GACf,MAAM,UAAU,CAAA;AACjB,OAAO,EACL,KAAK,aAAa,EAClB,OAAO,EACP,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,EACL,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,qBAAqB,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAA;AACpE,OAAO,EACL,QAAQ,EACR,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,mBAAmB,EACnB,WAAW,GACZ,MAAM,SAAS,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ // @playfast/reform-remote — run a reform scene's logic on the server and stream
2
+ // its rendered UI to a thin client over a transport. A fourth consumer of a
3
+ // `Scene`, alongside @playfast/react, @playfast/react-native, and @playfast/proof.
4
+ // See REMOTE_UI.md.
5
+ //
6
+ // Public API is exposed two ways (Effect-style): namespace barrels for discovery
7
+ // (`import { Transport } from "@playfast/reform-remote"`) and per-path subpaths
8
+ // for direct use (`import { connect } from "@playfast/reform-remote/transport"`).
9
+ // The select re-exports below keep the common entry points nameable from the root.
10
+ export * as Server from './server';
11
+ export * as Client from './client';
12
+ export * as Transport from './transport';
13
+ export * as Memory from './memory';
14
+ export * as React from './react';
15
+ export { makeRemoteServer } from './server';
16
+ export { remoteContract, remoteViews, renderWireTree, } from './client';
17
+ export { connect, serve, } from './transport';
18
+ export { inMemoryTransportPair } from './memory';
19
+ export { RemoteUI, useConnectionStatus, useRemoteUI, } from './react';
20
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,4EAA4E;AAC5E,mFAAmF;AACnF,oBAAoB;AACpB,EAAE;AACF,iFAAiF;AACjF,gFAAgF;AAChF,kFAAkF;AAClF,mFAAmF;AAEnF,OAAO,KAAK,MAAM,MAAM,UAAU,CAAA;AAClC,OAAO,KAAK,MAAM,MAAM,UAAU,CAAA;AAClC,OAAO,KAAK,SAAS,MAAM,aAAa,CAAA;AACxC,OAAO,KAAK,MAAM,MAAM,UAAU,CAAA;AAClC,OAAO,KAAK,KAAK,MAAM,SAAS,CAAA;AAEhC,OAAO,EAAE,gBAAgB,EAAqB,MAAM,UAAU,CAAA;AAC9D,OAAO,EAIL,cAAc,EAId,WAAW,EACX,cAAc,GACf,MAAM,UAAU,CAAA;AACjB,OAAO,EAEL,OAAO,EAKP,KAAK,GAKN,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,qBAAqB,EAAsB,MAAM,UAAU,CAAA;AACpE,OAAO,EACL,QAAQ,EAGR,mBAAmB,EACnB,WAAW,GACZ,MAAM,SAAS,CAAA"}
@@ -0,0 +1,15 @@
1
+ import type { RemoteTransport } from './transport';
2
+ /**
3
+ * The in-memory transport adapter: a duplex pair where what one end sends, the
4
+ * other receives — synchronously, in process. It is the simplest concrete
5
+ * `RemoteTransport`, and the one the test suite drives `serve`/`connect` over
6
+ * without a socket. A WebSocket adapter is the same shape with JSON framing
7
+ * across a wire; proving the loop here proves it everywhere.
8
+ */
9
+ /** A linked `server`/`client` transport: each receives exactly what the other sends. */
10
+ export interface TransportPair<Server, Client> {
11
+ readonly server: RemoteTransport<Server, Client>;
12
+ readonly client: RemoteTransport<Client, Server>;
13
+ }
14
+ export declare const inMemoryTransportPair: <Server, Client>() => TransportPair<Server, Client>;
15
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAElD;;;;;;GAMG;AAEH,wFAAwF;AACxF,MAAM,WAAW,aAAa,CAAC,MAAM,EAAE,MAAM;IAC3C,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChD,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjD;AAED,eAAO,MAAM,qBAAqB,GAAI,MAAM,EAAE,MAAM,OAAK,aAAa,CAAC,MAAM,EAAE,MAAM,CAmBpF,CAAA"}
package/dist/memory.js ADDED
@@ -0,0 +1,21 @@
1
+ export const inMemoryTransportPair = () => {
2
+ const serverHandlers = new Set();
3
+ const clientHandlers = new Set();
4
+ return {
5
+ server: {
6
+ send: (message) => clientHandlers.forEach((handler) => handler(message)),
7
+ onMessage: (handler) => {
8
+ serverHandlers.add(handler);
9
+ return () => void serverHandlers.delete(handler);
10
+ },
11
+ },
12
+ client: {
13
+ send: (message) => serverHandlers.forEach((handler) => handler(message)),
14
+ onMessage: (handler) => {
15
+ clientHandlers.add(handler);
16
+ return () => void clientHandlers.delete(handler);
17
+ },
18
+ },
19
+ };
20
+ };
21
+ //# sourceMappingURL=memory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.js","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAgBA,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAkD,EAAE;IACvF,MAAM,cAAc,GAAG,IAAI,GAAG,EAA6B,CAAA;IAC3D,MAAM,cAAc,GAAG,IAAI,GAAG,EAA6B,CAAA;IAC3D,OAAO;QACL,MAAM,EAAE;YACN,IAAI,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;YACxE,SAAS,EAAE,CAAC,OAAO,EAAE,EAAE;gBACrB,cAAc,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;gBAC3B,OAAO,GAAG,EAAE,CAAC,KAAK,cAAc,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;YAClD,CAAC;SACF;QACD,MAAM,EAAE;YACN,IAAI,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;YACxE,SAAS,EAAE,CAAC,OAAO,EAAE,EAAE;gBACrB,cAAc,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;gBAC3B,OAAO,GAAG,EAAE,CAAC,KAAK,cAAc,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;YAClD,CAAC;SACF;KACF,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,38 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type InvokeMessage, type RemoteTransport, type ServerMessage } from './transport';
3
+ import type { RemoteContract, RemoteViewSet } from './client';
4
+ /**
5
+ * The React binding for the remote client. A consumer hands it a transport and a
6
+ * `views` map and gets a live React subtree — the framework owns the transport
7
+ * wiring (connect, the patch subscription, re-render on each frame, teardown on
8
+ * unmount). No `useSyncExternalStore`, `binding.subscribe`, or `binding.node()`
9
+ * at the call site.
10
+ */
11
+ /**
12
+ * Render the remote scene as React. Pass a stable `transport` (created once,
13
+ * e.g. by `createWebSocketClientTransport`) — the binding is established on mount
14
+ * and disposed on unmount.
15
+ */
16
+ export declare const useRemoteUI: <C extends RemoteContract>(transport: RemoteTransport<InvokeMessage, ServerMessage>, views: RemoteViewSet<C>) => ReactNode;
17
+ export interface RemoteUIProps<C extends RemoteContract> {
18
+ readonly transport: RemoteTransport<InvokeMessage, ServerMessage>;
19
+ /** A contract-checked view set from `remoteViews<C>(…)` — an unbranded `Record` is rejected,
20
+ * so `<RemoteUI>` can only render views proven to implement the server's shape. */
21
+ readonly views: RemoteViewSet<C>;
22
+ }
23
+ /** Component form of {@link useRemoteUI}: `<RemoteUI transport={…} views={…} />`. `C` is
24
+ * inferred from `views` (the `remoteViews<AppContract>(…)` result), so the whole subtree is
25
+ * typed to that app's contract. */
26
+ export declare const RemoteUI: <C extends RemoteContract>({ transport, views, }: RemoteUIProps<C>) => ReactNode;
27
+ /**
28
+ * Anything that reports an observable connection status — implemented by the
29
+ * WebSocket client transport. Kept structural so the React binding needs no
30
+ * dependency on a specific adapter.
31
+ */
32
+ export interface StatusReporter<S> {
33
+ readonly status: () => S;
34
+ readonly onStatusChange: (listener: () => void) => () => void;
35
+ }
36
+ /** Subscribe to a transport's connection status (e.g. to show a reconnecting badge). */
37
+ export declare const useConnectionStatus: <S>(reporter: StatusReporter<S>) => S;
38
+ //# sourceMappingURL=react.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAA6C,MAAM,OAAO,CAAA;AACjF,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,aAAa,EACnB,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAE7D;;;;;;GAMG;AAEH;;;;GAIG;AACH,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,cAAc,EAClD,WAAW,eAAe,CAAC,aAAa,EAAE,aAAa,CAAC,EACxD,OAAO,aAAa,CAAC,CAAC,CAAC,KACtB,SAOF,CAAA;AAED,MAAM,WAAW,aAAa,CAAC,CAAC,SAAS,cAAc;IACrD,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAC,aAAa,EAAE,aAAa,CAAC,CAAA;IACjE;wFACoF;IACpF,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,CAAA;CACjC;AAED;;oCAEoC;AACpC,eAAO,MAAM,QAAQ,GAAI,CAAC,SAAS,cAAc,EAAE,uBAGhD,aAAa,CAAC,CAAC,CAAC,KAAG,SAA0C,CAAA;AAEhE;;;;GAIG;AACH,MAAM,WAAW,cAAc,CAAC,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACxB,QAAQ,CAAC,cAAc,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAA;CAC9D;AAED,wFAAwF;AACxF,eAAO,MAAM,mBAAmB,GAAI,CAAC,EAAE,UAAU,cAAc,CAAC,CAAC,CAAC,KAAG,CACY,CAAA"}
package/dist/react.js ADDED
@@ -0,0 +1,29 @@
1
+ import { useEffect, useState, useSyncExternalStore } from 'react';
2
+ import { connect, } from './transport';
3
+ /**
4
+ * The React binding for the remote client. A consumer hands it a transport and a
5
+ * `views` map and gets a live React subtree — the framework owns the transport
6
+ * wiring (connect, the patch subscription, re-render on each frame, teardown on
7
+ * unmount). No `useSyncExternalStore`, `binding.subscribe`, or `binding.node()`
8
+ * at the call site.
9
+ */
10
+ /**
11
+ * Render the remote scene as React. Pass a stable `transport` (created once,
12
+ * e.g. by `createWebSocketClientTransport`) — the binding is established on mount
13
+ * and disposed on unmount.
14
+ */
15
+ export const useRemoteUI = (transport, views) => {
16
+ // One binding for the life of the component (the transport is stable).
17
+ const [binding] = useState(() => connect({ transport, views }));
18
+ useEffect(() => () => binding.dispose(), [binding]);
19
+ // Re-render whenever a frame changes the tree; `snapshot` is a stable ref.
20
+ useSyncExternalStore(binding.subscribe, binding.snapshot, binding.snapshot);
21
+ return binding.node();
22
+ };
23
+ /** Component form of {@link useRemoteUI}: `<RemoteUI transport={…} views={…} />`. `C` is
24
+ * inferred from `views` (the `remoteViews<AppContract>(…)` result), so the whole subtree is
25
+ * typed to that app's contract. */
26
+ export const RemoteUI = ({ transport, views, }) => useRemoteUI(transport, views);
27
+ /** Subscribe to a transport's connection status (e.g. to show a reconnecting badge). */
28
+ export const useConnectionStatus = (reporter) => useSyncExternalStore(reporter.onStatusChange, reporter.status, reporter.status);
29
+ //# sourceMappingURL=react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.js","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,SAAS,EAAE,QAAQ,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAA;AACjF,OAAO,EACL,OAAO,GAIR,MAAM,aAAa,CAAA;AAGpB;;;;;;GAMG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CACzB,SAAwD,EACxD,KAAuB,EACZ,EAAE;IACb,uEAAuE;IACvE,MAAM,CAAC,OAAO,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC,CAAA;IAC/D,SAAS,CAAC,GAAG,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAA;IACnD,2EAA2E;IAC3E,oBAAoB,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAA;IAC3E,OAAO,OAAO,CAAC,IAAI,EAAE,CAAA;AACvB,CAAC,CAAA;AASD;;oCAEoC;AACpC,MAAM,CAAC,MAAM,QAAQ,GAAG,CAA2B,EACjD,SAAS,EACT,KAAK,GACY,EAAa,EAAE,CAAC,WAAW,CAAC,SAAS,EAAE,KAAK,CAAC,CAAA;AAYhE,wFAAwF;AACxF,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAI,QAA2B,EAAK,EAAE,CACvE,oBAAoB,CAAC,QAAQ,CAAC,cAAc,EAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA"}
@@ -0,0 +1,26 @@
1
+ import { type Scene, type WirePatch, type WireTree } from '@playfast/reform';
2
+ export interface RemoteServer {
3
+ /** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
4
+ readonly render: () => Promise<WireTree>;
5
+ /**
6
+ * Render and return only the patches since the previous frame (the streaming
7
+ * form). Handles of deleted nodes are revoked, so a stale client invocation
8
+ * fails cleanly rather than firing a dangling trigger.
9
+ */
10
+ readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>;
11
+ /** Fire a trigger the client referenced by handle, then settle the runtime. */
12
+ readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>;
13
+ /**
14
+ * Observe SERVER-INITIATED state changes. The listener fires (after the drain
15
+ * settles) whenever an event flows on the engine bus — i.e. when an async procedure
16
+ * resolves, a boot loader completes, or a scheduler ticks — NOT just in response to a
17
+ * client invoke. `serve` registers a `renderDiff`-and-push listener here so the client
18
+ * sees background updates (a repo list that finishes loading, a reconcile sweep) that
19
+ * no user interaction triggered. Returns an unsubscribe handle.
20
+ */
21
+ readonly subscribe: (listener: () => void) => () => void;
22
+ /** Tear down the runtime and its forked fibers. */
23
+ readonly dispose: () => Promise<void>;
24
+ }
25
+ export declare const makeRemoteServer: (scene: Scene) => RemoteServer;
26
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAEA,OAAO,EAYL,KAAK,KAAK,EAUV,KAAK,SAAS,EAEd,KAAK,QAAQ,EACd,MAAM,kBAAkB,CAAA;AA2FzB,MAAM,WAAW,YAAY;IAC3B,wGAAwG;IACxG,QAAQ,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,QAAQ,CAAC,CAAA;IACxC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,CAAA;IAC5D,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,cAAc,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3E;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAA;IACxD,mDAAmD;IACnD,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CACtC;AAED,eAAO,MAAM,gBAAgB,GAAI,OAAO,KAAK,KAAG,YA8L/C,CAAA"}
package/dist/server.js ADDED
@@ -0,0 +1,206 @@
1
+ import { Effect, Layer, ManagedRuntime, PubSub, Queue, Schema } from 'effect';
2
+ import { isValidElement } from 'react';
3
+ import { Bus, CaptureSink, Composition, forceSync, isFeatureBinding, publish, Triggers, Wire, } from '@playfast/reform';
4
+ const SETTLE_DRAIN = 30;
5
+ const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN));
6
+ const makeSink = () => {
7
+ const state = { captures: [] };
8
+ return {
9
+ api: { record: (capture) => state.captures.push(capture) },
10
+ get captures() {
11
+ return state.captures;
12
+ },
13
+ reset: () => {
14
+ state.captures = [];
15
+ },
16
+ };
17
+ };
18
+ const closedSceneLayer = (scene) => scene.provide.reduce((a, b) => Layer.merge(a, b));
19
+ const childComposition = (child) => isFeatureBinding(child) ? child.composition : child;
20
+ /**
21
+ * Walk a rendered node and enqueue the child each slot placeholder stands for.
22
+ * Slot components are matched by identity (the host defers them as JSX), so this
23
+ * never invokes — nor needs hooks from — ordinary presentation components.
24
+ */
25
+ const drive = (node, enqueueBy) => {
26
+ if (Array.isArray(node)) {
27
+ for (const child of node)
28
+ drive(child, enqueueBy);
29
+ return;
30
+ }
31
+ if (!isValidElement(node))
32
+ return;
33
+ if (node.type instanceof Function) {
34
+ const enqueue = enqueueBy.get(node.type);
35
+ if (enqueue !== undefined) {
36
+ // `node.key` is the React key the parent set (`createElement(slots.Row, { key })`),
37
+ // carried onto the wire node so a KEYED slot can select this child on the client
38
+ // (see WireNode.key). React coerces a set key to a string; absent → null.
39
+ enqueue(node.props, node.key);
40
+ return;
41
+ }
42
+ }
43
+ drive(node.props.children, enqueueBy);
44
+ };
45
+ const handlesOf = (node) => node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []));
46
+ export const makeRemoteServer = (scene) => {
47
+ const sink = makeSink();
48
+ const runtime = ManagedRuntime.make(Layer.provideMerge(closedSceneLayer(scene), Layer.succeed(CaptureSink, sink.api)));
49
+ const registry = forceSync(() => runtime.runSync(Triggers.make));
50
+ const collect = (root) => Effect.gen(function* () {
51
+ const bindings = new Map();
52
+ const walk = (comp) => Effect.gen(function* () {
53
+ for (const slotClass of Object.values(comp.manifest.slots ?? {})) {
54
+ if (bindings.has(slotClass))
55
+ continue;
56
+ const child = childComposition(yield* slotClass.tag);
57
+ bindings.set(slotClass, child);
58
+ yield* walk(child);
59
+ }
60
+ });
61
+ yield* walk(root);
62
+ return bindings;
63
+ });
64
+ const toWireNode = (m, capture) => Effect.gen(function* () {
65
+ // The composition's contract carries the wire schemas at runtime (the type is
66
+ // the erased `{ manifest }` carrier — `composition.ts:34`); read them as `UiManifest`.
67
+ const manifest = m.comp.manifest.ui.manifest;
68
+ const dataProps = [];
69
+ if (manifest.props !== undefined) {
70
+ const encoded = yield* Schema.encodeUnknown(manifest.props)(capture.props);
71
+ for (const [name, value] of Object.entries(encoded)) {
72
+ dataProps.push({ _tag: 'Data', name, value });
73
+ }
74
+ }
75
+ const eventSchemas = manifest.events ?? {};
76
+ const eventProps = [];
77
+ for (const [name, trigger] of Object.entries(capture.events)) {
78
+ const schema = eventSchemas[name];
79
+ if (schema === undefined)
80
+ continue;
81
+ const handle = `${m.id}:${name}`;
82
+ yield* registry.register(handle, trigger, schema);
83
+ eventProps.push({ _tag: 'Event', name, handle });
84
+ }
85
+ return {
86
+ id: m.id,
87
+ name: capture.name,
88
+ parentId: m.parentId,
89
+ childIndex: m.childIndex,
90
+ slot: m.slot,
91
+ key: m.key,
92
+ props: [...dataProps, ...eventProps],
93
+ };
94
+ });
95
+ const renderLevel = (frontier, bindings) => Effect.gen(function* () {
96
+ if (frontier.length === 0)
97
+ return [];
98
+ const next = [];
99
+ const nodes = [];
100
+ for (const mounted of frontier) {
101
+ const service = yield* mounted.comp.tag;
102
+ const slotIndex = new Map();
103
+ const enqueueBy = new Map();
104
+ const slots = {
105
+ slot: (name) => {
106
+ const slotClass = mounted.comp.manifest.slots?.[name];
107
+ const child = slotClass ? bindings.get(slotClass) : undefined;
108
+ const component = () => null;
109
+ if (child !== undefined) {
110
+ enqueueBy.set(component, (childProps, key) => {
111
+ const index = slotIndex.get(name) ?? 0;
112
+ slotIndex.set(name, index + 1);
113
+ next.push({
114
+ comp: child,
115
+ props: childProps,
116
+ id: `${mounted.id}.${name}.${index}`,
117
+ parentId: mounted.id,
118
+ slot: name,
119
+ childIndex: index,
120
+ key,
121
+ });
122
+ });
123
+ }
124
+ return component;
125
+ },
126
+ };
127
+ const before = sink.captures.length;
128
+ const env = { props: mounted.props, tracker: { add: () => { } }, slots };
129
+ const node = yield* Composition.render(service, env);
130
+ const capture = sink.captures[before];
131
+ if (capture !== undefined)
132
+ nodes.push(yield* toWireNode(mounted, capture));
133
+ drive(node, enqueueBy);
134
+ }
135
+ const rest = yield* renderLevel(next, bindings);
136
+ return [...nodes, ...rest];
137
+ });
138
+ const renderEffect = Effect.gen(function* () {
139
+ yield* settleDrain;
140
+ sink.reset();
141
+ const bindings = yield* collect(scene.composition);
142
+ const root = {
143
+ comp: scene.composition,
144
+ props: {},
145
+ id: '0',
146
+ parentId: null,
147
+ slot: null,
148
+ childIndex: 0,
149
+ key: null,
150
+ };
151
+ return yield* renderLevel([root], bindings);
152
+ });
153
+ // Server-initiated change notification: a forked daemon subscribes to the engine bus
154
+ // and, after each batch settles, fans out to registered listeners. This is what lets a
155
+ // streaming binding push frames the client never asked for — a background load resolving
156
+ // long after connect. Forked BEFORE the boot publish below so nothing is missed (the
157
+ // listener set is empty until `subscribe` runs, so early ticks are harmless no-ops; the
158
+ // binding's opening snapshot captures all state up to `start`).
159
+ const changeListeners = new Set();
160
+ const notifyChange = () => {
161
+ for (const listener of changeListeners)
162
+ listener();
163
+ };
164
+ runtime.runFork(Effect.scoped(Effect.gen(function* () {
165
+ const bus = yield* Bus;
166
+ const subscription = yield* PubSub.subscribe(bus);
167
+ // Notify on each event; the consumer (`serve`) debounces, so the render it triggers
168
+ // runs a tick later — after the drain has folded this event into state. No settle
169
+ // here: it would only perturb the drain's own scheduling for no benefit.
170
+ yield* Queue.take(subscription).pipe(Effect.zipRight(Effect.sync(notifyChange)), Effect.forever);
171
+ })));
172
+ runtime.runSync(Effect.forEach(scene.boot ?? [], (event) => publish('High', event)));
173
+ // The last emitted frame, to diff against. A const holder whose field we swap
174
+ // (no reassigned binding), matching the sink's immutable-swap idiom.
175
+ const frame = { tree: [] };
176
+ const render = () => runtime.runPromise(renderEffect).then((tree) => {
177
+ frame.tree = tree;
178
+ return tree;
179
+ });
180
+ const renderDiff = () => runtime.runPromise(renderEffect).then((next) => {
181
+ const previous = frame.tree;
182
+ const patches = Wire.diff(previous, next);
183
+ frame.tree = next;
184
+ const nextIds = new Set(next.map((node) => node.id));
185
+ const staleHandles = previous
186
+ .filter((node) => !nextIds.has(node.id))
187
+ .flatMap(handlesOf);
188
+ return runtime
189
+ .runPromise(Effect.forEach(staleHandles, (handle) => registry.revoke(handle)))
190
+ .then(() => patches);
191
+ });
192
+ return {
193
+ render,
194
+ renderDiff,
195
+ invoke: (handle, encodedPayload) => runtime.runPromise(Effect.gen(function* () {
196
+ yield* registry.invoke(handle, encodedPayload);
197
+ yield* settleDrain;
198
+ })),
199
+ subscribe: (listener) => {
200
+ changeListeners.add(listener);
201
+ return () => void changeListeners.delete(listener);
202
+ },
203
+ dispose: () => runtime.dispose(),
204
+ };
205
+ };
206
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ import type { ReactNode } from 'react';
2
+ import { type WirePatch, type WireTree } from '@playfast/reform';
3
+ import type { Scene } from '@playfast/reform';
4
+ import { type RemoteContract, type RemoteViewSet } from './client';
5
+ /**
6
+ * Binds the server driver and the client renderer to a transport. The wire
7
+ * carries only serializable data — patches one way, trigger invocations the
8
+ * other — so any duplex channel (WebSocket, postMessage, in-memory) works; a
9
+ * concrete adapter just implements `RemoteTransport`. See REMOTE_UI.md §5.
10
+ */
11
+ /**
12
+ * Server → client: the full current tree, replacing whatever the client holds.
13
+ * The first frame of every (re)connection — a fresh server runtime diffs from
14
+ * empty, so it can only *add* nodes; a snapshot is what lets a reconnecting
15
+ * client drop the stale tree it accumulated on the previous socket.
16
+ */
17
+ export interface SnapshotMessage {
18
+ readonly _tag: 'Snapshot';
19
+ readonly tree: WireTree;
20
+ }
21
+ /** Server → client: a batch of tree patches to fold with `Wire.apply`. */
22
+ export interface PatchesMessage {
23
+ readonly _tag: 'Patches';
24
+ readonly patches: ReadonlyArray<WirePatch>;
25
+ }
26
+ /** Everything the server sends the client: a fresh snapshot or incremental patches. */
27
+ export type ServerMessage = SnapshotMessage | PatchesMessage;
28
+ /** Client → server: fire the trigger behind `handle` with an encoded payload. */
29
+ export interface InvokeMessage {
30
+ readonly _tag: 'Invoke';
31
+ readonly handle: string;
32
+ readonly payload: unknown;
33
+ }
34
+ /** A duplex channel: send `Out`, receive `In`. */
35
+ export interface RemoteTransport<Out, In> {
36
+ readonly send: (message: Out) => void;
37
+ readonly onMessage: (handler: (message: In) => void) => () => void;
38
+ }
39
+ export interface ServerBinding {
40
+ /** Render the first frame and push it. Call once the client is connected. */
41
+ readonly start: () => Promise<void>;
42
+ readonly dispose: () => Promise<void>;
43
+ }
44
+ /** Options for {@link serve} — a single named object so every reform-remote binding shares one shape. */
45
+ export interface ServeOptions {
46
+ readonly scene: Scene;
47
+ readonly transport: RemoteTransport<ServerMessage, InvokeMessage>;
48
+ }
49
+ export declare const serve: (options: ServeOptions) => ServerBinding;
50
+ export interface ClientBinding {
51
+ /** The current rendered node — re-read on each `subscribe` notification. */
52
+ readonly node: () => ReactNode;
53
+ /** Notified whenever applied patches change the tree. */
54
+ readonly subscribe: (listener: () => void) => () => void;
55
+ /**
56
+ * The current wire tree — a stable reference that changes only when a frame is
57
+ * applied, so it is a valid `useSyncExternalStore` snapshot (the React binding
58
+ * builds on this).
59
+ */
60
+ readonly snapshot: () => WireTree;
61
+ readonly dispose: () => void;
62
+ }
63
+ /** Options for {@link connect} — a single named object, mirroring {@link ServeOptions}. */
64
+ export interface ConnectOptions<C extends RemoteContract> {
65
+ readonly transport: RemoteTransport<InvokeMessage, ServerMessage>;
66
+ readonly views: RemoteViewSet<C>;
67
+ }
68
+ export declare const connect: <C extends RemoteContract>(options: ConnectOptions<C>) => ClientBinding;
69
+ //# sourceMappingURL=transport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,EAAQ,KAAK,SAAS,EAAE,KAAK,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AACtE,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAA;AAE7C,OAAO,EAAkB,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAA;AAElF;;;;;GAKG;AAEH;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CACxB;AAED,0EAA0E;AAC1E,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,SAAS,CAAC,CAAA;CAC3C;AAED,uFAAuF;AACvF,MAAM,MAAM,aAAa,GAAG,eAAe,GAAG,cAAc,CAAA;AAE5D,iFAAiF;AACjF,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAC1B;AAED,kDAAkD;AAClD,MAAM,WAAW,eAAe,CAAC,GAAG,EAAE,EAAE;IACtC,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,GAAG,KAAK,IAAI,CAAA;IACrC,QAAQ,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,EAAE,KAAK,IAAI,KAAK,MAAM,IAAI,CAAA;CACnE;AAED,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IACnC,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CACtC;AAUD,yGAAyG;AACzG,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAC,aAAa,EAAE,aAAa,CAAC,CAAA;CAClE;AAED,eAAO,MAAM,KAAK,GAAI,SAAS,YAAY,KAAG,aA0E7C,CAAA;AAED,MAAM,WAAW,aAAa;IAC5B,4EAA4E;IAC5E,QAAQ,CAAC,IAAI,EAAE,MAAM,SAAS,CAAA;IAC9B,yDAAyD;IACzD,QAAQ,CAAC,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAA;IACxD;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,QAAQ,CAAA;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAC7B;AAED,2FAA2F;AAC3F,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,cAAc;IACtD,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAC,aAAa,EAAE,aAAa,CAAC,CAAA;IACjE,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,CAAA;CACjC;AAED,eAAO,MAAM,OAAO,GAAI,CAAC,SAAS,cAAc,EAAE,SAAS,cAAc,CAAC,CAAC,CAAC,KAAG,aA4B9E,CAAA"}
@@ -0,0 +1,116 @@
1
+ import { Match } from 'effect';
2
+ import { Wire } from '@playfast/reform';
3
+ import { makeRemoteServer } from './server';
4
+ import { renderWireTree } from './client';
5
+ /**
6
+ * Debounce window for SERVER-initiated frame pushes (background loads, scheduler ticks).
7
+ * Long enough that the engine drain has folded the change into state and a burst of facts
8
+ * coalesces into one render; short enough to feel live. Client invokes don't wait on this —
9
+ * they push immediately.
10
+ */
11
+ const BACKGROUND_FLUSH_MS = 16;
12
+ export const serve = (options) => {
13
+ const { scene, transport } = options;
14
+ const server = makeRemoteServer(scene);
15
+ // The first frame is a full snapshot (replace); every frame after is the diff since the
16
+ // last (apply). Both keep the server's `frame` baseline in step, so the diffs always
17
+ // reference what the client actually holds. Diffs are emitted both after a client invoke
18
+ // AND whenever the server's OWN state changes (a background load resolving, a scheduler
19
+ // tick) — without the latter, anything the user didn't directly trigger would never reach
20
+ // the client (it would sit on the snapshot's loading state forever).
21
+ //
22
+ // `started` gates pushes until the opening snapshot is sent: a diff that raced ahead of
23
+ // the snapshot would reference a tree the client has not received.
24
+ //
25
+ // `push` is a COALESCING single-flight render: at most one `renderDiff` runs at a time
26
+ // (concurrent ones would interleave the sink captures and corrupt the frame baseline). If
27
+ // a change arrives WHILE a render is in flight — e.g. an async procedure folds its result
28
+ // mid-render — `dirty` is set and a follow-up render runs when the current one settles, so
29
+ // a late state change is never dropped. Both a client invoke and a server-side change
30
+ // (the bus subscriber) funnel through here, so there is exactly one push path.
31
+ const started = { value: false };
32
+ const flight = { promise: null, dirty: false };
33
+ const push = () => {
34
+ if (!started.value)
35
+ return Promise.resolve();
36
+ if (flight.promise !== null) {
37
+ flight.dirty = true;
38
+ return flight.promise;
39
+ }
40
+ const run = (async () => {
41
+ const patches = await server.renderDiff();
42
+ if (patches.length > 0)
43
+ transport.send({ _tag: 'Patches', patches });
44
+ })().finally(() => {
45
+ flight.promise = null;
46
+ if (flight.dirty) {
47
+ flight.dirty = false;
48
+ void push();
49
+ }
50
+ });
51
+ flight.promise = run;
52
+ return run;
53
+ };
54
+ // Server-initiated changes are flushed on a short DEBOUNCE rather than synchronously: it
55
+ // coalesces a burst (a procedure that dispatches several facts) into one render, and the
56
+ // delay lets the drain fold the change into state before we read it — so the diff reflects
57
+ // the settled result, and the background flush never races the synchronous invoke push
58
+ // above. A client invoke still pushes immediately (its own settle path), so user actions
59
+ // stay snappy; this path only carries updates no interaction triggered.
60
+ const debounce = { handle: null };
61
+ const scheduleFlush = () => {
62
+ if (!started.value || debounce.handle !== null)
63
+ return;
64
+ debounce.handle = setTimeout(() => {
65
+ debounce.handle = null;
66
+ void push();
67
+ }, BACKGROUND_FLUSH_MS);
68
+ };
69
+ const start = async () => {
70
+ const tree = await server.render();
71
+ transport.send({ _tag: 'Snapshot', tree });
72
+ started.value = true;
73
+ // Flush anything that changed during the (async) opening render window.
74
+ scheduleFlush();
75
+ };
76
+ const off = transport.onMessage((message) => {
77
+ void server.invoke(message.handle, message.payload).then(push);
78
+ });
79
+ const offChange = server.subscribe(scheduleFlush);
80
+ return {
81
+ start,
82
+ dispose: async () => {
83
+ off();
84
+ offChange();
85
+ if (debounce.handle !== null)
86
+ clearTimeout(debounce.handle);
87
+ await server.dispose();
88
+ },
89
+ };
90
+ };
91
+ export const connect = (options) => {
92
+ const { transport, views } = options;
93
+ const state = { tree: [] };
94
+ const listeners = new Set();
95
+ const config = {
96
+ views,
97
+ invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
98
+ };
99
+ const off = transport.onMessage((message) => {
100
+ // A snapshot replaces the tree wholesale (a fresh or reconnected session);
101
+ // patches fold into it. So a reconnect re-syncs without leaking stale nodes.
102
+ state.tree = Match.value(message).pipe(Match.tag('Snapshot', ({ tree }) => tree), Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)), Match.exhaustive);
103
+ for (const listener of listeners)
104
+ listener();
105
+ });
106
+ return {
107
+ node: () => renderWireTree(state.tree, config),
108
+ subscribe: (listener) => {
109
+ listeners.add(listener);
110
+ return () => void listeners.delete(listener);
111
+ },
112
+ snapshot: () => state.tree,
113
+ dispose: off,
114
+ };
115
+ };
116
+ //# sourceMappingURL=transport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@playfast/reform-remote",
3
+ "playbook": "./playbook",
4
+ "version": "0.0.2",
5
+ "type": "module",
6
+ "description": "Run a reform scene's logic on the server and stream its rendered UI to a thin client over any duplex transport.",
7
+ "keywords": [
8
+ "reform",
9
+ "effect",
10
+ "remote",
11
+ "server-driven-ui",
12
+ "transport",
13
+ "react"
14
+ ],
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/playfast/reform.git",
19
+ "directory": "packages/reform-remote"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/playfast/reform/issues"
23
+ },
24
+ "sideEffects": false,
25
+ "exports": {
26
+ "./package.json": "./package.json",
27
+ ".": "./src/index.ts",
28
+ "./*": "./src/*.ts"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "README.md"
33
+ ],
34
+ "scripts": {
35
+ "clean": "rm -rf dist .tsbuildinfo",
36
+ "check": "tsc --noEmit",
37
+ "build": "tsc -p tsconfig.build.json",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "coverage": "vitest run --coverage",
41
+ "lint": "oxlint src",
42
+ "lint:fix": "oxlint --fix src"
43
+ },
44
+ "peerDependencies": {
45
+ "effect": "*",
46
+ "react": "^19.0.0",
47
+ "@playfast/reform": "*"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }