@playfast/reform-remote 0.0.4 → 0.1.0

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 CHANGED
@@ -9,9 +9,9 @@
9
9
  ---
10
10
 
11
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,
12
+ [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react),
13
+ [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native), and
14
+ [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof). State, events, reducers,
15
15
  async/remote data, and compositions all run **server-side**; the client receives a serialized
16
16
  tree of rendered UI contracts and renders them with local presentations. The wire carries only
17
17
  data — UI-tree patches one way, trigger invocations the other — so there is no API layer to write.
@@ -113,13 +113,6 @@ sockets pair with [`@playfast/reform-remote-node`](https://www.npmjs.com/package
113
113
  | [`@playfast/reform-remote-bun`](https://www.npmjs.com/package/@playfast/reform-remote-bun) | WebSocket server | `Bun.serve` |
114
114
  | [`@playfast/reform-remote-web`](https://www.npmjs.com/package/@playfast/reform-remote-web) | WebSocket client (factory, auto-reconnect) | global `WebSocket` |
115
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
116
  ## License
124
117
 
125
118
  MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.4",
4
+ "version": "0.1.0",
5
5
  "type": "module",
6
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
7
  "keywords": [
package/src/client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createElement, Fragment, type ReactNode, useRef } from 'react'
2
- import { Schema } from 'effect'
2
+ import { Option, Record as Rec, Schema } from 'effect'
3
3
  import {
4
4
  type MadeView,
5
5
  Ui,
@@ -42,11 +42,17 @@ type AnyMadeView = MadeView<any>
42
42
  * their runtime form. Views are authored with `Ui.make` (contract-typed); this is
43
43
  * only the internal shape `renderWireTree` invokes them through.
44
44
  */
45
+ /** Props a slot thunk/component accepts — a React element-props boundary
46
+ * (`<slots.Row slotKey={id}/>`), so it stays a plain optional-field shape. */
47
+ export interface SlotPropsExternalApi {
48
+ readonly slotKey?: string
49
+ }
50
+
45
51
  export type RemoteView = (
46
52
  props: Record<string, unknown>,
47
53
  // A slot thunk optionally takes `{ slotKey }` to select a single keyed child (see the keyed-
48
54
  // slot handling in `WireNodeView`); omitting it renders all of the slot's children.
49
- slots: Record<string, (slotProps?: { readonly slotKey?: string }) => ReactNode>,
55
+ slots: Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode>,
50
56
  events: Record<string, (payload: unknown) => void>,
51
57
  ) => ReactNode
52
58
 
@@ -58,7 +64,7 @@ export type RemoteView = (
58
64
  export interface RegisteredRemoteView {
59
65
  readonly name: string
60
66
  readonly view: RemoteView
61
- readonly propsSchema?: Schema.Schema<Record<string, unknown>, unknown>
67
+ readonly propsSchema: Option.Option<Schema.Schema<Record<string, unknown>, unknown>>
62
68
  }
63
69
 
64
70
  /** The by-name presentation record the renderer walks — the unbranded runtime shape behind
@@ -144,17 +150,16 @@ export type RemoteViews<C extends RemoteContract> = {
144
150
  * accept); an unbranded `Record` can never be substituted.
145
151
  */
146
152
  export const remoteViews = <C extends RemoteContract>(views: RemoteViews<C>): RemoteViewSet<C> => {
147
- const list: ReadonlyArray<AnyMadeView> = Object.values(views)
148
- const byName: ViewRegistry = Object.fromEntries(
149
- list.map((view): readonly [string, RegisteredRemoteView] => {
153
+ const madeViews: ReadonlyArray<AnyMadeView> = Object.values(views)
154
+ const byName: ViewRegistry = Rec.fromEntries(
155
+ madeViews.map((view): readonly [string, RegisteredRemoteView] => {
150
156
  const viewContract = view[UiViewContract]
151
- const propsSchema = viewContract.manifest.props
152
157
  // `Node` is `ReactNode` and `ViewImpl`'s params widen to the dynamic shape the
153
158
  // renderer calls, so the contract-typed view IS a `RemoteView` — no cast.
154
159
  const entry: RegisteredRemoteView = {
155
160
  name: viewContract.manifest.name,
156
161
  view,
157
- ...(propsSchema !== undefined ? { propsSchema } : {}),
162
+ propsSchema: Option.fromNullable(viewContract.manifest.props),
158
163
  }
159
164
  return [entry.name, entry]
160
165
  }),
@@ -163,7 +168,7 @@ export const remoteViews = <C extends RemoteContract>(views: RemoteViews<C>): Re
163
168
  // function OF `C` that is never called, so it needs NO runtime value of `C` — the set is
164
169
  // built cast-free even though the client only has the contract as a type. The renderer
165
170
  // reads the set by string key; the brand is never read at runtime.
166
- return Object.assign(byName, { [ViewSetContract]: (_contract: C): void => {} })
171
+ return { ...byName, [ViewSetContract]: (_contract: C): void => {} }
167
172
  }
168
173
 
169
174
  export interface ClientConfig<C extends RemoteContract> {
@@ -188,15 +193,13 @@ interface RenderConfig {
188
193
  * slot components, and runs the registered view INSIDE this component so the view's
189
194
  * own hooks get a fiber. A child slot renders its wire children as nested `WireNodeView`s.
190
195
  */
191
- const WireNodeView = ({
192
- node,
193
- tree,
194
- config,
195
- }: {
196
+ interface WireNodeViewProps {
196
197
  readonly node: WireNode
197
198
  readonly tree: WireTree
198
199
  readonly config: RenderConfig
199
- }): ReactNode => {
200
+ }
201
+
202
+ const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
200
203
  // The latest render inputs, for the stable slot closures below to read. Mutated every
201
204
  // render so a slot always renders against the current tree, even though its function
202
205
  // identity never changes.
@@ -214,60 +217,73 @@ const WireNodeView = ({
214
217
  const slotCache = useRef<Record<string, () => ReactNode>>({})
215
218
 
216
219
  const registered = config.views[node.name]
217
- if (registered === undefined) return null
218
-
219
- const encoded: Record<string, unknown> = {}
220
- const events: Record<string, (payload: unknown) => void> = {}
221
- for (const prop of node.props) {
222
- if (prop._tag === 'Data') encoded[prop.name] = prop.value
223
- else events[prop.name] = (payload) => config.invoke(prop.handle, payload)
220
+ if (registered === undefined) {
221
+ return null
224
222
  }
225
- const props: Record<string, unknown> =
226
- registered.propsSchema === undefined
227
- ? encoded
228
- : Schema.decodeUnknownSync(registered.propsSchema)(encoded)
229
223
 
230
- const children = Wire.childrenOf(tree, node.id)
231
- const slots: Record<string, (slotProps?: { readonly slotKey?: string }) => ReactNode> = {}
232
- for (const slotName of new Set(children.map((child) => child.slot))) {
233
- if (slotName === null) continue
234
- // A slot is a COMPONENT rendering that slot's wire children — usable as `<slots.Foo/>`
235
- // (Ui.make convention) or `slots.Foo()` (thunk convention). The function is cached so its
236
- // identity is STABLE across renders (see `slotCache` above); it reads `latest.current` so
237
- // each invocation renders against the current tree/config.
238
- //
239
- // KEYED SLOTS: when the caller passes `slotKey` (`<slots.Row slotKey={id} />`), render only
240
- // the ONE wire child whose `key` matches — the per-item identity the server captured from
241
- // the parent's React `key` (see WireNode.key). This is what lets a LIST slot be invoked once
242
- // per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
243
- // `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
244
- // singleton slot rendered once, e.g. `<slots.Create/>`).
224
+ const encoded: Record<string, unknown> = Rec.fromEntries(
225
+ node.props.flatMap((prop) => (prop._tag === 'Data' ? [[prop.name, prop.value] as const] : [])),
226
+ )
227
+ const events: Record<string, (payload: unknown) => void> = Rec.fromEntries(
228
+ node.props.flatMap((prop) =>
229
+ prop._tag === 'Event'
230
+ ? [[prop.name, (payload: unknown) => config.invoke(prop.handle, payload)] as const]
231
+ : [],
232
+ ),
233
+ )
234
+ const props: Record<string, unknown> = Option.match(registered.propsSchema, {
235
+ onNone: () => encoded,
236
+ onSome: (schema) => Schema.decodeUnknownSync(schema)(encoded),
237
+ })
238
+
239
+ // A slot resolves to a COMPONENT rendering that slot's wire children — usable as
240
+ // `<slots.Foo/>` (Ui.make convention) or `slots.Foo()` (thunk convention). The function is
241
+ // cached so its identity is STABLE across renders (see `slotCache` above); it reads
242
+ // `latest.current` so each invocation renders against the current tree/config.
243
+ //
244
+ // KEYED SLOTS: when the caller passes `slotKey` (`<slots.Row slotKey={id} />`), render only
245
+ // the ONE wire child whose `key` matches — the per-item identity the server captured from
246
+ // the parent's React `key` (see WireNode.key). This is what lets a LIST slot be invoked once
247
+ // per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
248
+ // `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
249
+ // singleton slot rendered once, e.g. `<slots.Create/>`).
250
+ const slotFor = (slotName: string): ((slotProps?: SlotPropsExternalApi) => ReactNode) => {
245
251
  const cached = slotCache.current[slotName]
246
- const stable =
247
- cached ??
248
- ((slotProps?: { readonly slotKey?: string }): ReactNode => {
249
- const { node: currentNode, tree: currentTree, config: currentConfig } = latest.current
250
- const requestedKey = slotProps?.slotKey
251
- return createElement(
252
- Fragment,
253
- null,
254
- ...Wire.childrenOf(currentTree, currentNode.id)
255
- .filter((child) => child.slot === slotName)
256
- .filter((child) => requestedKey === undefined || child.key === requestedKey)
257
- .map((child) =>
258
- createElement(WireNodeView, {
259
- key: child.id,
260
- node: child,
261
- tree: currentTree,
262
- config: currentConfig,
263
- }),
264
- ),
265
- )
266
- })
252
+ if (cached !== undefined) {
253
+ return cached
254
+ }
255
+ const stable = (slotProps?: SlotPropsExternalApi): ReactNode => {
256
+ const { node: currentNode, tree: currentTree, config: currentConfig } = latest.current
257
+ const requestedKey = slotProps?.slotKey
258
+ return createElement(
259
+ Fragment,
260
+ null,
261
+ ...Wire.childrenOf(currentTree, currentNode.id)
262
+ .filter((child) => child.slot === slotName)
263
+ .filter((child) => requestedKey === undefined || child.key === requestedKey)
264
+ .map((child) =>
265
+ createElement(WireNodeView, {
266
+ key: child.id,
267
+ node: child,
268
+ tree: currentTree,
269
+ config: currentConfig,
270
+ }),
271
+ ),
272
+ )
273
+ }
267
274
  slotCache.current[slotName] = stable
268
- slots[slotName] = stable
275
+ return stable
269
276
  }
270
277
 
278
+ // Resolve slots LAZILY by name: a view referencing `<slots.Item/>` when that slot has no
279
+ // wire children this frame (e.g. an empty list) gets a component that renders nothing,
280
+ // rather than `undefined` (which React rejects as an invalid element type). Mirrors the
281
+ // engine's total slot proxies — every declared slot is always callable.
282
+ const slots: Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode> = new Proxy(
283
+ Object.create(null),
284
+ { get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined) },
285
+ )
286
+
271
287
  return registered.view(props, slots, events)
272
288
  }
273
289
 
package/src/fixtures.ts CHANGED
@@ -52,7 +52,8 @@ export class CounterUi extends CounterUiBase {}
52
52
  class Counter extends Composition.make('Counter', { title: 'Counter', ui: CounterUi, states: [Count] }) {}
53
53
 
54
54
  /** A boot event the counter scene dispatches before its first render. */
55
- export const bumpedBy = (by: number): EventOf<'Bumped', { by: number }> => Event.construct(Bumped, { by })
55
+ export const bumpedBy = (amount: number): EventOf<'Bumped', { by: number }> =>
56
+ Event.construct(Bumped, { by: amount })
56
57
 
57
58
  export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number }>>): Scene => {
58
59
  const presentation = Layer.mergeAll(
@@ -65,7 +66,7 @@ export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number
65
66
  const bump = yield* Event.trigger(Bumped)
66
67
  return mount({ props: { count }, slots: {}, events: { bump } })
67
68
  }),
68
- Reducer.live(Bump, (n, event) => n + event.by),
69
+ Reducer.live(Bump, (count, event) => count + event.by),
69
70
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
70
71
  return scene(Counter, boot === undefined ? { provide: [app] } : { provide: [app], boot })
71
72
  }
@@ -85,7 +86,7 @@ export const structureCounterScene = (): Scene => {
85
86
  const bump = yield* Event.trigger(Bumped)
86
87
  return mount({ props: { count }, slots: {}, events: { bump } })
87
88
  }),
88
- Reducer.live(Bump, (n, event) => n + event.by),
89
+ Reducer.live(Bump, (count, event) => count + event.by),
89
90
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
90
91
  return scene(Counter, { provide: [app] })
91
92
  }
@@ -210,14 +211,14 @@ export const listScene = (
210
211
  )
211
212
  const app = Layer.mergeAll(
212
213
  Composition.live(ListComp, function* () {
213
- const items = yield* Items
214
+ const listItems = yield* Items
214
215
  return mount({
215
- props: { items },
216
+ props: { items: listItems },
216
217
  slots: {
217
218
  Bar: one({}),
218
- Item: each(items, {
219
- key: (item) => item.id,
220
- props: (item) => ({ id: item.id, label: item.label }),
219
+ Item: each(listItems, {
220
+ key: (entry) => entry.id,
221
+ props: (entry) => ({ id: entry.id, label: entry.label }),
221
222
  }),
222
223
  },
223
224
  })
@@ -227,15 +228,15 @@ export const listScene = (
227
228
  return mount({ props: {}, slots: {}, events: { add } })
228
229
  }),
229
230
  Composition.live(ItemComp, function* () {
230
- const item = (yield* Props) as { id: string; label: string }
231
+ const itemProps = S.decodeUnknownSync(ListItem)(yield* Props)
231
232
  const removeTrigger = yield* Event.trigger(Removed)
232
233
  // The item binds its own id, so the wire `remove` carries no payload — the
233
234
  // client fires it knowing only the handle.
234
- const remove = (): void => removeTrigger({ id: item.id })
235
- return mount({ props: { label: item.label }, slots: {}, events: { remove } })
235
+ const remove = (): void => removeTrigger({ id: itemProps.id })
236
+ return mount({ props: { label: itemProps.label }, slots: {}, events: { remove } })
236
237
  }),
237
- Reducer.live(AddItem, (items, event) => [...items, { id: event.id, label: event.label }]),
238
- Reducer.live(RemoveItem, (items, event) => items.filter((item) => item.id !== event.id)),
238
+ Reducer.live(AddItem, (current, event) => [...current, { id: event.id, label: event.label }]),
239
+ Reducer.live(RemoveItem, (current, event) => current.filter((entry) => entry.id !== event.id)),
239
240
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
240
241
  return scene(ListComp, { provide: [app] })
241
242
  }
@@ -260,7 +261,7 @@ export const asyncCounterScene = (delay: Duration.DurationInput = '40 millis'):
260
261
  const bump = yield* Event.trigger(Bumped)
261
262
  return mount({ props: { count }, slots: {}, events: { bump } })
262
263
  }),
263
- Reducer.live(Bump, (n, event) => n + event.by),
264
+ Reducer.live(Bump, (count, event) => count + event.by),
264
265
  // The procedure runs on the bus AFTER the scene boots: it sleeps past the opening
265
266
  // snapshot, then dispatches `Bumped` — so the only way the client learns the new count
266
267
  // is the server pushing a diff nobody asked for.
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ export * as Transport from './transport'
14
14
  export * as Memory from './memory'
15
15
  export * as React from './react'
16
16
 
17
- export { makeRemoteServer, type RemoteServer } from './server'
17
+ export { makeRemoteServer, type RemoteServer, renderSceneToWire } from './server'
18
18
  export {
19
19
  type ClientConfig,
20
20
  type RegisteredRemoteView,
@@ -35,8 +35,12 @@ export {
35
35
  type RemoteTransport,
36
36
  serve,
37
37
  type ServeOptions,
38
+ serveShared,
39
+ type ServeSharedOptions,
38
40
  type ServerBinding,
39
41
  type ServerMessage,
42
+ type SharedClientHandle,
43
+ type SharedServerBinding,
40
44
  type SnapshotMessage,
41
45
  } from './transport'
42
46
  export { inMemoryTransportPair, type TransportPair } from './memory'
package/src/server.ts CHANGED
@@ -1,4 +1,16 @@
1
- import { Effect, Layer, Match, ManagedRuntime, type ParseResult, PubSub, Queue, Schema } from 'effect'
1
+ import {
2
+ Array as Arr,
3
+ Effect,
4
+ Layer,
5
+ Match,
6
+ ManagedRuntime,
7
+ Option,
8
+ type ParseResult,
9
+ PubSub,
10
+ Queue,
11
+ Record as Rec,
12
+ Schema,
13
+ } from 'effect'
2
14
  import {
3
15
  Bus,
4
16
  Composition,
@@ -34,6 +46,11 @@ import {
34
46
  * evaluates a view. The render walk is breadth-first over the structure tree,
35
47
  * assigning stable ids and encoding props via each contract's own wire schema
36
48
  * (`WiredUiManifest`, Phase 1).
49
+ *
50
+ * The render walk (`renderSceneToWire`) is a free-standing effect that requires only
51
+ * `CompositionService | SlotChild` — so any runtime that supplies a scene's services
52
+ * can render a frame, not just `makeRemoteServer`'s own. `@playfast/reform-driver-shot`
53
+ * runs it against `@playfast/reform-drive`'s runtime to screenshot a driven state.
37
54
  */
38
55
 
39
56
  // The runtime services a closed scene exposes (mirrors proof's erasure boundary:
@@ -50,10 +67,14 @@ const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
50
67
  // is `Record<never, never>`, so each value is `never` — assignable to
51
68
  // `Trigger<unknown>` without a cast — and an omitted `events` defaults to empty.
52
69
  const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
53
- Object.fromEntries(Object.entries(structure.events ?? {}))
70
+ Option.match(Option.fromNullable(structure.events), {
71
+ onNone: () => ({}),
72
+ onSome: (events) => events,
73
+ })
54
74
 
55
75
  const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
56
- scene.provide.reduce((a, b) => Layer.merge(a, b)) as unknown as Layer.Layer<
76
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- closed-scene erasure: scene.provide is typed to MountedServices, recovered structurally as RuntimeServices
77
+ scene.provide.reduce((acc, layer) => Layer.merge(acc, layer)) as unknown as Layer.Layer<
57
78
  RuntimeServices,
58
79
  never,
59
80
  never
@@ -64,11 +85,11 @@ interface Mounted {
64
85
  readonly comp: CompositionClass<unknown>
65
86
  readonly props: unknown
66
87
  readonly id: string
67
- readonly parentId: string | null
68
- readonly slot: string | null
88
+ readonly parentId: Option.Option<string>
89
+ readonly slot: Option.Option<string>
69
90
  readonly childIndex: number
70
- /** The React `key` the parent gave this slot child, or `null` — the keyed-slot selector. */
71
- readonly key: string | null
91
+ /** The React `key` the parent gave this slot child, if any — the keyed-slot selector. */
92
+ readonly key: Option.Option<string>
72
93
  }
73
94
 
74
95
  const childComposition = (child: SlotChild): CompositionClass<unknown> =>
@@ -77,6 +98,228 @@ const childComposition = (child: SlotChild): CompositionClass<unknown> =>
77
98
  const handlesOf = (node: WireNode): ReadonlyArray<string> =>
78
99
  node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
79
100
 
101
+ // A static render (a screenshot) has no client to receive trigger handles, so the
102
+ // default registrar is a no-op: the wire node still carries its `Event` props (with
103
+ // deterministic handles), they're just never registered for invocation.
104
+ const noRegister: TriggerRegistryApi['register'] = () => Effect.void
105
+
106
+ // Structure → wire encoding (plan 02 + 03b). Props come straight from the
107
+ // `Structure` value (data, never a view render). Event triggers ride ON the
108
+ // structure (`structure.events`) and are registered through `register` (the live
109
+ // registry for a streaming server, a no-op for a one-shot render). Produces the
110
+ // wire shape the transport and `Wire.diff` consume.
111
+ const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
112
+ mounted: Mounted,
113
+ structure: Structure<UiContract>,
114
+ register: TriggerRegistryApi['register'],
115
+ ): Effect.fn.Return<WireNode, ParseResult.ParseError> {
116
+ const manifest: UiManifest = mounted.comp.manifest.ui.manifest
117
+ const name = manifest.name
118
+
119
+ // `manifest.props` is `Schema.Schema<any, any>`, so the encoded shape is `any` — a record
120
+ // of wire-encoded prop values keyed by name (no view eval). Absent on the type-only form.
121
+ const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
122
+ onNone: () => Effect.succeed({}),
123
+ onSome: (schema) => Schema.encodeUnknown(schema)(structure.props),
124
+ })
125
+ const dataProps: ReadonlyArray<WireProp> = Rec.toEntries(encodedProps).map(
126
+ ([propName, propValue]): WireProp => ({ _tag: 'Data', name: propName, value: propValue }),
127
+ )
128
+
129
+ const eventSchemas = Option.fromNullable(manifest.events)
130
+ const events = eventsOf(structure)
131
+ const eventProps: ReadonlyArray<WireProp> = yield* Effect.forEach(Rec.toEntries(events), ([eventName, trigger]) =>
132
+ Effect.gen(function* () {
133
+ const schema = Option.flatMap(eventSchemas, (schemas) => Rec.get(schemas, eventName))
134
+ if (Option.isNone(schema)) {
135
+ return Option.none<WireProp>()
136
+ }
137
+ const handle = `${mounted.id}:${eventName}`
138
+ yield* register(handle, trigger, schema.value)
139
+ return Option.some<WireProp>({ _tag: 'Event', name: eventName, handle })
140
+ }),
141
+ ).pipe(Effect.map(Arr.getSomes))
142
+
143
+ return {
144
+ id: mounted.id,
145
+ name,
146
+ parentId: Option.getOrNull(mounted.parentId),
147
+ childIndex: mounted.childIndex,
148
+ slot: Option.getOrNull(mounted.slot),
149
+ key: Option.getOrNull(mounted.key),
150
+ props: [...dataProps, ...eventProps],
151
+ }
152
+ })
153
+
154
+ // Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
155
+ // UiContract>` erases its per-slot fill types at this boundary (the strict typing
156
+ // lives on the concrete contract), so the runtime fills arrive as `unknown` and
157
+ // are recovered structurally — no `as`.
158
+ const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
159
+ typeof candidate === 'object' &&
160
+ candidate !== null &&
161
+ '_tag' in candidate &&
162
+ (candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
163
+
164
+ // Read a structure's fills by slot name, recovering each erased value via the
165
+ // discriminant guard. Returns a plain record keyed by declared slot name.
166
+ const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
167
+ Rec.fromEntries(
168
+ Rec.toEntries(structure.slots).flatMap(([slotName, fillValue]) =>
169
+ isSlotFill(fillValue) ? [[slotName, fillValue] as const] : [],
170
+ ),
171
+ )
172
+
173
+ // Enqueue the children a single declared slot is filled with, reading the fill
174
+ // (Each / One / Absent) from the returned `Structure`. Multiplicity, per-item
175
+ // `key`, and props are carried as data — no view walk, no reconstruction.
176
+ /** A composition's declared slot map, defaulting to an empty record when it declares none. */
177
+ const slotsOf = (comp: CompositionClass<unknown>): Record<string, SlotClass> =>
178
+ Option.getOrElse(Option.fromNullable(comp.manifest.slots), () => ({}))
179
+
180
+ const enqueueStructureSlot = (
181
+ parent: Mounted,
182
+ slotName: string,
183
+ child: CompositionClass<unknown>,
184
+ fill: SlotFill<unknown>,
185
+ ): ReadonlyArray<Mounted> =>
186
+ Match.value(fill).pipe(
187
+ Match.tag('Each', (each) =>
188
+ each.items.map(
189
+ (entry, index): Mounted => ({
190
+ comp: child,
191
+ props: entry.props,
192
+ id: `${parent.id}.${slotName}.${index}`,
193
+ parentId: Option.some(parent.id),
194
+ slot: Option.some(slotName),
195
+ childIndex: index,
196
+ key: Option.fromNullable(entry.key),
197
+ }),
198
+ ),
199
+ ),
200
+ Match.tag('One', (single) => [
201
+ {
202
+ comp: child,
203
+ props: single.props,
204
+ id: `${parent.id}.${slotName}.0`,
205
+ parentId: Option.some(parent.id),
206
+ slot: Option.some(slotName),
207
+ childIndex: 0,
208
+ key: Option.none(),
209
+ } satisfies Mounted,
210
+ ]),
211
+ Match.tag('Absent', () => []),
212
+ Match.exhaustive,
213
+ )
214
+
215
+ const collect = Effect.fn('collect')(function* (
216
+ root: CompositionClass<unknown>,
217
+ ): Effect.fn.Return<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> {
218
+ const bindings = new Map<SlotClass, CompositionClass<unknown>>()
219
+ // `walk` recurses, so it carries an explicit type so its body can reference itself cast-free.
220
+ const walk: (comp: CompositionClass<unknown>) => Effect.Effect<void, never, SlotChild> = Effect.fn(
221
+ 'walk',
222
+ )(function* (comp: CompositionClass<unknown>): Effect.fn.Return<void, never, SlotChild> {
223
+ yield* Effect.forEach(Rec.values(slotsOf(comp)), (slotClass) =>
224
+ Effect.gen(function* () {
225
+ if (bindings.has(slotClass)) {
226
+ return
227
+ }
228
+ const child = childComposition(yield* slotClass.tag)
229
+ bindings.set(slotClass, child)
230
+ yield* walk(child)
231
+ }),
232
+ )
233
+ })
234
+ yield* walk(root)
235
+ return bindings
236
+ })
237
+
238
+ // `renderLevel` recurses level by level, so it carries an explicit type so its body can
239
+ // reference itself cast-free.
240
+ const renderLevel: (
241
+ frontier: ReadonlyArray<Mounted>,
242
+ bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
243
+ register: TriggerRegistryApi['register'],
244
+ ) => Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> = Effect.fn(
245
+ 'renderLevel',
246
+ )(function* (
247
+ frontier: ReadonlyArray<Mounted>,
248
+ bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
249
+ register: TriggerRegistryApi['register'],
250
+ ): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> {
251
+ if (frontier.length === 0) {
252
+ return []
253
+ }
254
+ const rendered = yield* Effect.forEach(frontier, (mounted) =>
255
+ Effect.gen(function* () {
256
+ const service = yield* mounted.comp.tag
257
+ const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
258
+ const frame = yield* Composition.render(service, env)
259
+ // Every composition returns a `Structure` (the Node render path is gone): its
260
+ // wire node's props come from `frame.props`, event triggers from `frame.events`,
261
+ // and children are enqueued by reading each declared slot's fill — no view eval.
262
+ if (!isStructure(frame)) {
263
+ return yield* Effect.dieMessage(
264
+ `reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
265
+ )
266
+ }
267
+ const node = yield* toWireNodeFromStructure(mounted, frame, register)
268
+ const fills = structureFills(frame)
269
+ const children = Rec.toEntries(slotsOf(mounted.comp)).flatMap(([slotName, slotClass]) => {
270
+ const child = bindings.get(slotClass)
271
+ if (child === undefined) {
272
+ return []
273
+ }
274
+ const fill = fills[slotName]
275
+ if (fill === undefined) {
276
+ return []
277
+ }
278
+ return enqueueStructureSlot(mounted, slotName, child, fill)
279
+ })
280
+ return { node, children }
281
+ }),
282
+ )
283
+ const nodes = rendered.map((entry) => entry.node)
284
+ const next = rendered.flatMap((entry) => entry.children)
285
+ const rest = yield* renderLevel(next, bindings, register)
286
+ return [...nodes, ...rest]
287
+ })
288
+
289
+ /**
290
+ * Render the scene's CURRENT frame to a serializable `WireTree`, breadth-first from
291
+ * the root composition. Requires only the scene's render services
292
+ * (`CompositionService | SlotChild`), so it runs on any runtime built from the same
293
+ * closed scene — `makeRemoteServer`'s, or `@playfast/reform-drive`'s (the screenshot
294
+ * path). `register` defaults to a no-op: a one-shot render needs no live trigger
295
+ * registry, while `makeRemoteServer` passes its connection registry so the streamed
296
+ * client can invoke handles.
297
+ */
298
+ /** Options for {@link renderSceneToWire}; the registrar defaults to a no-op for one-shot renders. */
299
+ export interface RenderSceneToWireOptions {
300
+ readonly register: TriggerRegistryApi['register']
301
+ }
302
+
303
+ // oxlint-disable-next-line reform-rules/prefer-effect-fn -- exported binding: Effect.fn's inferred type isn't portable under isolatedDeclarations
304
+ export const renderSceneToWire = (
305
+ scene: Scene,
306
+ options?: RenderSceneToWireOptions,
307
+ ): Effect.Effect<WireTree, ParseResult.ParseError, CompositionService | SlotChild> =>
308
+ Effect.gen(function* () {
309
+ const register = options?.register ?? noRegister
310
+ const bindings = yield* collect(scene.composition)
311
+ const root: Mounted = {
312
+ comp: scene.composition,
313
+ props: {},
314
+ id: '0',
315
+ parentId: Option.none(),
316
+ slot: Option.none(),
317
+ childIndex: 0,
318
+ key: Option.none(),
319
+ }
320
+ return yield* renderLevel([root], bindings, register)
321
+ })
322
+
80
323
  export interface RemoteServer {
81
324
  /** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
82
325
  readonly render: () => Promise<WireTree>
@@ -97,6 +340,12 @@ export interface RemoteServer {
97
340
  * no user interaction triggered. Returns an unsubscribe handle.
98
341
  */
99
342
  readonly subscribe: (listener: () => void) => () => void
343
+ /**
344
+ * The last emitted frame — the baseline every `renderDiff` diffs against. A new
345
+ * client attaching to a SHARED server snapshots off this (rather than a fresh
346
+ * `render()`, which would re-key the baseline and desync clients mid-stream).
347
+ */
348
+ readonly currentTree: () => WireTree
100
349
  /** Tear down the runtime and its forked fibers. */
101
350
  readonly dispose: () => Promise<void>
102
351
  }
@@ -105,172 +354,11 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
105
354
  const runtime = ManagedRuntime.make(closedSceneLayer(scene))
106
355
  const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
107
356
 
108
- const collect = (
109
- root: CompositionClass<unknown>,
110
- ): Effect.Effect<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> =>
111
- Effect.gen(function* () {
112
- const bindings = new Map<SlotClass, CompositionClass<unknown>>()
113
- const walk = (comp: CompositionClass<unknown>): Effect.Effect<void, never, SlotChild> =>
114
- Effect.gen(function* () {
115
- for (const slotClass of Object.values(comp.manifest.slots ?? {})) {
116
- if (bindings.has(slotClass)) continue
117
- const child = childComposition(yield* slotClass.tag)
118
- bindings.set(slotClass, child)
119
- yield* walk(child)
120
- }
121
- })
122
- yield* walk(root)
123
- return bindings
124
- })
125
-
126
- // Structure → wire encoding (plan 02 + 03b). Props come straight from the
127
- // `Structure` value (data, never a view render). Event triggers ride ON the
128
- // structure (`structure.events`) and are registered from there. Produces the wire
129
- // shape the transport and `Wire.diff` consume.
130
- const toWireNodeFromStructure = (
131
- m: Mounted,
132
- structure: Structure<UiContract>,
133
- ): Effect.Effect<WireNode, ParseResult.ParseError> =>
134
- Effect.gen(function* () {
135
- const manifest = m.comp.manifest.ui.manifest as UiManifest
136
- const name = manifest.name
137
-
138
- const dataProps: WireProp[] = []
139
- if (manifest.props !== undefined) {
140
- const encoded = yield* Schema.encodeUnknown(manifest.props)(structure.props)
141
- for (const [propName, value] of Object.entries(encoded as Record<string, unknown>)) {
142
- dataProps.push({ _tag: 'Data', name: propName, value })
143
- }
144
- }
145
-
146
- const eventSchemas = manifest.events ?? {}
147
- const eventProps: WireProp[] = []
148
- const events = eventsOf(structure)
149
- for (const [eventName, trigger] of Object.entries(events)) {
150
- const schema = eventSchemas[eventName]
151
- if (schema === undefined) continue
152
- const handle = `${m.id}:${eventName}`
153
- yield* registry.register(handle, trigger, schema)
154
- eventProps.push({ _tag: 'Event', name: eventName, handle })
155
- }
156
-
157
- return {
158
- id: m.id,
159
- name,
160
- parentId: m.parentId,
161
- childIndex: m.childIndex,
162
- slot: m.slot,
163
- key: m.key,
164
- props: [...dataProps, ...eventProps],
165
- }
166
- })
167
-
168
- // Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
169
- // UiContract>` erases its per-slot fill types at this boundary (the strict typing
170
- // lives on the concrete contract), so the runtime fills arrive as `unknown` and
171
- // are recovered structurally — no `as`.
172
- const isSlotFill = (u: unknown): u is SlotFill<unknown> =>
173
- typeof u === 'object' &&
174
- u !== null &&
175
- '_tag' in u &&
176
- (u._tag === 'Each' || u._tag === 'One' || u._tag === 'Absent')
177
-
178
- // Read a structure's fills by slot name, recovering each erased value via the
179
- // discriminant guard. Returns a plain record keyed by declared slot name.
180
- const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
181
- Object.fromEntries(
182
- Object.entries(structure.slots).flatMap(([name, value]) =>
183
- isSlotFill(value) ? [[name, value] as const] : [],
184
- ),
185
- )
186
-
187
- // Enqueue the children a single declared slot is filled with, reading the fill
188
- // (Each / One / Absent) from the returned `Structure`. Multiplicity, per-item
189
- // `key`, and props are carried as data — no view walk, no reconstruction.
190
- const enqueueStructureSlot = (
191
- parent: Mounted,
192
- slotName: string,
193
- child: CompositionClass<unknown>,
194
- fill: SlotFill<unknown>,
195
- next: Mounted[],
196
- ): void =>
197
- Match.value(fill).pipe(
198
- Match.tag('Each', (each) => {
199
- each.items.forEach((item, index) => {
200
- next.push({
201
- comp: child,
202
- props: item.props,
203
- id: `${parent.id}.${slotName}.${index}`,
204
- parentId: parent.id,
205
- slot: slotName,
206
- childIndex: index,
207
- key: item.key,
208
- })
209
- })
210
- }),
211
- Match.tag('One', (single) => {
212
- next.push({
213
- comp: child,
214
- props: single.props,
215
- id: `${parent.id}.${slotName}.0`,
216
- parentId: parent.id,
217
- slot: slotName,
218
- childIndex: 0,
219
- key: null,
220
- })
221
- }),
222
- Match.tag('Absent', () => {}),
223
- Match.exhaustive,
224
- )
225
-
226
- const renderLevel = (
227
- frontier: ReadonlyArray<Mounted>,
228
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
229
- ): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> =>
230
- Effect.gen(function* () {
231
- if (frontier.length === 0) return []
232
- const next: Mounted[] = []
233
- const nodes: WireNode[] = []
234
- for (const mounted of frontier) {
235
- const service = yield* mounted.comp.tag
236
- const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
237
- const frame = yield* Composition.render(service, env)
238
- // Every composition returns a `Structure` (the Node render path is gone): its
239
- // wire node's props come from `frame.props`, event triggers from `frame.events`,
240
- // and children are enqueued by reading each declared slot's fill — no view eval.
241
- if (!isStructure(frame)) {
242
- return yield* Effect.dieMessage(
243
- `reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
244
- )
245
- }
246
- nodes.push(yield* toWireNodeFromStructure(mounted, frame))
247
- const fills = structureFills(frame)
248
- for (const [slotName, slotClass] of Object.entries(mounted.comp.manifest.slots ?? {})) {
249
- const child = bindings.get(slotClass)
250
- if (child === undefined) continue
251
- const fill = fills[slotName]
252
- if (fill === undefined) continue
253
- enqueueStructureSlot(mounted, slotName, child, fill, next)
254
- }
255
- }
256
- const rest = yield* renderLevel(next, bindings)
257
- return [...nodes, ...rest]
258
- })
259
-
260
- const renderEffect = Effect.gen(function* () {
261
- yield* settleDrain
262
- const bindings = yield* collect(scene.composition)
263
- const root: Mounted = {
264
- comp: scene.composition,
265
- props: {},
266
- id: '0',
267
- parentId: null,
268
- slot: null,
269
- childIndex: 0,
270
- key: null,
271
- }
272
- return yield* renderLevel([root], bindings)
273
- })
357
+ // The shared render walk, registering triggers in this connection's registry so the
358
+ // client can invoke them. `settleDrain` first so an in-flight batch folds into state.
359
+ const renderEffect = settleDrain.pipe(
360
+ Effect.zipRight(renderSceneToWire(scene, { register: registry.register })),
361
+ )
274
362
 
275
363
  // Server-initiated change notification: a forked daemon subscribes to the engine bus
276
364
  // and, after each batch settles, fans out to registered listeners. This is what lets a
@@ -280,7 +368,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
280
368
  // binding's opening snapshot captures all state up to `start`).
281
369
  const changeListeners = new Set<() => void>()
282
370
  const notifyChange = (): void => {
283
- for (const listener of changeListeners) listener()
371
+ changeListeners.forEach((listener) => listener())
284
372
  }
285
373
  runtime.runFork(
286
374
  Effect.scoped(
@@ -298,31 +386,43 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
298
386
  ),
299
387
  )
300
388
 
301
- runtime.runSync(Effect.forEach(scene.boot ?? [], (event) => publish('High', event)))
389
+ runtime.runSync(
390
+ Effect.forEach(
391
+ Option.getOrElse(Option.fromNullable(scene.boot), () => []),
392
+ (event) => publish('High', event),
393
+ ),
394
+ )
302
395
 
303
396
  // The last emitted frame, to diff against. A const holder whose field we swap
304
397
  // (no reassigned binding — house rule), not a `let`.
305
398
  const frame: { tree: WireTree } = { tree: [] }
306
399
 
307
400
  const render = (): Promise<WireTree> =>
308
- runtime.runPromise(renderEffect).then((tree) => {
309
- frame.tree = tree
310
- return tree
311
- })
401
+ runtime.runPromise(
402
+ renderEffect.pipe(
403
+ Effect.tap((tree) =>
404
+ Effect.sync(() => {
405
+ frame.tree = tree
406
+ }),
407
+ ),
408
+ ),
409
+ )
312
410
 
313
411
  const renderDiff = (): Promise<ReadonlyArray<WirePatch>> =>
314
- runtime.runPromise(renderEffect).then((next) => {
315
- const previous = frame.tree
316
- const patches = Wire.diff(previous, next)
317
- frame.tree = next
318
- const nextIds = new Set(next.map((node) => node.id))
319
- const staleHandles = previous
320
- .filter((node) => !nextIds.has(node.id))
321
- .flatMap(handlesOf)
322
- return runtime
323
- .runPromise(Effect.forEach(staleHandles, (handle) => registry.revoke(handle)))
324
- .then(() => patches)
325
- })
412
+ runtime.runPromise(
413
+ Effect.gen(function* () {
414
+ const next = yield* renderEffect
415
+ const previous = frame.tree
416
+ const patches = Wire.diff(previous, next)
417
+ frame.tree = next
418
+ const nextIds = new Set(next.map((node) => node.id))
419
+ const staleHandles = previous
420
+ .filter((node) => !nextIds.has(node.id))
421
+ .flatMap(handlesOf)
422
+ yield* Effect.forEach(staleHandles, (handle) => registry.revoke(handle))
423
+ return patches
424
+ }),
425
+ )
326
426
 
327
427
  return {
328
428
  render,
@@ -338,6 +438,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
338
438
  changeListeners.add(listener)
339
439
  return () => void changeListeners.delete(listener)
340
440
  },
441
+ currentTree: () => frame.tree,
341
442
  dispose: () => runtime.dispose(),
342
443
  }
343
444
  }
@@ -4,7 +4,7 @@ import { expect, test, vi } from 'vitest'
4
4
  import { Schema as S } from 'effect'
5
5
  import { Ui, ui } from '@playfast/reform'
6
6
  import type { InvokeMessage, ServerMessage } from './transport'
7
- import { connect, serve } from './transport'
7
+ import { connect, serve, serveShared } from './transport'
8
8
  import { inMemoryTransportPair } from './memory'
9
9
  import { remoteViews } from './client'
10
10
  import { asyncCounterScene, CounterUi, counterScene } from './fixtures'
@@ -181,6 +181,107 @@ test('two clients each get their own server runtime — state is isolated', asyn
181
181
  }
182
182
  })
183
183
 
184
+ test('serveShared: two clients share ONE runtime — one client drives state for both', async () => {
185
+ const one = inMemoryTransportPair<ServerMessage, InvokeMessage>()
186
+ const two = inMemoryTransportPair<ServerMessage, InvokeMessage>()
187
+
188
+ const probeOne: Probe = {}
189
+ const probeTwo: Probe = {}
190
+ const clientOne = connect({
191
+ transport: one.client,
192
+ views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeOne) }),
193
+ })
194
+ const clientTwo = connect({
195
+ transport: two.client,
196
+ views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeTwo) }),
197
+ })
198
+
199
+ // One shared server; both transports attach to the same runtime.
200
+ const shared = serveShared({ scene: counterScene() })
201
+ const handleOne = shared.addClient(one.server)
202
+ const handleTwo = shared.addClient(two.server)
203
+ try {
204
+ // Both clients receive the opening snapshot off the shared baseline.
205
+ await vi.waitFor(() => {
206
+ draw(clientOne.node())
207
+ draw(clientTwo.node())
208
+ expect(probeOne.props).toEqual({ count: 0 })
209
+ expect(probeTwo.props).toEqual({ count: 0 })
210
+ })
211
+
212
+ // A bump from client ONE is broadcast to BOTH — they share the same state.
213
+ probeOne.events?.['bump']?.({ by: 5 })
214
+ await vi.waitFor(() => {
215
+ draw(clientOne.node())
216
+ draw(clientTwo.node())
217
+ expect(probeOne.props).toEqual({ count: 5 })
218
+ expect(probeTwo.props).toEqual({ count: 5 })
219
+ })
220
+
221
+ // And a bump from client TWO lands on the same shared counter.
222
+ probeTwo.events?.['bump']?.({ by: 3 })
223
+ await vi.waitFor(() => {
224
+ draw(clientOne.node())
225
+ draw(clientTwo.node())
226
+ expect(probeOne.props).toEqual({ count: 8 })
227
+ expect(probeTwo.props).toEqual({ count: 8 })
228
+ })
229
+ } finally {
230
+ handleOne.remove()
231
+ handleTwo.remove()
232
+ clientOne.dispose()
233
+ clientTwo.dispose()
234
+ await shared.dispose()
235
+ }
236
+ })
237
+
238
+ test('serveShared: a client connecting mid-stream snapshots the current shared state', async () => {
239
+ const one = inMemoryTransportPair<ServerMessage, InvokeMessage>()
240
+
241
+ const probeOne: Probe = {}
242
+ const clientOne = connect({
243
+ transport: one.client,
244
+ views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeOne) }),
245
+ })
246
+
247
+ const shared = serveShared({ scene: counterScene() })
248
+ const handleOne = shared.addClient(one.server)
249
+ try {
250
+ await vi.waitFor(() => {
251
+ draw(clientOne.node())
252
+ expect(probeOne.props).toEqual({ count: 0 })
253
+ })
254
+
255
+ probeOne.events?.['bump']?.({ by: 4 })
256
+ await vi.waitFor(() => {
257
+ draw(clientOne.node())
258
+ expect(probeOne.props).toEqual({ count: 4 })
259
+ })
260
+
261
+ // A late joiner must see the CURRENT shared count (4), not a fresh 0.
262
+ const two = inMemoryTransportPair<ServerMessage, InvokeMessage>()
263
+ const probeTwo: Probe = {}
264
+ const clientTwo = connect({
265
+ transport: two.client,
266
+ views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeTwo) }),
267
+ })
268
+ const handleTwo = shared.addClient(two.server)
269
+ try {
270
+ await vi.waitFor(() => {
271
+ draw(clientTwo.node())
272
+ expect(probeTwo.props).toEqual({ count: 4 })
273
+ })
274
+ } finally {
275
+ handleTwo.remove()
276
+ clientTwo.dispose()
277
+ }
278
+ } finally {
279
+ handleOne.remove()
280
+ clientOne.dispose()
281
+ await shared.dispose()
282
+ }
283
+ })
284
+
184
285
  test('dispose detaches both ends: a post-dispose invoke no longer reaches the server', async () => {
185
286
  const { server: serverTransport, client: clientTransport } = inMemoryTransportPair<
186
287
  ServerMessage,
package/src/transport.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { Match } from 'effect'
1
+ import { Effect, Fiber, Match, Option } from 'effect'
2
2
  import type { ReactNode } from 'react'
3
3
  import { Wire, type WirePatch, type WireTree } from '@playfast/reform'
4
4
  import type { Scene } from '@playfast/reform'
5
5
  import { makeRemoteServer } from './server'
6
- import { renderWireTree, type RemoteContract, type RemoteViewSet } from './client'
6
+ import { renderWireTree, type ClientConfig, type RemoteContract, type RemoteViewSet } from './client'
7
7
 
8
8
  /**
9
9
  * Binds the server driver and the client renderer to a transport. The wire
@@ -85,24 +85,36 @@ export const serve = (options: ServeOptions): ServerBinding => {
85
85
  // a late state change is never dropped. Both a client invoke and a server-side change
86
86
  // (the bus subscriber) funnel through here, so there is exactly one push path.
87
87
  const started = { value: false }
88
- const flight = { promise: null as Promise<void> | null, dirty: false }
88
+ const flight = { promise: Option.none<Promise<void>>(), dirty: false }
89
89
  const push = (): Promise<void> => {
90
- if (!started.value) return Promise.resolve()
91
- if (flight.promise !== null) {
90
+ if (!started.value) {
91
+ return Effect.runPromise(Effect.void)
92
+ }
93
+ if (Option.isSome(flight.promise)) {
92
94
  flight.dirty = true
93
- return flight.promise
95
+ return flight.promise.value
94
96
  }
95
- const run = (async (): Promise<void> => {
96
- const patches = await server.renderDiff()
97
- if (patches.length > 0) transport.send({ _tag: 'Patches', patches })
98
- })().finally(() => {
99
- flight.promise = null
100
- if (flight.dirty) {
101
- flight.dirty = false
102
- void push()
103
- }
104
- })
105
- flight.promise = run
97
+ // `Effect.ensuring` runs the settle/re-render cleanup whether the render succeeds or fails
98
+ // (the old `.finally`), so the single-flight slot is never left stuck.
99
+ const run = Effect.runPromise(
100
+ Effect.gen(function* () {
101
+ const patches = yield* Effect.promise(() => server.renderDiff())
102
+ if (patches.length > 0) {
103
+ transport.send({ _tag: 'Patches', patches })
104
+ }
105
+ }).pipe(
106
+ Effect.ensuring(
107
+ Effect.sync(() => {
108
+ flight.promise = Option.none()
109
+ if (flight.dirty) {
110
+ flight.dirty = false
111
+ void push()
112
+ }
113
+ }),
114
+ ),
115
+ ),
116
+ )
117
+ flight.promise = Option.some(run)
106
118
  return run
107
119
  }
108
120
  // Server-initiated changes are flushed on a short DEBOUNCE rather than synchronously: it
@@ -111,13 +123,22 @@ export const serve = (options: ServeOptions): ServerBinding => {
111
123
  // the settled result, and the background flush never races the synchronous invoke push
112
124
  // above. A client invoke still pushes immediately (its own settle path), so user actions
113
125
  // stay snappy; this path only carries updates no interaction triggered.
114
- const debounce = { handle: null as ReturnType<typeof setTimeout> | null }
126
+ const debounce = { fiber: Option.none<Fiber.RuntimeFiber<void>>() }
115
127
  const scheduleFlush = (): void => {
116
- if (!started.value || debounce.handle !== null) return
117
- debounce.handle = setTimeout(() => {
118
- debounce.handle = null
119
- void push()
120
- }, BACKGROUND_FLUSH_MS)
128
+ if (!started.value || Option.isSome(debounce.fiber)) {
129
+ return
130
+ }
131
+ const fiber = Effect.runFork(
132
+ Effect.sleep(BACKGROUND_FLUSH_MS).pipe(
133
+ Effect.zipRight(
134
+ Effect.sync(() => {
135
+ debounce.fiber = Option.none()
136
+ void push()
137
+ }),
138
+ ),
139
+ ),
140
+ )
141
+ debounce.fiber = Option.some(fiber)
121
142
  }
122
143
  const start = async (): Promise<void> => {
123
144
  const tree = await server.render()
@@ -127,7 +148,11 @@ export const serve = (options: ServeOptions): ServerBinding => {
127
148
  scheduleFlush()
128
149
  }
129
150
  const off = transport.onMessage((message) => {
130
- void server.invoke(message.handle, message.payload).then(push)
151
+ void Effect.runPromise(
152
+ Effect.promise(() => server.invoke(message.handle, message.payload)).pipe(
153
+ Effect.zipRight(Effect.promise(push)),
154
+ ),
155
+ )
131
156
  })
132
157
  const offChange = server.subscribe(scheduleFlush)
133
158
  return {
@@ -135,7 +160,159 @@ export const serve = (options: ServeOptions): ServerBinding => {
135
160
  dispose: async (): Promise<void> => {
136
161
  off()
137
162
  offChange()
138
- if (debounce.handle !== null) clearTimeout(debounce.handle)
163
+ if (Option.isSome(debounce.fiber)) {
164
+ Effect.runFork(Fiber.interrupt(debounce.fiber.value))
165
+ }
166
+ await server.dispose()
167
+ },
168
+ }
169
+ }
170
+
171
+ /** A client's membership in a {@link SharedServerBinding}; `remove` detaches it. */
172
+ export interface SharedClientHandle {
173
+ /** Stop sending this client frames and drop its invoke listener. Call on disconnect. */
174
+ readonly remove: () => void
175
+ }
176
+
177
+ export interface SharedServerBinding {
178
+ /**
179
+ * Attach a freshly-connected transport to the ONE shared runtime: send it a
180
+ * Snapshot of the current shared tree, then fold it into the broadcast set so
181
+ * every later frame — and every other client's invoke result — reaches it too.
182
+ * Returns a handle to detach the client when its socket closes.
183
+ */
184
+ readonly addClient: (transport: RemoteTransport<ServerMessage, InvokeMessage>) => SharedClientHandle
185
+ /** Tear down the shared runtime and drop every client. */
186
+ readonly dispose: () => Promise<void>
187
+ }
188
+
189
+ /** Options for {@link serveShared} — one scene, run once, shared by every client. */
190
+ export interface ServeSharedOptions {
191
+ readonly scene: Scene
192
+ }
193
+
194
+ /**
195
+ * The single-instance counterpart to {@link serve}: ONE `makeRemoteServer(scene)` —
196
+ * one runtime, one trigger registry, one frame baseline — shared by every connected
197
+ * client. A client's invoke fires on the shared runtime and the resulting diff is
198
+ * broadcast to ALL clients, so every connection sees and drives the same state.
199
+ *
200
+ * Sync is structural: all clients hold the same baseline, so one render's diff applies
201
+ * to all. A client connecting mid-stream snapshots off the live `currentTree()` (after
202
+ * the opening render settles) and joins the broadcast set in the same tick — it never
203
+ * misses or double-applies a frame.
204
+ */
205
+ export const serveShared = (options: ServeSharedOptions): SharedServerBinding => {
206
+ const { scene } = options
207
+ const server = makeRemoteServer(scene)
208
+ const clients = new Set<RemoteTransport<ServerMessage, InvokeMessage>>()
209
+ const broadcast = (message: ServerMessage): void => {
210
+ clients.forEach((client) => client.send(message))
211
+ }
212
+
213
+ // The shared push loop: same coalescing single-flight as `serve`, but one render's
214
+ // patches fan out to EVERY client. `started` gates pushes until the opening render has
215
+ // set the baseline; `dirty` re-runs a render that a late change raced into mid-flight.
216
+ const started = { value: false }
217
+ const flight = { promise: Option.none<Promise<void>>(), dirty: false }
218
+ const push = (): Promise<void> => {
219
+ if (!started.value) {
220
+ return Effect.runPromise(Effect.void)
221
+ }
222
+ if (Option.isSome(flight.promise)) {
223
+ flight.dirty = true
224
+ return flight.promise.value
225
+ }
226
+ const run = Effect.runPromise(
227
+ Effect.gen(function* () {
228
+ const patches = yield* Effect.promise(() => server.renderDiff())
229
+ if (patches.length > 0) {
230
+ broadcast({ _tag: 'Patches', patches })
231
+ }
232
+ }).pipe(
233
+ Effect.ensuring(
234
+ Effect.sync(() => {
235
+ flight.promise = Option.none()
236
+ if (flight.dirty) {
237
+ flight.dirty = false
238
+ void push()
239
+ }
240
+ }),
241
+ ),
242
+ ),
243
+ )
244
+ flight.promise = Option.some(run)
245
+ return run
246
+ }
247
+
248
+ const debounce = { fiber: Option.none<Fiber.RuntimeFiber<void>>() }
249
+ const scheduleFlush = (): void => {
250
+ if (!started.value || Option.isSome(debounce.fiber)) {
251
+ return
252
+ }
253
+ const fiber = Effect.runFork(
254
+ Effect.sleep(BACKGROUND_FLUSH_MS).pipe(
255
+ Effect.zipRight(
256
+ Effect.sync(() => {
257
+ debounce.fiber = Option.none()
258
+ void push()
259
+ }),
260
+ ),
261
+ ),
262
+ )
263
+ debounce.fiber = Option.some(fiber)
264
+ }
265
+
266
+ // Render once up front so the shared baseline exists before any client snapshots off
267
+ // it. Clients `await ready`, so they never capture the empty pre-render tree. Anything
268
+ // that changed during the (async) opening render is flushed by the trailing scheduleFlush.
269
+ const ready = (async (): Promise<void> => {
270
+ await server.render()
271
+ started.value = true
272
+ scheduleFlush()
273
+ })()
274
+
275
+ const offChange = server.subscribe(scheduleFlush)
276
+
277
+ const addClient = (
278
+ transport: RemoteTransport<ServerMessage, InvokeMessage>,
279
+ ): SharedClientHandle => {
280
+ const off = transport.onMessage((message) => {
281
+ void Effect.runPromise(
282
+ Effect.promise(() => server.invoke(message.handle, message.payload)).pipe(
283
+ Effect.zipRight(Effect.promise(push)),
284
+ ),
285
+ )
286
+ })
287
+ // Once the baseline exists, send this client its opening Snapshot and join the
288
+ // broadcast set in the SAME tick — no async push can interleave between the two, so
289
+ // the client's first frame and every subsequent diff stay in lockstep.
290
+ void Effect.runPromise(
291
+ Effect.promise(() => ready).pipe(
292
+ Effect.zipRight(
293
+ Effect.sync(() => {
294
+ transport.send({ _tag: 'Snapshot', tree: server.currentTree() })
295
+ clients.add(transport)
296
+ }),
297
+ ),
298
+ ),
299
+ )
300
+ return {
301
+ remove: (): void => {
302
+ off()
303
+ clients.delete(transport)
304
+ },
305
+ }
306
+ }
307
+
308
+ return {
309
+ addClient,
310
+ dispose: async (): Promise<void> => {
311
+ offChange()
312
+ if (Option.isSome(debounce.fiber)) {
313
+ Effect.runFork(Fiber.interrupt(debounce.fiber.value))
314
+ }
315
+ clients.clear()
139
316
  await server.dispose()
140
317
  },
141
318
  }
@@ -165,10 +342,9 @@ export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): C
165
342
  const { transport, views } = options
166
343
  const state: { tree: WireTree } = { tree: [] }
167
344
  const listeners = new Set<() => void>()
168
- const config = {
345
+ const config: ClientConfig<C> = {
169
346
  views,
170
- invoke: (handle: string, payload: unknown): void =>
171
- transport.send({ _tag: 'Invoke', handle, payload }),
347
+ invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
172
348
  }
173
349
  const off = transport.onMessage((message) => {
174
350
  // A snapshot replaces the tree wholesale (a fresh or reconnected session);
@@ -178,7 +354,7 @@ export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): C
178
354
  Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
179
355
  Match.exhaustive,
180
356
  )
181
- for (const listener of listeners) listener()
357
+ listeners.forEach((listener) => listener())
182
358
  })
183
359
  return {
184
360
  node: () => renderWireTree(state.tree, config),