@playfast/reform-remote 0.0.2 → 0.0.3

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,309 @@
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
+ } from './fixtures'
18
+
19
+ // The client renders views as React components, so a test runs them by RENDERING
20
+ // `renderWireTree`'s output (node, no DOM needed) — the views capture as they render.
21
+ const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
22
+
23
+ const dataOf = (node: WireNode, name: string): WireProp | undefined =>
24
+ node.props.find((prop) => prop._tag === 'Data' && prop.name === name)
25
+
26
+ const eventOf = (node: WireNode, name: string): Extract<WireProp, { _tag: 'Event' }> | undefined =>
27
+ node.props.find((prop): prop is Extract<WireProp, { _tag: 'Event' }> => prop._tag === 'Event' && prop.name === name)
28
+
29
+ const byId = (tree: WireTree, id: string): WireNode | undefined => tree.find((node) => node.id === id)
30
+
31
+ const upsertIds = (patches: ReadonlyArray<WirePatch>): ReadonlyArray<string> =>
32
+ patches.flatMap((patch) => (patch._tag === 'Upsert' ? [patch.node.id] : []))
33
+
34
+ const deleteIds = (patches: ReadonlyArray<WirePatch>): ReadonlyArray<string> =>
35
+ patches.flatMap((patch) => (patch._tag === 'Delete' ? [patch.id] : []))
36
+
37
+ // ── flat counter ──────────────────────────────────────────────────────────────
38
+
39
+ test('a scene renders to a wire tree with schema-encoded props and a trigger handle', async () => {
40
+ const server = makeRemoteServer(counterScene())
41
+ try {
42
+ const tree = await server.render()
43
+ expect(tree).toHaveLength(1)
44
+ const root = tree[0]!
45
+ expect(root.id).toBe('0')
46
+ expect(root.name).toBe('Counter')
47
+ expect(root.parentId).toBeNull()
48
+ expect(dataOf(root, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 0 })
49
+ expect(eventOf(root, 'bump')).toEqual({ _tag: 'Event', name: 'bump', handle: '0:bump' })
50
+ } finally {
51
+ await server.dispose()
52
+ }
53
+ })
54
+
55
+ test('invoking a trigger handle dispatches into the runtime and the next render reflects it', async () => {
56
+ const server = makeRemoteServer(counterScene())
57
+ try {
58
+ await server.render()
59
+ await server.invoke('0:bump', { by: 5 })
60
+ await server.invoke('0:bump', { by: 2 })
61
+ const tree = await server.render()
62
+ expect(dataOf(tree[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 7 })
63
+ } finally {
64
+ await server.dispose()
65
+ }
66
+ })
67
+
68
+ test('the wire payload is validated at the seam — a bad payload rejects', async () => {
69
+ const server = makeRemoteServer(counterScene())
70
+ try {
71
+ await server.render()
72
+ await expect(server.invoke('0:bump', { by: 'nope' })).rejects.toBeDefined()
73
+ } finally {
74
+ await server.dispose()
75
+ }
76
+ })
77
+
78
+ test('renderDiff streams patches a client folds back to the current tree', async () => {
79
+ const server = makeRemoteServer(counterScene())
80
+ try {
81
+ const first = await server.renderDiff()
82
+ const afterFirst = Wire.apply([], first)
83
+ expect(dataOf(afterFirst[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 0 })
84
+
85
+ await server.invoke('0:bump', { by: 3 })
86
+ const second = await server.renderDiff()
87
+ expect(second).toHaveLength(1)
88
+ expect(second[0]?._tag).toBe('Upsert')
89
+ const afterSecond = Wire.apply(afterFirst, second)
90
+ expect(dataOf(afterSecond[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 3 })
91
+ } finally {
92
+ await server.dispose()
93
+ }
94
+ })
95
+
96
+ test('end to end: client reconstructs props + event callbacks that drive the server', async () => {
97
+ const server = makeRemoteServer(counterScene())
98
+ const captured: { props?: Record<string, unknown>; events?: Record<string, (p: { by: number }) => void> } = {}
99
+ const sent: Array<[string, unknown]> = []
100
+ try {
101
+ const tree = await server.render()
102
+ draw(
103
+ renderWireTree(tree, {
104
+ views: remoteViews<{ Counter: typeof CounterUi }>({
105
+ Counter: Ui.make(CounterUi, (props, _slots, events) => {
106
+ captured.props = props
107
+ captured.events = events
108
+ return null
109
+ }),
110
+ }),
111
+ invoke: (handle, payload) => void sent.push([handle, payload]),
112
+ }),
113
+ )
114
+
115
+ expect(captured.props).toEqual({ count: 0 })
116
+ captured.events?.['bump']?.({ by: 4 })
117
+ expect(sent).toEqual([['0:bump', { by: 4 }]])
118
+
119
+ await server.invoke(sent[0]![0], sent[0]![1])
120
+ const next = await server.render()
121
+ expect(dataOf(next[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 4 })
122
+ } finally {
123
+ await server.dispose()
124
+ }
125
+ })
126
+
127
+ test('boot events are applied before the first render', async () => {
128
+ const server = makeRemoteServer(counterScene([bumpedBy(10), bumpedBy(5)]))
129
+ try {
130
+ const tree = await server.render()
131
+ expect(dataOf(tree[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 15 })
132
+ } finally {
133
+ await server.dispose()
134
+ }
135
+ })
136
+
137
+ // ── nested slot: a child with its own state, two events ─────────────────────────
138
+
139
+ test('a slotted child renders nested with its own encoded props and handle', async () => {
140
+ const server = makeRemoteServer(slottedScene())
141
+ try {
142
+ const tree = await server.render()
143
+ expect(tree).toHaveLength(2)
144
+
145
+ const shell = byId(tree, '0')!
146
+ expect(shell.name).toBe('Shell')
147
+ expect(shell.parentId).toBeNull()
148
+
149
+ const panel = byId(tree, '0.Main.0')!
150
+ expect(panel.name).toBe('Panel')
151
+ expect(panel.parentId).toBe('0')
152
+ expect(panel.slot).toBe('Main')
153
+ expect(panel.childIndex).toBe(0)
154
+ expect(dataOf(panel, 'greeting')).toEqual({ _tag: 'Data', name: 'greeting', value: 'hi' })
155
+ expect(eventOf(panel, 'greet')?.handle).toBe('0.Main.0:greet')
156
+ } finally {
157
+ await server.dispose()
158
+ }
159
+ })
160
+
161
+ test("invoking a nested child's trigger updates the child's own state", async () => {
162
+ const server = makeRemoteServer(slottedScene())
163
+ try {
164
+ await server.render()
165
+ await server.invoke('0.Main.0:greet', { text: 'yo' })
166
+ const tree = await server.render()
167
+ expect(dataOf(byId(tree, '0.Main.0')!, 'greeting')).toEqual({
168
+ _tag: 'Data',
169
+ name: 'greeting',
170
+ value: 'yo',
171
+ })
172
+ } finally {
173
+ await server.dispose()
174
+ }
175
+ })
176
+
177
+ test('two events on one node register distinct handles and each fires independently', async () => {
178
+ const server = makeRemoteServer(slottedScene())
179
+ try {
180
+ await server.render()
181
+ const before = byId(await server.render(), '0.Main.0')!
182
+ expect(eventOf(before, 'greet')?.handle).toBe('0.Main.0:greet')
183
+ expect(eventOf(before, 'clear')?.handle).toBe('0.Main.0:clear')
184
+
185
+ await server.invoke('0.Main.0:greet', { text: 'hello' })
186
+ expect(dataOf(byId(await server.render(), '0.Main.0')!, 'greeting')).toMatchObject({ value: 'hello' })
187
+
188
+ await server.invoke('0.Main.0:clear', {})
189
+ expect(dataOf(byId(await server.render(), '0.Main.0')!, 'greeting')).toMatchObject({ value: '' })
190
+ } finally {
191
+ await server.dispose()
192
+ }
193
+ })
194
+
195
+ // ── dynamic list: add (upsert), remove (delete + handle revocation) ─────────────
196
+
197
+ test('a list renders one child per item under a single slot, in order', async () => {
198
+ const server = makeRemoteServer(listScene())
199
+ try {
200
+ const tree = await server.render()
201
+ // List + Bar + two items.
202
+ expect(tree).toHaveLength(4)
203
+ expect(byId(tree, '0')!.name).toBe('List')
204
+ expect(byId(tree, '0.Bar.0')!.slot).toBe('Bar')
205
+
206
+ const items = Wire.childrenOf(tree, '0').filter((node) => node.slot === 'Item')
207
+ expect(items.map((node) => node.id)).toEqual(['0.Item.0', '0.Item.1'])
208
+ expect(items.map((node) => node.childIndex)).toEqual([0, 1])
209
+ expect(dataOf(items[0]!, 'label')).toMatchObject({ value: 'A' })
210
+ expect(dataOf(items[1]!, 'label')).toMatchObject({ value: 'B' })
211
+ } finally {
212
+ await server.dispose()
213
+ }
214
+ })
215
+
216
+ test('adding an item emits an Upsert for the new node; removing emits a Delete', async () => {
217
+ const server = makeRemoteServer(listScene())
218
+ try {
219
+ await server.renderDiff() // first frame (snapshot baseline)
220
+
221
+ await server.invoke('0.Bar.0:add', { id: 'c', label: 'C' })
222
+ const added = await server.renderDiff()
223
+ expect(deleteIds(added)).toEqual([])
224
+ expect(upsertIds(added)).toContain('0.Item.2')
225
+
226
+ // Remove the last item — exactly its node leaves the tree.
227
+ await server.invoke('0.Item.2:remove', {})
228
+ const removed = await server.renderDiff()
229
+ expect(deleteIds(removed)).toEqual(['0.Item.2'])
230
+ } finally {
231
+ await server.dispose()
232
+ }
233
+ })
234
+
235
+ test("a removed node's handle is revoked — a stale client invocation rejects", async () => {
236
+ const server = makeRemoteServer(listScene())
237
+ try {
238
+ await server.renderDiff()
239
+ // The handle works while the item is mounted…
240
+ await server.invoke('0.Item.1:remove', {})
241
+ await server.renderDiff() // item 'b' gone → its handle revoked
242
+ // …and fails cleanly once the node has been unmounted.
243
+ await expect(server.invoke('0.Item.1:remove', {})).rejects.toBeDefined()
244
+ } finally {
245
+ await server.dispose()
246
+ }
247
+ })
248
+
249
+ // ── Option props: Effect-native values survive the wire as real instances ───────
250
+
251
+ test('an Option prop round-trips through JSON as a real Option (symmetric schema decode)', async () => {
252
+ const server = makeRemoteServer(optionScene(Option.some('hi')))
253
+ const captured: { label?: unknown } = {}
254
+ try {
255
+ const tree = await server.render()
256
+ // Round-trip the tree through JSON exactly as the real WebSocket transport does —
257
+ // the Option's `toJSON` flattens it to a plain `{_tag,value}` shape on the way out.
258
+ const wire: WireTree = JSON.parse(JSON.stringify(tree))
259
+ draw(
260
+ renderWireTree(wire, {
261
+ views: remoteViews<{ Optional: typeof OptionalUi }>({
262
+ Optional: Ui.make(OptionalUi, (props) => {
263
+ captured.label = props.label
264
+ return null
265
+ }),
266
+ }),
267
+ invoke: () => undefined,
268
+ }),
269
+ )
270
+ // The client decoded it back through the contract schema → a REAL Option instance.
271
+ expect(Option.isOption(captured.label)).toBe(true)
272
+ expect(captured.label).toStrictEqual(Option.some('hi'))
273
+ } finally {
274
+ await server.dispose()
275
+ }
276
+ })
277
+
278
+ test('an Option.none prop round-trips through JSON as Option.none()', async () => {
279
+ const server = makeRemoteServer(optionScene(Option.none()))
280
+ const captured: { label?: unknown } = {}
281
+ try {
282
+ const wire: WireTree = JSON.parse(JSON.stringify(await server.render()))
283
+ draw(
284
+ renderWireTree(wire, {
285
+ views: remoteViews<{ Optional: typeof OptionalUi }>({
286
+ Optional: Ui.make(OptionalUi, (props) => ((captured.label = props.label), null)),
287
+ }),
288
+ invoke: () => undefined,
289
+ }),
290
+ )
291
+ expect(captured.label).toStrictEqual(Option.none())
292
+ } finally {
293
+ await server.dispose()
294
+ }
295
+ })
296
+
297
+ test('a data-only change upserts only the changed node, leaving siblings untouched', async () => {
298
+ const server = makeRemoteServer(slottedScene())
299
+ try {
300
+ await server.renderDiff()
301
+ await server.invoke('0.Main.0:greet', { text: 'changed' })
302
+ const patches = await server.renderDiff()
303
+ // Only the panel changed; the shell is unchanged, so no patch for it.
304
+ expect(upsertIds(patches)).toEqual(['0.Main.0'])
305
+ expect(deleteIds(patches)).toEqual([])
306
+ } finally {
307
+ await server.dispose()
308
+ }
309
+ })
package/src/server.ts ADDED
@@ -0,0 +1,333 @@
1
+ import { Effect, Layer, ManagedRuntime, type ParseResult, PubSub, Queue, Schema } from 'effect'
2
+ import { isValidElement } from 'react'
3
+ import {
4
+ Bus,
5
+ CaptureSink,
6
+ type CaptureSinkApi,
7
+ Composition,
8
+ type CompositionClass,
9
+ type CompositionService,
10
+ forceSync,
11
+ isFeatureBinding,
12
+ type Node,
13
+ publish,
14
+ type RenderEnv,
15
+ type Scene,
16
+ type SlotChild,
17
+ type SlotClass,
18
+ type SlotHost,
19
+ Triggers,
20
+ type TriggerRegistryApi,
21
+ type UiCapture,
22
+ type UiManifest,
23
+ Wire,
24
+ type WireNode,
25
+ type WirePatch,
26
+ type WireProp,
27
+ type WireTree,
28
+ } from '@playfast/reform'
29
+
30
+ /**
31
+ * The server side of the remote transport (REMOTE_UI.md §5): render a closed
32
+ * `Scene` to a serializable `WireTree`, registering each trigger behind a handle.
33
+ * Built on the existing `CaptureSink` seam — the same one proofs use headlessly —
34
+ * so nothing in core changes: production has no sink (renders to React), a host
35
+ * provides one (here, to serialize). The render walk lifts proof's breadth-first
36
+ * `collect`/`drive`, assigning stable ids and encoding props via each contract's
37
+ * own wire schema (`WiredUiManifest`, Phase 1).
38
+ */
39
+
40
+ // The runtime services a closed scene exposes (mirrors proof's erasure boundary:
41
+ // the scene's `provide` is typed to `MountedServices`, but slot tags resolve
42
+ // `SlotChild` from the same closed layer).
43
+ type RuntimeServices = CompositionService | SlotChild | Bus
44
+
45
+ const SETTLE_DRAIN = 30
46
+ const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
47
+
48
+ interface Sink {
49
+ readonly api: CaptureSinkApi
50
+ readonly captures: ReadonlyArray<UiCapture>
51
+ reset(): void
52
+ }
53
+
54
+ const makeSink = (): Sink => {
55
+ const state: { captures: UiCapture[] } = { captures: [] }
56
+ return {
57
+ api: { record: (capture) => state.captures.push(capture) },
58
+ get captures() {
59
+ return state.captures
60
+ },
61
+ reset: () => {
62
+ state.captures = []
63
+ },
64
+ }
65
+ }
66
+
67
+ const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
68
+ scene.provide.reduce((a, b) => Layer.merge(a, b)) as unknown as Layer.Layer<
69
+ RuntimeServices,
70
+ never,
71
+ never
72
+ >
73
+
74
+ /** A composition queued to render, with the props its parent handed it and its place in the tree. */
75
+ interface Mounted {
76
+ readonly comp: CompositionClass<unknown>
77
+ readonly props: unknown
78
+ readonly id: string
79
+ readonly parentId: string | null
80
+ readonly slot: string | null
81
+ readonly childIndex: number
82
+ /** The React `key` the parent gave this slot child, or `null` — the keyed-slot selector. */
83
+ readonly key: string | null
84
+ }
85
+
86
+ const childComposition = (child: SlotChild): CompositionClass<unknown> =>
87
+ isFeatureBinding(child) ? child.composition : child
88
+
89
+ /**
90
+ * Walk a rendered node and enqueue the child each slot placeholder stands for.
91
+ * Slot components are matched by identity (the host defers them as JSX), so this
92
+ * never invokes — nor needs hooks from — ordinary presentation components.
93
+ */
94
+ const drive = (
95
+ node: Node,
96
+ enqueueBy: Map<Function, (props: unknown, key: string | null) => void>,
97
+ ): void => {
98
+ if (Array.isArray(node)) {
99
+ for (const child of node) drive(child, enqueueBy)
100
+ return
101
+ }
102
+ if (!isValidElement<{ readonly children?: Node }>(node)) return
103
+ if (node.type instanceof Function) {
104
+ const enqueue = enqueueBy.get(node.type)
105
+ if (enqueue !== undefined) {
106
+ // `node.key` is the React key the parent set (`createElement(slots.Row, { key })`),
107
+ // carried onto the wire node so a KEYED slot can select this child on the client
108
+ // (see WireNode.key). React coerces a set key to a string; absent → null.
109
+ enqueue(node.props, node.key)
110
+ return
111
+ }
112
+ }
113
+ drive(node.props.children, enqueueBy)
114
+ }
115
+
116
+ const handlesOf = (node: WireNode): ReadonlyArray<string> =>
117
+ node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
118
+
119
+ export interface RemoteServer {
120
+ /** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
121
+ readonly render: () => Promise<WireTree>
122
+ /**
123
+ * Render and return only the patches since the previous frame (the streaming
124
+ * form). Handles of deleted nodes are revoked, so a stale client invocation
125
+ * fails cleanly rather than firing a dangling trigger.
126
+ */
127
+ readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
128
+ /** Fire a trigger the client referenced by handle, then settle the runtime. */
129
+ readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>
130
+ /**
131
+ * Observe SERVER-INITIATED state changes. The listener fires (after the drain
132
+ * settles) whenever an event flows on the engine bus — i.e. when an async procedure
133
+ * resolves, a boot loader completes, or a scheduler ticks — NOT just in response to a
134
+ * client invoke. `serve` registers a `renderDiff`-and-push listener here so the client
135
+ * sees background updates (a repo list that finishes loading, a reconcile sweep) that
136
+ * no user interaction triggered. Returns an unsubscribe handle.
137
+ */
138
+ readonly subscribe: (listener: () => void) => () => void
139
+ /** Tear down the runtime and its forked fibers. */
140
+ readonly dispose: () => Promise<void>
141
+ }
142
+
143
+ export const makeRemoteServer = (scene: Scene): RemoteServer => {
144
+ const sink = makeSink()
145
+ const runtime = ManagedRuntime.make(
146
+ Layer.provideMerge(closedSceneLayer(scene), Layer.succeed(CaptureSink, sink.api)),
147
+ )
148
+ const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
149
+
150
+ const collect = (
151
+ root: CompositionClass<unknown>,
152
+ ): Effect.Effect<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> =>
153
+ Effect.gen(function* () {
154
+ const bindings = new Map<SlotClass, CompositionClass<unknown>>()
155
+ const walk = (comp: CompositionClass<unknown>): Effect.Effect<void, never, SlotChild> =>
156
+ Effect.gen(function* () {
157
+ for (const slotClass of Object.values(comp.manifest.slots ?? {})) {
158
+ if (bindings.has(slotClass)) continue
159
+ const child = childComposition(yield* slotClass.tag)
160
+ bindings.set(slotClass, child)
161
+ yield* walk(child)
162
+ }
163
+ })
164
+ yield* walk(root)
165
+ return bindings
166
+ })
167
+
168
+ const toWireNode = (m: Mounted, capture: UiCapture): Effect.Effect<WireNode, ParseResult.ParseError> =>
169
+ Effect.gen(function* () {
170
+ // The composition's contract carries the wire schemas at runtime (the type is
171
+ // the erased `{ manifest }` carrier — `composition.ts:34`); read them as `UiManifest`.
172
+ const manifest = m.comp.manifest.ui.manifest as UiManifest
173
+
174
+ const dataProps: WireProp[] = []
175
+ if (manifest.props !== undefined) {
176
+ const encoded = yield* Schema.encodeUnknown(manifest.props)(capture.props)
177
+ for (const [name, value] of Object.entries(encoded as Record<string, unknown>)) {
178
+ dataProps.push({ _tag: 'Data', name, value })
179
+ }
180
+ }
181
+
182
+ const eventSchemas = manifest.events ?? {}
183
+ const eventProps: WireProp[] = []
184
+ for (const [name, trigger] of Object.entries(capture.events)) {
185
+ const schema = eventSchemas[name]
186
+ if (schema === undefined) continue
187
+ const handle = `${m.id}:${name}`
188
+ yield* registry.register(handle, trigger, schema)
189
+ eventProps.push({ _tag: 'Event', name, handle })
190
+ }
191
+
192
+ return {
193
+ id: m.id,
194
+ name: capture.name,
195
+ parentId: m.parentId,
196
+ childIndex: m.childIndex,
197
+ slot: m.slot,
198
+ key: m.key,
199
+ props: [...dataProps, ...eventProps],
200
+ }
201
+ })
202
+
203
+ const renderLevel = (
204
+ frontier: ReadonlyArray<Mounted>,
205
+ bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
206
+ ): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> =>
207
+ Effect.gen(function* () {
208
+ if (frontier.length === 0) return []
209
+ const next: Mounted[] = []
210
+ const nodes: WireNode[] = []
211
+ for (const mounted of frontier) {
212
+ const service = yield* mounted.comp.tag
213
+ const slotIndex = new Map<string, number>()
214
+ const enqueueBy = new Map<Function, (childProps: unknown, key: string | null) => void>()
215
+ const slots: SlotHost = {
216
+ slot: (name) => {
217
+ const slotClass = mounted.comp.manifest.slots?.[name]
218
+ const child = slotClass ? bindings.get(slotClass) : undefined
219
+ const component: (childProps: unknown) => Node = () => null
220
+ if (child !== undefined) {
221
+ enqueueBy.set(component, (childProps, key) => {
222
+ const index = slotIndex.get(name) ?? 0
223
+ slotIndex.set(name, index + 1)
224
+ next.push({
225
+ comp: child,
226
+ props: childProps,
227
+ id: `${mounted.id}.${name}.${index}`,
228
+ parentId: mounted.id,
229
+ slot: name,
230
+ childIndex: index,
231
+ key,
232
+ })
233
+ })
234
+ }
235
+ return component
236
+ },
237
+ }
238
+ const before = sink.captures.length
239
+ const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} }, slots }
240
+ const node = yield* Composition.render(service, env)
241
+ const capture = sink.captures[before]
242
+ if (capture !== undefined) nodes.push(yield* toWireNode(mounted, capture))
243
+ drive(node, enqueueBy)
244
+ }
245
+ const rest = yield* renderLevel(next, bindings)
246
+ return [...nodes, ...rest]
247
+ })
248
+
249
+ const renderEffect = Effect.gen(function* () {
250
+ yield* settleDrain
251
+ sink.reset()
252
+ const bindings = yield* collect(scene.composition)
253
+ const root: Mounted = {
254
+ comp: scene.composition,
255
+ props: {},
256
+ id: '0',
257
+ parentId: null,
258
+ slot: null,
259
+ childIndex: 0,
260
+ key: null,
261
+ }
262
+ return yield* renderLevel([root], bindings)
263
+ })
264
+
265
+ // Server-initiated change notification: a forked daemon subscribes to the engine bus
266
+ // and, after each batch settles, fans out to registered listeners. This is what lets a
267
+ // streaming binding push frames the client never asked for — a background load resolving
268
+ // long after connect. Forked BEFORE the boot publish below so nothing is missed (the
269
+ // listener set is empty until `subscribe` runs, so early ticks are harmless no-ops; the
270
+ // binding's opening snapshot captures all state up to `start`).
271
+ const changeListeners = new Set<() => void>()
272
+ const notifyChange = (): void => {
273
+ for (const listener of changeListeners) listener()
274
+ }
275
+ runtime.runFork(
276
+ Effect.scoped(
277
+ Effect.gen(function* () {
278
+ const bus = yield* Bus
279
+ const subscription = yield* PubSub.subscribe(bus)
280
+ // Notify on each event; the consumer (`serve`) debounces, so the render it triggers
281
+ // runs a tick later — after the drain has folded this event into state. No settle
282
+ // here: it would only perturb the drain's own scheduling for no benefit.
283
+ yield* Queue.take(subscription).pipe(
284
+ Effect.zipRight(Effect.sync(notifyChange)),
285
+ Effect.forever,
286
+ )
287
+ }),
288
+ ),
289
+ )
290
+
291
+ runtime.runSync(Effect.forEach(scene.boot ?? [], (event) => publish('High', event)))
292
+
293
+ // The last emitted frame, to diff against. A const holder whose field we swap
294
+ // (no reassigned binding), matching the sink's immutable-swap idiom.
295
+ const frame: { tree: WireTree } = { tree: [] }
296
+
297
+ const render = (): Promise<WireTree> =>
298
+ runtime.runPromise(renderEffect).then((tree) => {
299
+ frame.tree = tree
300
+ return tree
301
+ })
302
+
303
+ const renderDiff = (): Promise<ReadonlyArray<WirePatch>> =>
304
+ runtime.runPromise(renderEffect).then((next) => {
305
+ const previous = frame.tree
306
+ const patches = Wire.diff(previous, next)
307
+ frame.tree = next
308
+ const nextIds = new Set(next.map((node) => node.id))
309
+ const staleHandles = previous
310
+ .filter((node) => !nextIds.has(node.id))
311
+ .flatMap(handlesOf)
312
+ return runtime
313
+ .runPromise(Effect.forEach(staleHandles, (handle) => registry.revoke(handle)))
314
+ .then(() => patches)
315
+ })
316
+
317
+ return {
318
+ render,
319
+ renderDiff,
320
+ invoke: (handle, encodedPayload) =>
321
+ runtime.runPromise(
322
+ Effect.gen(function* () {
323
+ yield* registry.invoke(handle, encodedPayload)
324
+ yield* settleDrain
325
+ }),
326
+ ),
327
+ subscribe: (listener) => {
328
+ changeListeners.add(listener)
329
+ return () => void changeListeners.delete(listener)
330
+ },
331
+ dispose: () => runtime.dispose(),
332
+ }
333
+ }