@playfast/reform-remote 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ import { Effect, Layer, ManagedRuntime, Option, PubSub, Queue } from 'effect'
2
+ import {
3
+ Bus,
4
+ type CompositionService,
5
+ forceSync,
6
+ publish,
7
+ type Scene,
8
+ type SlotChild,
9
+ Triggers,
10
+ type TriggerRegistryApi,
11
+ Wire,
12
+ type WirePatch,
13
+ type WireTree,
14
+ type WireNode,
15
+ } from '@playfast/reform'
16
+ import { renderSceneToWire } from './server'
17
+
18
+ type RuntimeServices = CompositionService | SlotChild | Bus
19
+ const SETTLE_DRAIN = 30
20
+ const settleDrain: Effect.Effect<void> = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
21
+ const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
22
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- closed-scene erasure: scene.provide is typed to MountedServices, recovered structurally as RuntimeServices
23
+ scene.provide.reduce((acc, layer) => Layer.merge(acc, layer)) as unknown as Layer.Layer<
24
+ RuntimeServices,
25
+ never,
26
+ never
27
+ >
28
+ const handlesOf = (node: WireNode): ReadonlyArray<string> =>
29
+ node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
30
+
31
+ export interface RemoteServer {
32
+ readonly render: () => Promise<WireTree>
33
+ readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
34
+ readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>
35
+ readonly subscribe: (listener: () => void) => () => void
36
+ readonly currentTree: () => WireTree
37
+ readonly dispose: () => Promise<void>
38
+ }
39
+
40
+ export const makeRemoteServer = (scene: Scene): RemoteServer => {
41
+ const runtime = ManagedRuntime.make(closedSceneLayer(scene))
42
+ const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
43
+ const renderEffect = settleDrain.pipe(
44
+ Effect.zipRight(renderSceneToWire(scene, { register: registry.register })),
45
+ )
46
+ const changeListeners = new Set<() => void>()
47
+ const notifyChange = (): void => {
48
+ changeListeners.forEach((listener) => listener())
49
+ }
50
+ runtime.runFork(
51
+ Effect.scoped(
52
+ Effect.gen(function* () {
53
+ const bus = yield* Bus
54
+ const subscription = yield* PubSub.subscribe(bus)
55
+ yield* Queue.take(subscription).pipe(
56
+ Effect.zipRight(Effect.sync(notifyChange)),
57
+ Effect.forever,
58
+ )
59
+ }),
60
+ ),
61
+ )
62
+ runtime.runSync(
63
+ Effect.forEach(
64
+ Option.getOrElse(Option.fromNullable(scene.boot), () => []),
65
+ (event) => publish('High', event),
66
+ ),
67
+ )
68
+ const frame: { tree: WireTree } = { tree: [] }
69
+ const render = (): Promise<WireTree> =>
70
+ runtime.runPromise(
71
+ renderEffect.pipe(
72
+ Effect.tap((tree) =>
73
+ Effect.sync(() => {
74
+ frame.tree = tree
75
+ }),
76
+ ),
77
+ ),
78
+ )
79
+ const renderDiff = (): Promise<ReadonlyArray<WirePatch>> =>
80
+ runtime.runPromise(
81
+ Effect.gen(function* () {
82
+ const next = yield* renderEffect
83
+ const previous = frame.tree
84
+ const patches = Wire.diff(previous, next)
85
+ frame.tree = next
86
+ const nextIds = new Set(next.map((node) => node.id))
87
+ const staleHandles = previous.filter((node) => !nextIds.has(node.id)).flatMap(handlesOf)
88
+ yield* Effect.forEach(staleHandles, (handle) => registry.revoke(handle))
89
+ return patches
90
+ }),
91
+ )
92
+ return {
93
+ render,
94
+ renderDiff,
95
+ invoke: (handle, encodedPayload) =>
96
+ runtime.runPromise(
97
+ Effect.gen(function* () {
98
+ yield* registry.invoke(handle, encodedPayload)
99
+ yield* settleDrain
100
+ }),
101
+ ),
102
+ subscribe: (listener) => {
103
+ changeListeners.add(listener)
104
+ return () => void changeListeners.delete(listener)
105
+ },
106
+ currentTree: () => frame.tree,
107
+ dispose: () => runtime.dispose(),
108
+ }
109
+ }
@@ -3,21 +3,11 @@ import { Schema as S } from 'effect'
3
3
  import { Composition, slot, ui, Ui } from '@playfast/reform'
4
4
  import { remoteContract, remoteViews } from './client'
5
5
 
6
- /**
7
- * Type-level proof that a remote client view — authored with the SAME `Ui.make`
8
- * the local renderer uses and registered with `remoteViews` — is strict: props,
9
- * event payloads, and slot names are all derived from the contract, so a wrong
10
- * access fails to compile. Compiled by `tsc` (the `@ts-expect-error`s would
11
- * themselves error if the line below them ever stopped being an error); not
12
- * bundled, not a runtime test.
13
- */
14
-
15
6
  class FooUi extends ui('Foo', {
16
7
  props: S.Struct({ n: S.Number }),
17
8
  events: { go: S.Struct({ x: S.String }) },
18
9
  }) {}
19
10
 
20
- // ✓ props and event payloads are inferred from the contract.
21
11
  const fooView = Ui.make(FooUi, (props, _slots, events) => {
22
12
  events.go({ x: String(props.n) })
23
13
  return null
@@ -41,12 +31,10 @@ Ui.make(FooUi, (_props, _slots, events) => {
41
31
  return null
42
32
  })
43
33
 
44
- // A local contract with a slot.
45
34
  class FooComp extends Composition.make('FooComp', { title: 'Foo', ui: FooUi }) {}
46
35
  class FooSlot extends slot('Foo')<typeof FooComp>() {}
47
36
  class ShellUi extends ui('Shell', { props: S.Struct({}), slots: { Main: FooSlot } }) {}
48
37
 
49
- // ✓ slot components are keyed by the contract's slot names.
50
38
  const shellView = Ui.make(ShellUi, (_props, slots) => createElement(slots.Main, {}))
51
39
 
52
40
  Ui.make(ShellUi, (_props, slots) =>
@@ -54,12 +42,9 @@ Ui.make(ShellUi, (_props, slots) =>
54
42
  createElement(slots.Other, {}),
55
43
  )
56
44
 
57
- // The server declares its shape ONCE and exports `typeof` it; the client implements that
58
- // type with `remoteViews<AppContract>(…)` — importing only the TYPE, never the value.
59
45
  const AppContractValue = remoteContract({ Foo: FooUi, Shell: ShellUi })
60
46
  type AppContract = typeof AppContractValue
61
47
 
62
- // ✓ a view per contract, each recovering its own name + schema; implements the full shape.
63
48
  remoteViews<AppContract>({ Foo: fooView, Shell: shellView })
64
49
 
65
50
  // @ts-expect-error — a view set MISSING `Shell` does not implement the server's shape.
@@ -17,8 +17,6 @@ import {
17
17
  structureCounterScene,
18
18
  } from './fixtures'
19
19
 
20
- // The client renders views as React components, so a test runs them by RENDERING
21
- // `renderWireTree`'s output (node, no DOM needed) — the views capture as they render.
22
20
  const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
23
21
 
24
22
  const dataOf = (node: WireNode, name: string): WireProp | undefined =>
@@ -35,8 +33,6 @@ const upsertIds = (patches: ReadonlyArray<WirePatch>): ReadonlyArray<string> =>
35
33
  const deleteIds = (patches: ReadonlyArray<WirePatch>): ReadonlyArray<string> =>
36
34
  patches.flatMap((patch) => (patch._tag === 'Delete' ? [patch.id] : []))
37
35
 
38
- // ── flat counter ──────────────────────────────────────────────────────────────
39
-
40
36
  test('a scene renders to a wire tree with schema-encoded props and a trigger handle', async () => {
41
37
  const server = makeRemoteServer(counterScene())
42
38
  try {
@@ -66,7 +62,6 @@ test('invoking a trigger handle dispatches into the runtime and the next render
66
62
  }
67
63
  })
68
64
 
69
- // ── structure-path counter: events ride ON the structure (plan 03b) ─────────────
70
65
 
71
66
  test('a mount(...)-returning body registers its event from structure.events and invoking it dispatches', async () => {
72
67
  const server = makeRemoteServer(structureCounterScene())
@@ -74,8 +69,6 @@ test('a mount(...)-returning body registers its event from structure.events and
74
69
  const tree = await server.render()
75
70
  const root = tree[0]!
76
71
  expect(root.name).toBe('Counter')
77
- // Props come from the structure value; the `bump` trigger rode on
78
- // `structure.events` and was registered behind the standard handle.
79
72
  expect(dataOf(root, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 0 })
80
73
  expect(eventOf(root, 'bump')).toEqual({ _tag: 'Event', name: 'bump', handle: '0:bump' })
81
74
 
@@ -156,7 +149,6 @@ test('boot events are applied before the first render', async () => {
156
149
  }
157
150
  })
158
151
 
159
- // ── nested slot: a child with its own state, two events ─────────────────────────
160
152
 
161
153
  test('a slotted child renders nested with its own encoded props and handle', async () => {
162
154
  const server = makeRemoteServer(slottedScene())
@@ -214,13 +206,11 @@ test('two events on one node register distinct handles and each fires independen
214
206
  }
215
207
  })
216
208
 
217
- // ── dynamic list: add (upsert), remove (delete + handle revocation) ─────────────
218
209
 
219
210
  test('a list renders one child per item under a single slot, in order', async () => {
220
211
  const server = makeRemoteServer(listScene())
221
212
  try {
222
213
  const tree = await server.render()
223
- // List + Bar + two items.
224
214
  expect(tree).toHaveLength(4)
225
215
  expect(byId(tree, '0')!.name).toBe('List')
226
216
  expect(byId(tree, '0.Bar.0')!.slot).toBe('Bar')
@@ -238,14 +228,13 @@ test('a list renders one child per item under a single slot, in order', async ()
238
228
  test('adding an item emits an Upsert for the new node; removing emits a Delete', async () => {
239
229
  const server = makeRemoteServer(listScene())
240
230
  try {
241
- await server.renderDiff() // first frame (snapshot baseline)
231
+ await server.renderDiff()
242
232
 
243
233
  await server.invoke('0.Bar.0:add', { id: 'c', label: 'C' })
244
234
  const added = await server.renderDiff()
245
235
  expect(deleteIds(added)).toEqual([])
246
236
  expect(upsertIds(added)).toContain('0.Item.2')
247
237
 
248
- // Remove the last item — exactly its node leaves the tree.
249
238
  await server.invoke('0.Item.2:remove', {})
250
239
  const removed = await server.renderDiff()
251
240
  expect(deleteIds(removed)).toEqual(['0.Item.2'])
@@ -258,25 +247,20 @@ test("a removed node's handle is revoked — a stale client invocation rejects",
258
247
  const server = makeRemoteServer(listScene())
259
248
  try {
260
249
  await server.renderDiff()
261
- // The handle works while the item is mounted…
262
250
  await server.invoke('0.Item.1:remove', {})
263
- await server.renderDiff() // item 'b' gone → its handle revoked
264
- // …and fails cleanly once the node has been unmounted.
251
+ await server.renderDiff()
265
252
  await expect(server.invoke('0.Item.1:remove', {})).rejects.toBeDefined()
266
253
  } finally {
267
254
  await server.dispose()
268
255
  }
269
256
  })
270
257
 
271
- // ── Option props: Effect-native values survive the wire as real instances ───────
272
258
 
273
259
  test('an Option prop round-trips through JSON as a real Option (symmetric schema decode)', async () => {
274
260
  const server = makeRemoteServer(optionScene(Option.some('hi')))
275
261
  const captured: { label?: unknown } = {}
276
262
  try {
277
263
  const tree = await server.render()
278
- // Round-trip the tree through JSON exactly as the real WebSocket transport does —
279
- // the Option's `toJSON` flattens it to a plain `{_tag,value}` shape on the way out.
280
264
  const wire: WireTree = JSON.parse(JSON.stringify(tree))
281
265
  draw(
282
266
  renderWireTree(wire, {
@@ -289,7 +273,6 @@ test('an Option prop round-trips through JSON as a real Option (symmetric schema
289
273
  invoke: () => undefined,
290
274
  }),
291
275
  )
292
- // The client decoded it back through the contract schema → a REAL Option instance.
293
276
  expect(Option.isOption(captured.label)).toBe(true)
294
277
  expect(captured.label).toStrictEqual(Option.some('hi'))
295
278
  } finally {
@@ -322,7 +305,6 @@ test('a data-only change upserts only the changed node, leaving siblings untouch
322
305
  await server.renderDiff()
323
306
  await server.invoke('0.Main.0:greet', { text: 'changed' })
324
307
  const patches = await server.renderDiff()
325
- // Only the panel changed; the shell is unchanged, so no patch for it.
326
308
  expect(upsertIds(patches)).toEqual(['0.Main.0'])
327
309
  expect(deleteIds(patches)).toEqual([])
328
310
  } finally {
package/src/server.ts CHANGED
@@ -1,86 +1,40 @@
1
1
  import {
2
2
  Array as Arr,
3
3
  Effect,
4
- Layer,
5
4
  Match,
6
- ManagedRuntime,
7
5
  Option,
8
6
  type ParseResult,
9
- PubSub,
10
- Queue,
11
7
  Record as Rec,
12
8
  Schema,
13
9
  } from 'effect'
14
10
  import {
15
- Bus,
16
11
  Composition,
17
12
  type CompositionClass,
18
13
  type CompositionService,
19
- forceSync,
20
14
  isFeatureBinding,
21
15
  isStructure,
22
- publish,
23
16
  type RenderEnv,
24
17
  type Scene,
25
18
  type SlotChild,
26
19
  type SlotClass,
27
20
  type SlotFill,
28
21
  type Structure,
29
- Triggers,
30
22
  type Trigger,
31
23
  type TriggerRegistryApi,
32
24
  type UiContract,
33
25
  type UiManifest,
34
- Wire,
35
26
  type WireNode,
36
- type WirePatch,
37
27
  type WireProp,
38
28
  type WireTree,
39
29
  } from '@playfast/reform'
40
30
 
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
60
-
61
- const SETTLE_DRAIN = 30
62
- const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
63
-
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.
31
+ // At erased UiContract boundary, event map is Record<never, never> → assignable to Trigger<unknown> without cast.
69
32
  const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
70
33
  Option.match(Option.fromNullable(structure.events), {
71
34
  onNone: () => ({}),
72
35
  onSome: (events) => events,
73
36
  })
74
37
 
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
38
  interface Mounted {
85
39
  readonly comp: CompositionClass<unknown>
86
40
  readonly props: unknown
@@ -88,26 +42,15 @@ interface Mounted {
88
42
  readonly parentId: Option.Option<string>
89
43
  readonly slot: Option.Option<string>
90
44
  readonly childIndex: number
91
- /** The React `key` the parent gave this slot child, if any — the keyed-slot selector. */
92
45
  readonly key: Option.Option<string>
93
46
  }
94
47
 
95
48
  const childComposition = (child: SlotChild): CompositionClass<unknown> =>
96
49
  isFeatureBinding(child) ? child.composition : child
97
50
 
98
- const handlesOf = (node: WireNode): ReadonlyArray<string> =>
99
- node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
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.
51
+ // One-shot render (screenshot): wire still has Event handles; registrar is no-op.
104
52
  const noRegister: TriggerRegistryApi['register'] = () => Effect.void
105
53
 
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
54
  const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
112
55
  mounted: Mounted,
113
56
  structure: Structure<UiContract>,
@@ -116,8 +59,6 @@ const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
116
59
  const manifest: UiManifest = mounted.comp.manifest.ui.manifest
117
60
  const name = manifest.name
118
61
 
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
62
  const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
122
63
  onNone: () => Effect.succeed({}),
123
64
  onSome: (schema) => Schema.encodeUnknown(schema)(structure.props),
@@ -151,18 +92,13 @@ const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
151
92
  }
152
93
  })
153
94
 
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`.
95
+ // Structure erases per-slot fill types; recover SlotFill structurally without `as`.
158
96
  const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
159
97
  typeof candidate === 'object' &&
160
98
  candidate !== null &&
161
99
  '_tag' in candidate &&
162
100
  (candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
163
101
 
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
102
  const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
167
103
  Rec.fromEntries(
168
104
  Rec.toEntries(structure.slots).flatMap(([slotName, fillValue]) =>
@@ -170,10 +106,6 @@ const structureFills = (structure: Structure<UiContract>): Record<string, SlotFi
170
106
  ),
171
107
  )
172
108
 
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
109
  const slotsOf = (comp: CompositionClass<unknown>): Record<string, SlotClass> =>
178
110
  Option.getOrElse(Option.fromNullable(comp.manifest.slots), () => ({}))
179
111
 
@@ -216,7 +148,7 @@ const collect = Effect.fn('collect')(function* (
216
148
  root: CompositionClass<unknown>,
217
149
  ): Effect.fn.Return<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> {
218
150
  const bindings = new Map<SlotClass, CompositionClass<unknown>>()
219
- // `walk` recurses, so it carries an explicit type so its body can reference itself cast-free.
151
+ // Explicit type so recursive walk references itself cast-free.
220
152
  const walk: (comp: CompositionClass<unknown>) => Effect.Effect<void, never, SlotChild> = Effect.fn(
221
153
  'walk',
222
154
  )(function* (comp: CompositionClass<unknown>): Effect.fn.Return<void, never, SlotChild> {
@@ -235,8 +167,7 @@ const collect = Effect.fn('collect')(function* (
235
167
  return bindings
236
168
  })
237
169
 
238
- // `renderLevel` recurses level by level, so it carries an explicit type so its body can
239
- // reference itself cast-free.
170
+ // Explicit type so recursive renderLevel references itself cast-free.
240
171
  const renderLevel: (
241
172
  frontier: ReadonlyArray<Mounted>,
242
173
  bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
@@ -256,9 +187,6 @@ const renderLevel: (
256
187
  const service = yield* mounted.comp.tag
257
188
  const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
258
189
  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
190
  if (!isStructure(frame)) {
263
191
  return yield* Effect.dieMessage(
264
192
  `reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
@@ -286,16 +214,6 @@ const renderLevel: (
286
214
  return [...nodes, ...rest]
287
215
  })
288
216
 
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
217
  export interface RenderSceneToWireOptions {
300
218
  readonly register: TriggerRegistryApi['register']
301
219
  }
@@ -320,125 +238,4 @@ export const renderSceneToWire = (
320
238
  return yield* renderLevel([root], bindings, register)
321
239
  })
322
240
 
323
- export interface RemoteServer {
324
- /** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
325
- 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
- readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
332
- /** Fire a trigger the client referenced by handle, then settle the runtime. */
333
- 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
- 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
349
- /** Tear down the runtime and its forked fibers. */
350
- readonly dispose: () => Promise<void>
351
- }
352
-
353
- export const makeRemoteServer = (scene: Scene): RemoteServer => {
354
- const runtime = ManagedRuntime.make(closedSceneLayer(scene))
355
- const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
356
-
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
- )
362
-
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
- const changeListeners = new Set<() => void>()
370
- const notifyChange = (): void => {
371
- changeListeners.forEach((listener) => listener())
372
- }
373
- runtime.runFork(
374
- Effect.scoped(
375
- Effect.gen(function* () {
376
- const bus = yield* Bus
377
- 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.
381
- yield* Queue.take(subscription).pipe(
382
- Effect.zipRight(Effect.sync(notifyChange)),
383
- Effect.forever,
384
- )
385
- }),
386
- ),
387
- )
388
-
389
- runtime.runSync(
390
- Effect.forEach(
391
- Option.getOrElse(Option.fromNullable(scene.boot), () => []),
392
- (event) => publish('High', event),
393
- ),
394
- )
395
-
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
- const frame: { tree: WireTree } = { tree: [] }
399
-
400
- const render = (): Promise<WireTree> =>
401
- runtime.runPromise(
402
- renderEffect.pipe(
403
- Effect.tap((tree) =>
404
- Effect.sync(() => {
405
- frame.tree = tree
406
- }),
407
- ),
408
- ),
409
- )
410
-
411
- const renderDiff = (): Promise<ReadonlyArray<WirePatch>> =>
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
- )
426
-
427
- return {
428
- render,
429
- renderDiff,
430
- invoke: (handle, encodedPayload) =>
431
- runtime.runPromise(
432
- Effect.gen(function* () {
433
- yield* registry.invoke(handle, encodedPayload)
434
- yield* settleDrain
435
- }),
436
- ),
437
- subscribe: (listener) => {
438
- changeListeners.add(listener)
439
- return () => void changeListeners.delete(listener)
440
- },
441
- currentTree: () => frame.tree,
442
- dispose: () => runtime.dispose(),
443
- }
444
- }
241
+ export { makeRemoteServer, type RemoteServer } from './remote-server'