@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.
- package/package.json +2 -2
- package/src/client-keyed-slot.test.ts +136 -0
- package/src/client-remount.test.ts +127 -0
- package/src/client.ts +284 -0
- package/src/fixtures.ts +246 -0
- package/src/index.ts +49 -0
- package/src/memory.ts +36 -0
- package/src/react.ts +62 -0
- package/src/remote-view.typecheck.ts +75 -0
- package/src/server.test.ts +309 -0
- package/src/server.ts +333 -0
- package/src/transport.test.ts +205 -0
- package/src/transport.ts +192 -0
- package/dist/client.d.ts +0 -127
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -127
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts +0 -11
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -20
- package/dist/index.js.map +0 -1
- package/dist/memory.d.ts +0 -15
- package/dist/memory.d.ts.map +0 -1
- package/dist/memory.js +0 -21
- package/dist/memory.js.map +0 -1
- package/dist/react.d.ts +0 -38
- package/dist/react.d.ts.map +0 -1
- package/dist/react.js +0 -29
- package/dist/react.js.map +0 -1
- package/dist/server.d.ts +0 -26
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js +0 -206
- package/dist/server.js.map +0 -1
- package/dist/transport.d.ts +0 -69
- package/dist/transport.d.ts.map +0 -1
- package/dist/transport.js +0 -116
- package/dist/transport.js.map +0 -1
|
@@ -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
|
+
}
|