@playfast/reform-remote 0.0.2 → 0.0.4

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,331 @@
1
+ import { createElement, Fragment, type ReactNode } from 'react'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { expect, test } from 'vitest'
4
+ import { Option } from 'effect'
5
+ import { Ui, Wire } from '@playfast/reform'
6
+ import type { WireNode, WirePatch, WireProp, WireTree } from '@playfast/reform'
7
+ import { makeRemoteServer } from './server'
8
+ import { remoteViews, renderWireTree } from './client'
9
+ import {
10
+ bumpedBy,
11
+ CounterUi,
12
+ counterScene,
13
+ listScene,
14
+ OptionalUi,
15
+ optionScene,
16
+ slottedScene,
17
+ structureCounterScene,
18
+ } from './fixtures'
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
+ const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
23
+
24
+ const dataOf = (node: WireNode, name: string): WireProp | undefined =>
25
+ node.props.find((prop) => prop._tag === 'Data' && prop.name === name)
26
+
27
+ const eventOf = (node: WireNode, name: string): Extract<WireProp, { _tag: 'Event' }> | undefined =>
28
+ node.props.find((prop): prop is Extract<WireProp, { _tag: 'Event' }> => prop._tag === 'Event' && prop.name === name)
29
+
30
+ const byId = (tree: WireTree, id: string): WireNode | undefined => tree.find((node) => node.id === id)
31
+
32
+ const upsertIds = (patches: ReadonlyArray<WirePatch>): ReadonlyArray<string> =>
33
+ patches.flatMap((patch) => (patch._tag === 'Upsert' ? [patch.node.id] : []))
34
+
35
+ const deleteIds = (patches: ReadonlyArray<WirePatch>): ReadonlyArray<string> =>
36
+ patches.flatMap((patch) => (patch._tag === 'Delete' ? [patch.id] : []))
37
+
38
+ // ── flat counter ──────────────────────────────────────────────────────────────
39
+
40
+ test('a scene renders to a wire tree with schema-encoded props and a trigger handle', async () => {
41
+ const server = makeRemoteServer(counterScene())
42
+ try {
43
+ const tree = await server.render()
44
+ expect(tree).toHaveLength(1)
45
+ const root = tree[0]!
46
+ expect(root.id).toBe('0')
47
+ expect(root.name).toBe('Counter')
48
+ expect(root.parentId).toBeNull()
49
+ expect(dataOf(root, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 0 })
50
+ expect(eventOf(root, 'bump')).toEqual({ _tag: 'Event', name: 'bump', handle: '0:bump' })
51
+ } finally {
52
+ await server.dispose()
53
+ }
54
+ })
55
+
56
+ test('invoking a trigger handle dispatches into the runtime and the next render reflects it', async () => {
57
+ const server = makeRemoteServer(counterScene())
58
+ try {
59
+ await server.render()
60
+ await server.invoke('0:bump', { by: 5 })
61
+ await server.invoke('0:bump', { by: 2 })
62
+ const tree = await server.render()
63
+ expect(dataOf(tree[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 7 })
64
+ } finally {
65
+ await server.dispose()
66
+ }
67
+ })
68
+
69
+ // ── structure-path counter: events ride ON the structure (plan 03b) ─────────────
70
+
71
+ test('a mount(...)-returning body registers its event from structure.events and invoking it dispatches', async () => {
72
+ const server = makeRemoteServer(structureCounterScene())
73
+ try {
74
+ const tree = await server.render()
75
+ const root = tree[0]!
76
+ 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
+ expect(dataOf(root, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 0 })
80
+ expect(eventOf(root, 'bump')).toEqual({ _tag: 'Event', name: 'bump', handle: '0:bump' })
81
+
82
+ await server.invoke('0:bump', { by: 4 })
83
+ const next = await server.render()
84
+ expect(dataOf(next[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 4 })
85
+ } finally {
86
+ await server.dispose()
87
+ }
88
+ })
89
+
90
+ test('the wire payload is validated at the seam — a bad payload rejects', async () => {
91
+ const server = makeRemoteServer(counterScene())
92
+ try {
93
+ await server.render()
94
+ await expect(server.invoke('0:bump', { by: 'nope' })).rejects.toBeDefined()
95
+ } finally {
96
+ await server.dispose()
97
+ }
98
+ })
99
+
100
+ test('renderDiff streams patches a client folds back to the current tree', async () => {
101
+ const server = makeRemoteServer(counterScene())
102
+ try {
103
+ const first = await server.renderDiff()
104
+ const afterFirst = Wire.apply([], first)
105
+ expect(dataOf(afterFirst[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 0 })
106
+
107
+ await server.invoke('0:bump', { by: 3 })
108
+ const second = await server.renderDiff()
109
+ expect(second).toHaveLength(1)
110
+ expect(second[0]?._tag).toBe('Upsert')
111
+ const afterSecond = Wire.apply(afterFirst, second)
112
+ expect(dataOf(afterSecond[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 3 })
113
+ } finally {
114
+ await server.dispose()
115
+ }
116
+ })
117
+
118
+ test('end to end: client reconstructs props + event callbacks that drive the server', async () => {
119
+ const server = makeRemoteServer(counterScene())
120
+ const captured: { props?: Record<string, unknown>; events?: Record<string, (p: { by: number }) => void> } = {}
121
+ const sent: Array<[string, unknown]> = []
122
+ try {
123
+ const tree = await server.render()
124
+ draw(
125
+ renderWireTree(tree, {
126
+ views: remoteViews<{ Counter: typeof CounterUi }>({
127
+ Counter: Ui.make(CounterUi, (props, _slots, events) => {
128
+ captured.props = props
129
+ captured.events = events
130
+ return null
131
+ }),
132
+ }),
133
+ invoke: (handle, payload) => void sent.push([handle, payload]),
134
+ }),
135
+ )
136
+
137
+ expect(captured.props).toEqual({ count: 0 })
138
+ captured.events?.['bump']?.({ by: 4 })
139
+ expect(sent).toEqual([['0:bump', { by: 4 }]])
140
+
141
+ await server.invoke(sent[0]![0], sent[0]![1])
142
+ const next = await server.render()
143
+ expect(dataOf(next[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 4 })
144
+ } finally {
145
+ await server.dispose()
146
+ }
147
+ })
148
+
149
+ test('boot events are applied before the first render', async () => {
150
+ const server = makeRemoteServer(counterScene([bumpedBy(10), bumpedBy(5)]))
151
+ try {
152
+ const tree = await server.render()
153
+ expect(dataOf(tree[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 15 })
154
+ } finally {
155
+ await server.dispose()
156
+ }
157
+ })
158
+
159
+ // ── nested slot: a child with its own state, two events ─────────────────────────
160
+
161
+ test('a slotted child renders nested with its own encoded props and handle', async () => {
162
+ const server = makeRemoteServer(slottedScene())
163
+ try {
164
+ const tree = await server.render()
165
+ expect(tree).toHaveLength(2)
166
+
167
+ const shell = byId(tree, '0')!
168
+ expect(shell.name).toBe('Shell')
169
+ expect(shell.parentId).toBeNull()
170
+
171
+ const panel = byId(tree, '0.Main.0')!
172
+ expect(panel.name).toBe('Panel')
173
+ expect(panel.parentId).toBe('0')
174
+ expect(panel.slot).toBe('Main')
175
+ expect(panel.childIndex).toBe(0)
176
+ expect(dataOf(panel, 'greeting')).toEqual({ _tag: 'Data', name: 'greeting', value: 'hi' })
177
+ expect(eventOf(panel, 'greet')?.handle).toBe('0.Main.0:greet')
178
+ } finally {
179
+ await server.dispose()
180
+ }
181
+ })
182
+
183
+ test("invoking a nested child's trigger updates the child's own state", async () => {
184
+ const server = makeRemoteServer(slottedScene())
185
+ try {
186
+ await server.render()
187
+ await server.invoke('0.Main.0:greet', { text: 'yo' })
188
+ const tree = await server.render()
189
+ expect(dataOf(byId(tree, '0.Main.0')!, 'greeting')).toEqual({
190
+ _tag: 'Data',
191
+ name: 'greeting',
192
+ value: 'yo',
193
+ })
194
+ } finally {
195
+ await server.dispose()
196
+ }
197
+ })
198
+
199
+ test('two events on one node register distinct handles and each fires independently', async () => {
200
+ const server = makeRemoteServer(slottedScene())
201
+ try {
202
+ await server.render()
203
+ const before = byId(await server.render(), '0.Main.0')!
204
+ expect(eventOf(before, 'greet')?.handle).toBe('0.Main.0:greet')
205
+ expect(eventOf(before, 'clear')?.handle).toBe('0.Main.0:clear')
206
+
207
+ await server.invoke('0.Main.0:greet', { text: 'hello' })
208
+ expect(dataOf(byId(await server.render(), '0.Main.0')!, 'greeting')).toMatchObject({ value: 'hello' })
209
+
210
+ await server.invoke('0.Main.0:clear', {})
211
+ expect(dataOf(byId(await server.render(), '0.Main.0')!, 'greeting')).toMatchObject({ value: '' })
212
+ } finally {
213
+ await server.dispose()
214
+ }
215
+ })
216
+
217
+ // ── dynamic list: add (upsert), remove (delete + handle revocation) ─────────────
218
+
219
+ test('a list renders one child per item under a single slot, in order', async () => {
220
+ const server = makeRemoteServer(listScene())
221
+ try {
222
+ const tree = await server.render()
223
+ // List + Bar + two items.
224
+ expect(tree).toHaveLength(4)
225
+ expect(byId(tree, '0')!.name).toBe('List')
226
+ expect(byId(tree, '0.Bar.0')!.slot).toBe('Bar')
227
+
228
+ const items = Wire.childrenOf(tree, '0').filter((node) => node.slot === 'Item')
229
+ expect(items.map((node) => node.id)).toEqual(['0.Item.0', '0.Item.1'])
230
+ expect(items.map((node) => node.childIndex)).toEqual([0, 1])
231
+ expect(dataOf(items[0]!, 'label')).toMatchObject({ value: 'A' })
232
+ expect(dataOf(items[1]!, 'label')).toMatchObject({ value: 'B' })
233
+ } finally {
234
+ await server.dispose()
235
+ }
236
+ })
237
+
238
+ test('adding an item emits an Upsert for the new node; removing emits a Delete', async () => {
239
+ const server = makeRemoteServer(listScene())
240
+ try {
241
+ await server.renderDiff() // first frame (snapshot baseline)
242
+
243
+ await server.invoke('0.Bar.0:add', { id: 'c', label: 'C' })
244
+ const added = await server.renderDiff()
245
+ expect(deleteIds(added)).toEqual([])
246
+ expect(upsertIds(added)).toContain('0.Item.2')
247
+
248
+ // Remove the last item — exactly its node leaves the tree.
249
+ await server.invoke('0.Item.2:remove', {})
250
+ const removed = await server.renderDiff()
251
+ expect(deleteIds(removed)).toEqual(['0.Item.2'])
252
+ } finally {
253
+ await server.dispose()
254
+ }
255
+ })
256
+
257
+ test("a removed node's handle is revoked — a stale client invocation rejects", async () => {
258
+ const server = makeRemoteServer(listScene())
259
+ try {
260
+ await server.renderDiff()
261
+ // The handle works while the item is mounted…
262
+ 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.
265
+ await expect(server.invoke('0.Item.1:remove', {})).rejects.toBeDefined()
266
+ } finally {
267
+ await server.dispose()
268
+ }
269
+ })
270
+
271
+ // ── Option props: Effect-native values survive the wire as real instances ───────
272
+
273
+ test('an Option prop round-trips through JSON as a real Option (symmetric schema decode)', async () => {
274
+ const server = makeRemoteServer(optionScene(Option.some('hi')))
275
+ const captured: { label?: unknown } = {}
276
+ try {
277
+ 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
+ const wire: WireTree = JSON.parse(JSON.stringify(tree))
281
+ draw(
282
+ renderWireTree(wire, {
283
+ views: remoteViews<{ Optional: typeof OptionalUi }>({
284
+ Optional: Ui.make(OptionalUi, (props) => {
285
+ captured.label = props.label
286
+ return null
287
+ }),
288
+ }),
289
+ invoke: () => undefined,
290
+ }),
291
+ )
292
+ // The client decoded it back through the contract schema → a REAL Option instance.
293
+ expect(Option.isOption(captured.label)).toBe(true)
294
+ expect(captured.label).toStrictEqual(Option.some('hi'))
295
+ } finally {
296
+ await server.dispose()
297
+ }
298
+ })
299
+
300
+ test('an Option.none prop round-trips through JSON as Option.none()', async () => {
301
+ const server = makeRemoteServer(optionScene(Option.none()))
302
+ const captured: { label?: unknown } = {}
303
+ try {
304
+ const wire: WireTree = JSON.parse(JSON.stringify(await server.render()))
305
+ draw(
306
+ renderWireTree(wire, {
307
+ views: remoteViews<{ Optional: typeof OptionalUi }>({
308
+ Optional: Ui.make(OptionalUi, (props) => ((captured.label = props.label), null)),
309
+ }),
310
+ invoke: () => undefined,
311
+ }),
312
+ )
313
+ expect(captured.label).toStrictEqual(Option.none())
314
+ } finally {
315
+ await server.dispose()
316
+ }
317
+ })
318
+
319
+ test('a data-only change upserts only the changed node, leaving siblings untouched', async () => {
320
+ const server = makeRemoteServer(slottedScene())
321
+ try {
322
+ await server.renderDiff()
323
+ await server.invoke('0.Main.0:greet', { text: 'changed' })
324
+ const patches = await server.renderDiff()
325
+ // Only the panel changed; the shell is unchanged, so no patch for it.
326
+ expect(upsertIds(patches)).toEqual(['0.Main.0'])
327
+ expect(deleteIds(patches)).toEqual([])
328
+ } finally {
329
+ await server.dispose()
330
+ }
331
+ })
package/src/server.ts ADDED
@@ -0,0 +1,343 @@
1
+ import { Effect, Layer, Match, ManagedRuntime, type ParseResult, PubSub, Queue, Schema } from 'effect'
2
+ import {
3
+ Bus,
4
+ Composition,
5
+ type CompositionClass,
6
+ type CompositionService,
7
+ forceSync,
8
+ isFeatureBinding,
9
+ isStructure,
10
+ publish,
11
+ type RenderEnv,
12
+ type Scene,
13
+ type SlotChild,
14
+ type SlotClass,
15
+ type SlotFill,
16
+ type Structure,
17
+ Triggers,
18
+ type Trigger,
19
+ type TriggerRegistryApi,
20
+ type UiContract,
21
+ type UiManifest,
22
+ Wire,
23
+ type WireNode,
24
+ type WirePatch,
25
+ type WireProp,
26
+ type WireTree,
27
+ } from '@playfast/reform'
28
+
29
+ /**
30
+ * The server side of the remote transport (REMOTE_UI.md §5): render a closed
31
+ * `Scene` to a serializable `WireTree`, registering each trigger behind a handle.
32
+ * Reads each composition's returned `Structure` value directly — props, per-slot
33
+ * fills, and event triggers are all DATA — so the server imports no React and never
34
+ * evaluates a view. The render walk is breadth-first over the structure tree,
35
+ * assigning stable ids and encoding props via each contract's own wire schema
36
+ * (`WiredUiManifest`, Phase 1).
37
+ */
38
+
39
+ // The runtime services a closed scene exposes (mirrors proof's erasure boundary:
40
+ // the scene's `provide` is typed to `MountedServices`, but slot tags resolve
41
+ // `SlotChild` from the same closed layer).
42
+ type RuntimeServices = CompositionService | SlotChild | Bus
43
+
44
+ const SETTLE_DRAIN = 30
45
+ const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
46
+
47
+ // The event triggers a `Structure` frame carries (plan 03b). A composition's logic
48
+ // returns its acquired triggers ON the structure value (it never evaluates a view),
49
+ // so the server reads them straight from there. At the erased `UiContract` boundary the contract's event map
50
+ // is `Record<never, never>`, so each value is `never` — assignable to
51
+ // `Trigger<unknown>` without a cast — and an omitted `events` defaults to empty.
52
+ const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
53
+ Object.fromEntries(Object.entries(structure.events ?? {}))
54
+
55
+ const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
56
+ scene.provide.reduce((a, b) => Layer.merge(a, b)) as unknown as Layer.Layer<
57
+ RuntimeServices,
58
+ never,
59
+ never
60
+ >
61
+
62
+ /** A composition queued to render, with the props its parent handed it and its place in the tree. */
63
+ interface Mounted {
64
+ readonly comp: CompositionClass<unknown>
65
+ readonly props: unknown
66
+ readonly id: string
67
+ readonly parentId: string | null
68
+ readonly slot: string | null
69
+ readonly childIndex: number
70
+ /** The React `key` the parent gave this slot child, or `null` — the keyed-slot selector. */
71
+ readonly key: string | null
72
+ }
73
+
74
+ const childComposition = (child: SlotChild): CompositionClass<unknown> =>
75
+ isFeatureBinding(child) ? child.composition : child
76
+
77
+ const handlesOf = (node: WireNode): ReadonlyArray<string> =>
78
+ node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
79
+
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
+ }
103
+
104
+ export const makeRemoteServer = (scene: Scene): RemoteServer => {
105
+ const runtime = ManagedRuntime.make(closedSceneLayer(scene))
106
+ const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
107
+
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
+ })
274
+
275
+ // Server-initiated change notification: a forked daemon subscribes to the engine bus
276
+ // and, after each batch settles, fans out to registered listeners. This is what lets a
277
+ // streaming binding push frames the client never asked for — a background load resolving
278
+ // long after connect. Forked BEFORE the boot publish below so nothing is missed (the
279
+ // listener set is empty until `subscribe` runs, so early ticks are harmless no-ops; the
280
+ // binding's opening snapshot captures all state up to `start`).
281
+ const changeListeners = new Set<() => void>()
282
+ const notifyChange = (): void => {
283
+ for (const listener of changeListeners) listener()
284
+ }
285
+ runtime.runFork(
286
+ Effect.scoped(
287
+ Effect.gen(function* () {
288
+ const bus = yield* Bus
289
+ const subscription = yield* PubSub.subscribe(bus)
290
+ // Notify on each event; the consumer (`serve`) debounces, so the render it triggers
291
+ // runs a tick later — after the drain has folded this event into state. No settle
292
+ // here: it would only perturb the drain's own scheduling for no benefit.
293
+ yield* Queue.take(subscription).pipe(
294
+ Effect.zipRight(Effect.sync(notifyChange)),
295
+ Effect.forever,
296
+ )
297
+ }),
298
+ ),
299
+ )
300
+
301
+ runtime.runSync(Effect.forEach(scene.boot ?? [], (event) => publish('High', event)))
302
+
303
+ // The last emitted frame, to diff against. A const holder whose field we swap
304
+ // (no reassigned binding — house rule), not a `let`.
305
+ const frame: { tree: WireTree } = { tree: [] }
306
+
307
+ const render = (): Promise<WireTree> =>
308
+ runtime.runPromise(renderEffect).then((tree) => {
309
+ frame.tree = tree
310
+ return tree
311
+ })
312
+
313
+ 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
+ })
326
+
327
+ return {
328
+ render,
329
+ renderDiff,
330
+ invoke: (handle, encodedPayload) =>
331
+ runtime.runPromise(
332
+ Effect.gen(function* () {
333
+ yield* registry.invoke(handle, encodedPayload)
334
+ yield* settleDrain
335
+ }),
336
+ ),
337
+ subscribe: (listener) => {
338
+ changeListeners.add(listener)
339
+ return () => void changeListeners.delete(listener)
340
+ },
341
+ dispose: () => runtime.dispose(),
342
+ }
343
+ }