@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.
- 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 +276 -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 +331 -0
- package/src/server.ts +343 -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,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
|
+
}
|