@playfast/reform-remote 0.0.4 → 0.0.5

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/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.0.5",
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
@@ -227,47 +227,52 @@ const WireNodeView = ({
227
227
  ? encoded
228
228
  : Schema.decodeUnknownSync(registered.propsSchema)(encoded)
229
229
 
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/>`).
230
+ // A slot resolves to a COMPONENT rendering that slot's wire children usable as
231
+ // `<slots.Foo/>` (Ui.make convention) or `slots.Foo()` (thunk convention). The function is
232
+ // cached so its identity is STABLE across renders (see `slotCache` above); it reads
233
+ // `latest.current` so each invocation renders against the current tree/config.
234
+ //
235
+ // KEYED SLOTS: when the caller passes `slotKey` (`<slots.Row slotKey={id} />`), render only
236
+ // the ONE wire child whose `key` matches the per-item identity the server captured from
237
+ // the parent's React `key` (see WireNode.key). This is what lets a LIST slot be invoked once
238
+ // per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
239
+ // `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
240
+ // singleton slot rendered once, e.g. `<slots.Create/>`).
241
+ const slotFor = (slotName: string): ((slotProps?: { readonly slotKey?: string }) => ReactNode) => {
245
242
  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
- })
243
+ if (cached !== undefined) return cached
244
+ const stable = (slotProps?: { readonly slotKey?: string }): ReactNode => {
245
+ const { node: currentNode, tree: currentTree, config: currentConfig } = latest.current
246
+ const requestedKey = slotProps?.slotKey
247
+ return createElement(
248
+ Fragment,
249
+ null,
250
+ ...Wire.childrenOf(currentTree, currentNode.id)
251
+ .filter((child) => child.slot === slotName)
252
+ .filter((child) => requestedKey === undefined || child.key === requestedKey)
253
+ .map((child) =>
254
+ createElement(WireNodeView, {
255
+ key: child.id,
256
+ node: child,
257
+ tree: currentTree,
258
+ config: currentConfig,
259
+ }),
260
+ ),
261
+ )
262
+ }
267
263
  slotCache.current[slotName] = stable
268
- slots[slotName] = stable
264
+ return stable
269
265
  }
270
266
 
267
+ // Resolve slots LAZILY by name: a view referencing `<slots.Item/>` when that slot has no
268
+ // wire children this frame (e.g. an empty list) gets a component that renders nothing,
269
+ // rather than `undefined` (which React rejects as an invalid element type). Mirrors the
270
+ // engine's total slot proxies — every declared slot is always callable.
271
+ const slots: Record<string, (slotProps?: { readonly slotKey?: string }) => ReactNode> = new Proxy(
272
+ Object.create(null),
273
+ { get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined) },
274
+ )
275
+
271
276
  return registered.view(props, slots, events)
272
277
  }
273
278
 
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,
package/src/server.ts CHANGED
@@ -34,6 +34,11 @@ import {
34
34
  * evaluates a view. The render walk is breadth-first over the structure tree,
35
35
  * assigning stable ids and encoding props via each contract's own wire schema
36
36
  * (`WiredUiManifest`, Phase 1).
37
+ *
38
+ * The render walk (`renderSceneToWire`) is a free-standing effect that requires only
39
+ * `CompositionService | SlotChild` — so any runtime that supplies a scene's services
40
+ * can render a frame, not just `makeRemoteServer`'s own. `@playfast/reform-driver-shot`
41
+ * runs it against `@playfast/reform-drive`'s runtime to screenshot a driven state.
37
42
  */
38
43
 
39
44
  // The runtime services a closed scene exposes (mirrors proof's erasure boundary:
@@ -77,188 +82,181 @@ const childComposition = (child: SlotChild): CompositionClass<unknown> =>
77
82
  const handlesOf = (node: WireNode): ReadonlyArray<string> =>
78
83
  node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
79
84
 
80
- export interface RemoteServer {
81
- /** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
82
- readonly render: () => Promise<WireTree>
83
- /**
84
- * Render and return only the patches since the previous frame (the streaming
85
- * form). Handles of deleted nodes are revoked, so a stale client invocation
86
- * fails cleanly rather than firing a dangling trigger.
87
- */
88
- readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
89
- /** Fire a trigger the client referenced by handle, then settle the runtime. */
90
- readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>
91
- /**
92
- * Observe SERVER-INITIATED state changes. The listener fires (after the drain
93
- * settles) whenever an event flows on the engine bus — i.e. when an async procedure
94
- * resolves, a boot loader completes, or a scheduler ticks — NOT just in response to a
95
- * client invoke. `serve` registers a `renderDiff`-and-push listener here so the client
96
- * sees background updates (a repo list that finishes loading, a reconcile sweep) that
97
- * no user interaction triggered. Returns an unsubscribe handle.
98
- */
99
- readonly subscribe: (listener: () => void) => () => void
100
- /** Tear down the runtime and its forked fibers. */
101
- readonly dispose: () => Promise<void>
102
- }
85
+ // A static render (a screenshot) has no client to receive trigger handles, so the
86
+ // default registrar is a no-op: the wire node still carries its `Event` props (with
87
+ // deterministic handles), they're just never registered for invocation.
88
+ const noRegister: TriggerRegistryApi['register'] = () => Effect.void
103
89
 
104
- export const makeRemoteServer = (scene: Scene): RemoteServer => {
105
- const runtime = ManagedRuntime.make(closedSceneLayer(scene))
106
- const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
90
+ // Structure wire encoding (plan 02 + 03b). Props come straight from the
91
+ // `Structure` value (data, never a view render). Event triggers ride ON the
92
+ // structure (`structure.events`) and are registered through `register` (the live
93
+ // registry for a streaming server, a no-op for a one-shot render). Produces the
94
+ // wire shape the transport and `Wire.diff` consume.
95
+ const toWireNodeFromStructure = (
96
+ m: Mounted,
97
+ structure: Structure<UiContract>,
98
+ register: TriggerRegistryApi['register'],
99
+ ): Effect.Effect<WireNode, ParseResult.ParseError> =>
100
+ Effect.gen(function* () {
101
+ const manifest = m.comp.manifest.ui.manifest as UiManifest
102
+ const name = manifest.name
107
103
 
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
- }
104
+ const dataProps: WireProp[] = []
105
+ if (manifest.props !== undefined) {
106
+ const encoded = yield* Schema.encodeUnknown(manifest.props)(structure.props)
107
+ for (const [propName, value] of Object.entries(encoded as Record<string, unknown>)) {
108
+ dataProps.push({ _tag: 'Data', name: propName, value })
144
109
  }
110
+ }
145
111
 
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
- }
112
+ const eventSchemas = manifest.events ?? {}
113
+ const eventProps: WireProp[] = []
114
+ const events = eventsOf(structure)
115
+ for (const [eventName, trigger] of Object.entries(events)) {
116
+ const schema = eventSchemas[eventName]
117
+ if (schema === undefined) continue
118
+ const handle = `${m.id}:${eventName}`
119
+ yield* register(handle, trigger, schema)
120
+ eventProps.push({ _tag: 'Event', name: eventName, handle })
121
+ }
156
122
 
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
- })
123
+ return {
124
+ id: m.id,
125
+ name,
126
+ parentId: m.parentId,
127
+ childIndex: m.childIndex,
128
+ slot: m.slot,
129
+ key: m.key,
130
+ props: [...dataProps, ...eventProps],
131
+ }
132
+ })
167
133
 
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')
134
+ // Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
135
+ // UiContract>` erases its per-slot fill types at this boundary (the strict typing
136
+ // lives on the concrete contract), so the runtime fills arrive as `unknown` and
137
+ // are recovered structurally — no `as`.
138
+ const isSlotFill = (u: unknown): u is SlotFill<unknown> =>
139
+ typeof u === 'object' &&
140
+ u !== null &&
141
+ '_tag' in u &&
142
+ (u._tag === 'Each' || u._tag === 'One' || u._tag === 'Absent')
177
143
 
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
- )
144
+ // Read a structure's fills by slot name, recovering each erased value via the
145
+ // discriminant guard. Returns a plain record keyed by declared slot name.
146
+ const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
147
+ Object.fromEntries(
148
+ Object.entries(structure.slots).flatMap(([name, value]) =>
149
+ isSlotFill(value) ? [[name, value] as const] : [],
150
+ ),
151
+ )
186
152
 
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) => {
153
+ // Enqueue the children a single declared slot is filled with, reading the fill
154
+ // (Each / One / Absent) from the returned `Structure`. Multiplicity, per-item
155
+ // `key`, and props are carried as data — no view walk, no reconstruction.
156
+ const enqueueStructureSlot = (
157
+ parent: Mounted,
158
+ slotName: string,
159
+ child: CompositionClass<unknown>,
160
+ fill: SlotFill<unknown>,
161
+ next: Mounted[],
162
+ ): void =>
163
+ Match.value(fill).pipe(
164
+ Match.tag('Each', (each) => {
165
+ each.items.forEach((item, index) => {
212
166
  next.push({
213
167
  comp: child,
214
- props: single.props,
215
- id: `${parent.id}.${slotName}.0`,
168
+ props: item.props,
169
+ id: `${parent.id}.${slotName}.${index}`,
216
170
  parentId: parent.id,
217
171
  slot: slotName,
218
- childIndex: 0,
219
- key: null,
172
+ childIndex: index,
173
+ key: item.key,
220
174
  })
221
- }),
222
- Match.tag('Absent', () => {}),
223
- Match.exhaustive,
224
- )
175
+ })
176
+ }),
177
+ Match.tag('One', (single) => {
178
+ next.push({
179
+ comp: child,
180
+ props: single.props,
181
+ id: `${parent.id}.${slotName}.0`,
182
+ parentId: parent.id,
183
+ slot: slotName,
184
+ childIndex: 0,
185
+ key: null,
186
+ })
187
+ }),
188
+ Match.tag('Absent', () => {}),
189
+ Match.exhaustive,
190
+ )
225
191
 
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)
192
+ const collect = (
193
+ root: CompositionClass<unknown>,
194
+ ): Effect.Effect<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> =>
195
+ Effect.gen(function* () {
196
+ const bindings = new Map<SlotClass, CompositionClass<unknown>>()
197
+ const walk = (comp: CompositionClass<unknown>): Effect.Effect<void, never, SlotChild> =>
198
+ Effect.gen(function* () {
199
+ for (const slotClass of Object.values(comp.manifest.slots ?? {})) {
200
+ if (bindings.has(slotClass)) continue
201
+ const child = childComposition(yield* slotClass.tag)
202
+ bindings.set(slotClass, child)
203
+ yield* walk(child)
254
204
  }
205
+ })
206
+ yield* walk(root)
207
+ return bindings
208
+ })
209
+
210
+ const renderLevel = (
211
+ frontier: ReadonlyArray<Mounted>,
212
+ bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
213
+ register: TriggerRegistryApi['register'],
214
+ ): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> =>
215
+ Effect.gen(function* () {
216
+ if (frontier.length === 0) return []
217
+ const next: Mounted[] = []
218
+ const nodes: WireNode[] = []
219
+ for (const mounted of frontier) {
220
+ const service = yield* mounted.comp.tag
221
+ const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
222
+ const frame = yield* Composition.render(service, env)
223
+ // Every composition returns a `Structure` (the Node render path is gone): its
224
+ // wire node's props come from `frame.props`, event triggers from `frame.events`,
225
+ // and children are enqueued by reading each declared slot's fill — no view eval.
226
+ if (!isStructure(frame)) {
227
+ return yield* Effect.dieMessage(
228
+ `reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
229
+ )
255
230
  }
256
- const rest = yield* renderLevel(next, bindings)
257
- return [...nodes, ...rest]
258
- })
231
+ nodes.push(yield* toWireNodeFromStructure(mounted, frame, register))
232
+ const fills = structureFills(frame)
233
+ for (const [slotName, slotClass] of Object.entries(mounted.comp.manifest.slots ?? {})) {
234
+ const child = bindings.get(slotClass)
235
+ if (child === undefined) continue
236
+ const fill = fills[slotName]
237
+ if (fill === undefined) continue
238
+ enqueueStructureSlot(mounted, slotName, child, fill, next)
239
+ }
240
+ }
241
+ const rest = yield* renderLevel(next, bindings, register)
242
+ return [...nodes, ...rest]
243
+ })
259
244
 
260
- const renderEffect = Effect.gen(function* () {
261
- yield* settleDrain
245
+ /**
246
+ * Render the scene's CURRENT frame to a serializable `WireTree`, breadth-first from
247
+ * the root composition. Requires only the scene's render services
248
+ * (`CompositionService | SlotChild`), so it runs on any runtime built from the same
249
+ * closed scene — `makeRemoteServer`'s, or `@playfast/reform-drive`'s (the screenshot
250
+ * path). `register` defaults to a no-op: a one-shot render needs no live trigger
251
+ * registry, while `makeRemoteServer` passes its connection registry so the streamed
252
+ * client can invoke handles.
253
+ */
254
+ export const renderSceneToWire = (
255
+ scene: Scene,
256
+ options?: { readonly register?: TriggerRegistryApi['register'] },
257
+ ): Effect.Effect<WireTree, ParseResult.ParseError, CompositionService | SlotChild> =>
258
+ Effect.gen(function* () {
259
+ const register = options?.register ?? noRegister
262
260
  const bindings = yield* collect(scene.composition)
263
261
  const root: Mounted = {
264
262
  comp: scene.composition,
@@ -269,9 +267,43 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
269
267
  childIndex: 0,
270
268
  key: null,
271
269
  }
272
- return yield* renderLevel([root], bindings)
270
+ return yield* renderLevel([root], bindings, register)
273
271
  })
274
272
 
273
+ export interface RemoteServer {
274
+ /** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
275
+ readonly render: () => Promise<WireTree>
276
+ /**
277
+ * Render and return only the patches since the previous frame (the streaming
278
+ * form). Handles of deleted nodes are revoked, so a stale client invocation
279
+ * fails cleanly rather than firing a dangling trigger.
280
+ */
281
+ readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
282
+ /** Fire a trigger the client referenced by handle, then settle the runtime. */
283
+ readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>
284
+ /**
285
+ * Observe SERVER-INITIATED state changes. The listener fires (after the drain
286
+ * settles) whenever an event flows on the engine bus — i.e. when an async procedure
287
+ * resolves, a boot loader completes, or a scheduler ticks — NOT just in response to a
288
+ * client invoke. `serve` registers a `renderDiff`-and-push listener here so the client
289
+ * sees background updates (a repo list that finishes loading, a reconcile sweep) that
290
+ * no user interaction triggered. Returns an unsubscribe handle.
291
+ */
292
+ readonly subscribe: (listener: () => void) => () => void
293
+ /** Tear down the runtime and its forked fibers. */
294
+ readonly dispose: () => Promise<void>
295
+ }
296
+
297
+ export const makeRemoteServer = (scene: Scene): RemoteServer => {
298
+ const runtime = ManagedRuntime.make(closedSceneLayer(scene))
299
+ const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
300
+
301
+ // The shared render walk, registering triggers in this connection's registry so the
302
+ // client can invoke them. `settleDrain` first so an in-flight batch folds into state.
303
+ const renderEffect = settleDrain.pipe(
304
+ Effect.zipRight(renderSceneToWire(scene, { register: registry.register })),
305
+ )
306
+
275
307
  // Server-initiated change notification: a forked daemon subscribes to the engine bus
276
308
  // and, after each batch settles, fans out to registered listeners. This is what lets a
277
309
  // streaming binding push frames the client never asked for — a background load resolving