@playfast/reform-remote 1.0.1 → 1.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/src/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  Array as Arr,
3
+ Context,
3
4
  Effect,
4
5
  Layer,
5
6
  Match,
@@ -12,102 +13,71 @@ import {
12
13
  Schema,
13
14
  } from 'effect'
14
15
  import {
15
- Bus,
16
+ type CapturedScene,
16
17
  Composition,
17
18
  type CompositionClass,
18
- type CompositionService,
19
19
  forceSync,
20
20
  isFeatureBinding,
21
+ isSlot,
21
22
  isStructure,
22
23
  publish,
23
- type RenderEnv,
24
- type Scene,
25
- type SlotChild,
26
- type SlotClass,
27
24
  type SlotFill,
28
25
  type Structure,
29
- Triggers,
30
26
  type Trigger,
31
- type TriggerRegistryApi,
32
27
  type UiContract,
28
+ } from '@playfast/reform'
29
+ import {
30
+ type AnyComposition,
31
+ type AnyScene,
32
+ type AnySlot,
33
+ type AnySlotChild,
34
+ Bus,
35
+ Triggers,
36
+ type TriggerRegistryApi,
33
37
  type UiManifest,
34
38
  Wire,
35
39
  type WireNode,
36
40
  type WirePatch,
37
41
  type WireProp,
38
42
  type WireTree,
39
- } from '@playfast/reform'
40
-
41
- /**
42
- * The server side of the remote transport (REMOTE_UI.md §5): render a closed
43
- * `Scene` to a serializable `WireTree`, registering each trigger behind a handle.
44
- * Reads each composition's returned `Structure` value directly — props, per-slot
45
- * fills, and event triggers are all DATA — so the server imports no React and never
46
- * evaluates a view. The render walk is breadth-first over the structure tree,
47
- * assigning stable ids and encoding props via each contract's own wire schema
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.
54
- */
55
-
56
- // The runtime services a closed scene exposes (mirrors proof's erasure boundary:
57
- // the scene's `provide` is typed to `MountedServices`, but slot tags resolve
58
- // `SlotChild` from the same closed layer).
59
- type RuntimeServices = CompositionService | SlotChild | Bus
43
+ } from '@playfast/reform/internal'
60
44
 
61
45
  const SETTLE_DRAIN = 30
62
46
  const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
63
47
 
64
- // The event triggers a `Structure` frame carries (plan 03b). A composition's logic
65
- // returns its acquired triggers ON the structure value (it never evaluates a view),
66
- // so the server reads them straight from there. At the erased `UiContract` boundary the contract's event map
67
- // is `Record<never, never>`, so each value is `never` — assignable to
68
- // `Trigger<unknown>` without a cast — and an omitted `events` defaults to empty.
48
+ // The erased event map's never values remain assignable to Trigger<unknown> without a cast.
69
49
  const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
70
50
  Option.match(Option.fromNullable(structure.events), {
71
51
  onNone: () => ({}),
72
52
  onSome: (events) => events,
73
53
  })
74
54
 
75
- const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
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<
78
- RuntimeServices,
79
- never,
80
- never
81
- >
82
-
83
- /** A composition queued to render, with the props its parent handed it and its place in the tree. */
84
55
  interface Mounted {
85
- readonly comp: CompositionClass<unknown>
56
+ readonly comp: AnyComposition
86
57
  readonly props: unknown
87
58
  readonly id: string
88
59
  readonly parentId: Option.Option<string>
89
60
  readonly slot: Option.Option<string>
90
61
  readonly childIndex: number
91
- /** The React `key` the parent gave this slot child, if any — the keyed-slot selector. */
92
62
  readonly key: Option.Option<string>
93
63
  }
94
64
 
95
- const childComposition = (child: SlotChild): CompositionClass<unknown> =>
96
- isFeatureBinding(child) ? child.composition : child
65
+ const childComposition = (child: AnySlotChild): AnyComposition => {
66
+ if (!isFeatureBinding(child)) {
67
+ return child
68
+ }
69
+ return child.capture<AnyComposition>((binding): AnyComposition => binding.composition)
70
+ }
71
+
72
+ const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
73
+ typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)
97
74
 
98
75
  const handlesOf = (node: WireNode): ReadonlyArray<string> =>
99
76
  node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
100
77
 
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.
78
+ // One-shot renders still emit deterministic handles but have no client registry.
104
79
  const noRegister: TriggerRegistryApi['register'] = () => Effect.void
105
80
 
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
81
  const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
112
82
  mounted: Mounted,
113
83
  structure: Structure<UiContract>,
@@ -116,28 +86,47 @@ const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
116
86
  const manifest: UiManifest = mounted.comp.manifest.ui.manifest
117
87
  const name = manifest.name
118
88
 
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.
89
+ // The manifest stores schema AST only; reconstruct an unknown-safe schema at this wire seam.
121
90
  const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
122
91
  onNone: () => Effect.succeed({}),
123
- onSome: (schema) => Schema.encodeUnknown(schema)(structure.props),
92
+ onSome: (reflection) =>
93
+ Schema.encodeUnknown(Schema.make<unknown, unknown>(reflection.ast))(structure.props).pipe(
94
+ Effect.flatMap((encoded) => {
95
+ if (isRecord(encoded)) {
96
+ return Effect.succeed(encoded)
97
+ }
98
+ return Effect.dieMessage(
99
+ `reform-remote: ${manifest.name} props did not encode to a record`,
100
+ )
101
+ }),
102
+ ),
124
103
  })
125
104
  const dataProps: ReadonlyArray<WireProp> = Rec.toEntries(encodedProps).map(
126
- ([propName, propValue]): WireProp => ({ _tag: 'Data', name: propName, value: propValue }),
105
+ ([propName, propValue]): WireProp => ({
106
+ _tag: 'Data',
107
+ name: propName,
108
+ value: propValue,
109
+ }),
127
110
  )
128
111
 
129
112
  const eventSchemas = Option.fromNullable(manifest.events)
130
113
  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
- }),
114
+ const eventProps: ReadonlyArray<WireProp> = yield* Effect.forEach(
115
+ Rec.toEntries(events),
116
+ ([eventName, trigger]) =>
117
+ Effect.gen(function* () {
118
+ const schema = Option.flatMap(eventSchemas, (schemas) => Rec.get(schemas, eventName))
119
+ if (Option.isNone(schema)) {
120
+ return Option.none<WireProp>()
121
+ }
122
+ const handle = `${mounted.id}:${eventName}`
123
+ yield* register(handle, trigger, Schema.make<unknown, unknown>(schema.value.ast))
124
+ return Option.some<WireProp>({
125
+ _tag: 'Event',
126
+ name: eventName,
127
+ handle,
128
+ })
129
+ }),
141
130
  ).pipe(Effect.map(Arr.getSomes))
142
131
 
143
132
  return {
@@ -151,36 +140,102 @@ const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
151
140
  }
152
141
  })
153
142
 
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`.
143
+ // Structure erases per-slot fill types; recover SlotFill structurally without `as`.
158
144
  const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
159
145
  typeof candidate === 'object' &&
160
146
  candidate !== null &&
161
147
  '_tag' in candidate &&
162
148
  (candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
163
149
 
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
150
  const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
167
151
  Rec.fromEntries(
168
- Rec.toEntries(structure.slots).flatMap(([slotName, fillValue]) =>
169
- isSlotFill(fillValue) ? [[slotName, fillValue] as const] : [],
152
+ Rec.toEntries(structure.slots).flatMap(
153
+ ([slotName, fillValue]): ReadonlyArray<readonly [string, SlotFill<unknown>]> =>
154
+ isSlotFill(fillValue) ? [[slotName, fillValue]] : [],
155
+ ),
156
+ )
157
+
158
+ const slotsOf = (comp: AnyComposition): Record<string, AnySlot> =>
159
+ comp.capture<Record<string, AnySlot>>((exact) => {
160
+ if (!('slots' in exact.manifest)) {
161
+ return {}
162
+ }
163
+ const candidate: unknown = exact.manifest.slots
164
+ if (!isRecord(candidate)) {
165
+ return {}
166
+ }
167
+ return Rec.fromEntries(
168
+ Rec.toEntries(candidate).flatMap(
169
+ ([name, slotDefinition]): ReadonlyArray<readonly [string, AnySlot]> =>
170
+ isSlot(slotDefinition) ? [[name, slotDefinition]] : [],
171
+ ),
172
+ )
173
+ })
174
+
175
+ const serviceFromContext = <Services, Identifier, Service>(
176
+ context: Context.Context<Services>,
177
+ tag: Context.Tag<Identifier, Service>,
178
+ missing: string,
179
+ ): Effect.Effect<Service, never, never> =>
180
+ Option.match(Context.getOption(context, tag), {
181
+ onNone: () => Effect.dieMessage(missing),
182
+ onSome: Effect.succeed,
183
+ })
184
+
185
+ const capturedSlotProvider = <Services>(
186
+ context: Context.Context<Services>,
187
+ slotDefinition: AnySlot,
188
+ ): Effect.Effect<AnySlotChild, never, never> =>
189
+ slotDefinition.capture((exact) =>
190
+ serviceFromContext(
191
+ context,
192
+ exact.provider,
193
+ 'reform-remote: scene does not provide a declared slot',
170
194
  ),
171
195
  )
172
196
 
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), () => ({}))
197
+ const renderCapturedComposition = <Services>(
198
+ context: Context.Context<Services>,
199
+ composition: AnyComposition,
200
+ props: unknown,
201
+ ): Effect.Effect<Structure<UiContract>, never, never> =>
202
+ composition.capture<Effect.Effect<Structure<UiContract>, never, never>>(
203
+ <P, C extends UiContract, S extends ReadonlyArray<unknown>, N extends string, Identity>(
204
+ exact: CompositionClass<P, C, S, N, Identity>,
205
+ ): Effect.Effect<Structure<UiContract>, never, never> =>
206
+ Option.match(exact.parseProps(props), {
207
+ onNone: () =>
208
+ Effect.dieMessage(
209
+ `reform-remote: invalid props for composition ${composition.manifest.name}`,
210
+ ),
211
+ onSome: (parsed) =>
212
+ serviceFromContext(
213
+ context,
214
+ exact.tag,
215
+ `reform-remote: scene does not provide composition ${composition.manifest.name}`,
216
+ ).pipe(
217
+ Effect.flatMap((service) =>
218
+ Composition.render<P, C>(service, {
219
+ props: parsed,
220
+ tracker: { add: () => {} },
221
+ }),
222
+ ),
223
+ Effect.flatMap((frame) => {
224
+ if (isStructure(frame)) {
225
+ return Effect.succeed(frame)
226
+ }
227
+ return Effect.dieMessage(
228
+ `reform-remote: composition ${composition.manifest.name} did not return a Structure`,
229
+ )
230
+ }),
231
+ ),
232
+ }),
233
+ )
179
234
 
180
235
  const enqueueStructureSlot = (
181
236
  parent: Mounted,
182
237
  slotName: string,
183
- child: CompositionClass<unknown>,
238
+ child: AnyComposition,
184
239
  fill: SlotFill<unknown>,
185
240
  ): ReadonlyArray<Mounted> =>
186
241
  Match.value(fill).pipe(
@@ -212,58 +267,60 @@ const enqueueStructureSlot = (
212
267
  Match.exhaustive,
213
268
  )
214
269
 
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
- })
270
+ type SlotProvider<Services> = (
271
+ slotDefinition: AnySlot,
272
+ ) => Effect.Effect<AnySlotChild, never, Services>
273
+
274
+ const collect = Effect.fn('collect')(function* <Services>(
275
+ root: AnyComposition,
276
+ provideSlot: SlotProvider<Services>,
277
+ ): Effect.fn.Return<Map<AnySlot, AnyComposition>, never, Services> {
278
+ const bindings = new Map<AnySlot, AnyComposition>()
279
+ // Explicit type keeps the recursive reference cast-free.
280
+ const walk: (comp: AnyComposition) => Effect.Effect<void, never, Services> = Effect.fn('walk')(
281
+ function* (comp: AnyComposition): Effect.fn.Return<void, never, Services> {
282
+ yield* Effect.forEach(Rec.values(slotsOf(comp)), (slotClass) =>
283
+ Effect.gen(function* () {
284
+ if (bindings.has(slotClass)) {
285
+ return
286
+ }
287
+ const child = childComposition(yield* provideSlot(slotClass))
288
+ bindings.set(slotClass, child)
289
+ yield* walk(child)
290
+ }),
291
+ )
292
+ },
293
+ )
234
294
  yield* walk(root)
235
295
  return bindings
236
296
  })
237
297
 
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* (
298
+ type CompositionRenderer<Services> = (
299
+ composition: AnyComposition,
300
+ props: unknown,
301
+ ) => Effect.Effect<Structure<UiContract>, never, Services>
302
+
303
+ interface RenderLevel {
304
+ <Services>(
305
+ frontier: ReadonlyArray<Mounted>,
306
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
307
+ register: TriggerRegistryApi['register'],
308
+ render: CompositionRenderer<Services>,
309
+ ): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, Services>
310
+ }
311
+
312
+ const renderLevel: RenderLevel = Effect.fn('renderLevel')(function* <Services>(
247
313
  frontier: ReadonlyArray<Mounted>,
248
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
314
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
249
315
  register: TriggerRegistryApi['register'],
250
- ): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> {
316
+ render: CompositionRenderer<Services>,
317
+ ): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, Services> {
251
318
  if (frontier.length === 0) {
252
319
  return []
253
320
  }
254
321
  const rendered = yield* Effect.forEach(frontier, (mounted) =>
255
322
  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
- }
323
+ const frame = yield* render(mounted.comp, mounted.props)
267
324
  const node = yield* toWireNodeFromStructure(mounted, frame, register)
268
325
  const fills = structureFills(frame)
269
326
  const children = Rec.toEntries(slotsOf(mounted.comp)).flatMap(([slotName, slotClass]) => {
@@ -282,90 +339,96 @@ const renderLevel: (
282
339
  )
283
340
  const nodes = rendered.map((entry) => entry.node)
284
341
  const next = rendered.flatMap((entry) => entry.children)
285
- const rest = yield* renderLevel(next, bindings, register)
342
+ const rest = yield* renderLevel(next, bindings, register, render)
286
343
  return [...nodes, ...rest]
287
344
  })
288
345
 
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. */
346
+ const renderSceneWith = Effect.fn('renderSceneWith')(function* <SlotServices, CompositionServices>(
347
+ scene: AnyScene,
348
+ options: RenderSceneToWireOptions | undefined,
349
+ provideSlot: SlotProvider<SlotServices>,
350
+ render: CompositionRenderer<CompositionServices>,
351
+ ): Effect.fn.Return<WireTree, ParseResult.ParseError, SlotServices | CompositionServices> {
352
+ const register = options?.register ?? noRegister
353
+ const bindings = yield* collect(scene.composition, provideSlot)
354
+ const root: Mounted = {
355
+ comp: scene.composition,
356
+ props: {},
357
+ id: '0',
358
+ parentId: Option.none(),
359
+ slot: Option.none(),
360
+ childIndex: 0,
361
+ key: Option.none(),
362
+ }
363
+ return yield* renderLevel([root], bindings, register, render)
364
+ })
299
365
  export interface RenderSceneToWireOptions {
300
366
  readonly register: TriggerRegistryApi['register']
301
367
  }
302
368
 
303
369
  // 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,
370
+ export const renderSceneToWire = <
371
+ C extends UiContract,
372
+ S extends ReadonlyArray<unknown>,
373
+ Services,
374
+ P,
375
+ N extends string,
376
+ Identity,
377
+ >(
378
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
306
379
  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
- })
380
+ ): Effect.Effect<WireTree, ParseResult.ParseError, Services> =>
381
+ Effect.context<Services>().pipe(
382
+ Effect.flatMap((context) =>
383
+ renderSceneWith(
384
+ scene,
385
+ options,
386
+ (slotDefinition) => capturedSlotProvider(context, slotDefinition),
387
+ (composition, props) => renderCapturedComposition(context, composition, props),
388
+ ),
389
+ ),
390
+ )
322
391
 
323
392
  export interface RemoteServer {
324
- /** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
325
393
  readonly render: () => Promise<WireTree>
326
- /**
327
- * Render and return only the patches since the previous frame (the streaming
328
- * form). Handles of deleted nodes are revoked, so a stale client invocation
329
- * fails cleanly rather than firing a dangling trigger.
330
- */
331
394
  readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
332
- /** Fire a trigger the client referenced by handle, then settle the runtime. */
333
395
  readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>
334
- /**
335
- * Observe SERVER-INITIATED state changes. The listener fires (after the drain
336
- * settles) whenever an event flows on the engine bus — i.e. when an async procedure
337
- * resolves, a boot loader completes, or a scheduler ticks — NOT just in response to a
338
- * client invoke. `serve` registers a `renderDiff`-and-push listener here so the client
339
- * sees background updates (a repo list that finishes loading, a reconcile sweep) that
340
- * no user interaction triggered. Returns an unsubscribe handle.
341
- */
342
396
  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
397
  readonly currentTree: () => WireTree
349
- /** Tear down the runtime and its forked fibers. */
350
398
  readonly dispose: () => Promise<void>
351
399
  }
352
400
 
353
- export const makeRemoteServer = (scene: Scene): RemoteServer => {
354
- const runtime = ManagedRuntime.make(closedSceneLayer(scene))
401
+ const makeCapturedRemoteServer = <
402
+ C extends UiContract,
403
+ S extends ReadonlyArray<unknown>,
404
+ Services,
405
+ P,
406
+ N extends string,
407
+ Identity,
408
+ >(
409
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
410
+ ): RemoteServer => {
411
+ const layer = scene.provide.reduce((acc, provided) => Layer.merge(acc, provided))
412
+ const runtime = ManagedRuntime.make(layer)
355
413
  const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
414
+ const context = forceSync(() => runtime.runSync(Effect.context<Services>()))
415
+ const busFromContext = serviceFromContext(
416
+ context,
417
+ Bus,
418
+ 'reform-remote: scene does not provide Bus',
419
+ )
356
420
 
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
421
  const renderEffect = settleDrain.pipe(
360
- Effect.zipRight(renderSceneToWire(scene, { register: registry.register })),
422
+ Effect.zipRight(
423
+ renderSceneWith(
424
+ scene,
425
+ { register: registry.register },
426
+ (slotDefinition) => capturedSlotProvider(context, slotDefinition),
427
+ (composition, props) => renderCapturedComposition(context, composition, props),
428
+ ),
429
+ ),
361
430
  )
362
431
 
363
- // Server-initiated change notification: a forked daemon subscribes to the engine bus
364
- // and, after each batch settles, fans out to registered listeners. This is what lets a
365
- // streaming binding push frames the client never asked for — a background load resolving
366
- // long after connect. Forked BEFORE the boot publish below so nothing is missed (the
367
- // listener set is empty until `subscribe` runs, so early ticks are harmless no-ops; the
368
- // binding's opening snapshot captures all state up to `start`).
369
432
  const changeListeners = new Set<() => void>()
370
433
  const notifyChange = (): void => {
371
434
  changeListeners.forEach((listener) => listener())
@@ -373,11 +436,9 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
373
436
  runtime.runFork(
374
437
  Effect.scoped(
375
438
  Effect.gen(function* () {
376
- const bus = yield* Bus
439
+ const bus = yield* busFromContext
377
440
  const subscription = yield* PubSub.subscribe(bus)
378
- // Notify on each event; the consumer (`serve`) debounces, so the render it triggers
379
- // runs a tick later — after the drain has folded this event into state. No settle
380
- // here: it would only perturb the drain's own scheduling for no benefit.
441
+ // The transport debounces; settling here would perturb the drain's scheduling.
381
442
  yield* Queue.take(subscription).pipe(
382
443
  Effect.zipRight(Effect.sync(notifyChange)),
383
444
  Effect.forever,
@@ -389,12 +450,13 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
389
450
  runtime.runSync(
390
451
  Effect.forEach(
391
452
  Option.getOrElse(Option.fromNullable(scene.boot), () => []),
392
- (event) => publish('High', event),
453
+ (event) =>
454
+ busFromContext.pipe(
455
+ Effect.flatMap((bus) => publish('High', event).pipe(Effect.provideService(Bus, bus))),
456
+ ),
393
457
  ),
394
458
  )
395
459
 
396
- // The last emitted frame, to diff against. A const holder whose field we swap
397
- // (no reassigned binding — house rule), not a `let`.
398
460
  const frame: { tree: WireTree } = { tree: [] }
399
461
 
400
462
  const render = (): Promise<WireTree> =>
@@ -416,9 +478,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
416
478
  const patches = Wire.diff(previous, next)
417
479
  frame.tree = next
418
480
  const nextIds = new Set(next.map((node) => node.id))
419
- const staleHandles = previous
420
- .filter((node) => !nextIds.has(node.id))
421
- .flatMap(handlesOf)
481
+ const staleHandles = previous.filter((node) => !nextIds.has(node.id)).flatMap(handlesOf)
422
482
  yield* Effect.forEach(staleHandles, (handle) => registry.revoke(handle))
423
483
  return patches
424
484
  }),
@@ -442,3 +502,17 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
442
502
  dispose: () => runtime.dispose(),
443
503
  }
444
504
  }
505
+
506
+ export const makeRemoteServer = (scene: AnyScene): RemoteServer =>
507
+ scene.captureAny<RemoteServer>(
508
+ <
509
+ C extends UiContract,
510
+ S extends ReadonlyArray<unknown>,
511
+ Services,
512
+ P,
513
+ N extends string,
514
+ Identity,
515
+ >(
516
+ exactScene: CapturedScene<C, S, Services, P, N, Identity>,
517
+ ) => makeCapturedRemoteServer(exactScene),
518
+ )