@playfast/reform-remote 0.0.3 → 0.0.5
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 +1 -1
- package/src/client.ts +42 -37
- package/src/fixtures.ts +46 -16
- package/src/index.ts +1 -1
- package/src/server.test.ts +22 -0
- package/src/server.ts +217 -175
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-remote",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Run a reform scene's logic on the server and stream its rendered UI to a thin client over any duplex transport.",
|
|
7
7
|
"keywords": [
|
package/src/client.ts
CHANGED
|
@@ -227,47 +227,52 @@ const WireNodeView = ({
|
|
|
227
227
|
? encoded
|
|
228
228
|
: Schema.decodeUnknownSync(registered.propsSchema)(encoded)
|
|
229
229
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
|
|
243
|
-
// `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
|
|
244
|
-
// singleton slot rendered once, e.g. `<slots.Create/>`).
|
|
230
|
+
// A slot resolves to a COMPONENT rendering that slot's wire children — usable as
|
|
231
|
+
// `<slots.Foo/>` (Ui.make convention) or `slots.Foo()` (thunk convention). The function is
|
|
232
|
+
// cached so its identity is STABLE across renders (see `slotCache` above); it reads
|
|
233
|
+
// `latest.current` so each invocation renders against the current tree/config.
|
|
234
|
+
//
|
|
235
|
+
// KEYED SLOTS: when the caller passes `slotKey` (`<slots.Row slotKey={id} />`), render only
|
|
236
|
+
// the ONE wire child whose `key` matches — the per-item identity the server captured from
|
|
237
|
+
// the parent's React `key` (see WireNode.key). This is what lets a LIST slot be invoked once
|
|
238
|
+
// per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
|
|
239
|
+
// `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
|
|
240
|
+
// singleton slot rendered once, e.g. `<slots.Create/>`).
|
|
241
|
+
const slotFor = (slotName: string): ((slotProps?: { readonly slotKey?: string }) => ReactNode) => {
|
|
245
242
|
const cached = slotCache.current[slotName]
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
})
|
|
243
|
+
if (cached !== undefined) return cached
|
|
244
|
+
const stable = (slotProps?: { readonly slotKey?: string }): ReactNode => {
|
|
245
|
+
const { node: currentNode, tree: currentTree, config: currentConfig } = latest.current
|
|
246
|
+
const requestedKey = slotProps?.slotKey
|
|
247
|
+
return createElement(
|
|
248
|
+
Fragment,
|
|
249
|
+
null,
|
|
250
|
+
...Wire.childrenOf(currentTree, currentNode.id)
|
|
251
|
+
.filter((child) => child.slot === slotName)
|
|
252
|
+
.filter((child) => requestedKey === undefined || child.key === requestedKey)
|
|
253
|
+
.map((child) =>
|
|
254
|
+
createElement(WireNodeView, {
|
|
255
|
+
key: child.id,
|
|
256
|
+
node: child,
|
|
257
|
+
tree: currentTree,
|
|
258
|
+
config: currentConfig,
|
|
259
|
+
}),
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
}
|
|
267
263
|
slotCache.current[slotName] = stable
|
|
268
|
-
|
|
264
|
+
return stable
|
|
269
265
|
}
|
|
270
266
|
|
|
267
|
+
// Resolve slots LAZILY by name: a view referencing `<slots.Item/>` when that slot has no
|
|
268
|
+
// wire children this frame (e.g. an empty list) gets a component that renders nothing,
|
|
269
|
+
// rather than `undefined` (which React rejects as an invalid element type). Mirrors the
|
|
270
|
+
// engine's total slot proxies — every declared slot is always callable.
|
|
271
|
+
const slots: Record<string, (slotProps?: { readonly slotKey?: string }) => ReactNode> = new Proxy(
|
|
272
|
+
Object.create(null),
|
|
273
|
+
{ get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined) },
|
|
274
|
+
)
|
|
275
|
+
|
|
271
276
|
return registered.view(props, slots, events)
|
|
272
277
|
}
|
|
273
278
|
|
package/src/fixtures.ts
CHANGED
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
StateGroup,
|
|
16
16
|
Ui,
|
|
17
17
|
type WiredUi,
|
|
18
|
+
each,
|
|
19
|
+
mount,
|
|
20
|
+
one,
|
|
18
21
|
provide,
|
|
19
22
|
scene,
|
|
20
23
|
slot,
|
|
@@ -25,10 +28,10 @@ import {
|
|
|
25
28
|
* Shared scenes the remote-transport tests render server-side. Each is a closed
|
|
26
29
|
* `Scene` wired the canonical way — compositions + reducers in `Layer.mergeAll`,
|
|
27
30
|
* then `provideMerge(presentation)` (views + seeded state) and `provideMerge(Engine)`
|
|
28
|
-
* — so they exercise the real runtime, not a stub. The
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
31
|
+
* — so they exercise the real runtime, not a stub. The compositions return
|
|
32
|
+
* headless structures; the server expands slot fills from those structures and the
|
|
33
|
+
* client renders the resulting wire tree with its own `RemoteView`s (thunk slots),
|
|
34
|
+
* which the transport tests supply separately.
|
|
32
35
|
*/
|
|
33
36
|
|
|
34
37
|
// ── counter: a flat composition with own state + one event ────────────────────
|
|
@@ -60,13 +63,33 @@ export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number
|
|
|
60
63
|
Composition.live(Counter, function* () {
|
|
61
64
|
const count = yield* StateGroup.select(Counters, 'count')
|
|
62
65
|
const bump = yield* Event.trigger(Bumped)
|
|
63
|
-
return (
|
|
66
|
+
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
64
67
|
}),
|
|
65
68
|
Reducer.live(Bump, (n, event) => n + event.by),
|
|
66
69
|
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
67
70
|
return scene(Counter, boot === undefined ? { provide: [app] } : { provide: [app], boot })
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
// ── structure counter: kept as explicit structure-event coverage. The `bump`
|
|
74
|
+
// trigger rides ON the structure, so the server reads it from `structure.events`,
|
|
75
|
+
// registers the handle, and produces the same wire shape as a legacy view body.
|
|
76
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
export const structureCounterScene = (): Scene => {
|
|
78
|
+
const presentation = Layer.mergeAll(
|
|
79
|
+
provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
|
|
80
|
+
StateGroup.live(Counters, { count: 0 }),
|
|
81
|
+
)
|
|
82
|
+
const app = Layer.mergeAll(
|
|
83
|
+
Composition.live(Counter, function* () {
|
|
84
|
+
const count = yield* StateGroup.select(Counters, 'count')
|
|
85
|
+
const bump = yield* Event.trigger(Bumped)
|
|
86
|
+
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
87
|
+
}),
|
|
88
|
+
Reducer.live(Bump, (n, event) => n + event.by),
|
|
89
|
+
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
90
|
+
return scene(Counter, { provide: [app] })
|
|
91
|
+
}
|
|
92
|
+
|
|
70
93
|
// ── option: a flat composition whose prop is an `Option` (an Effect-native, non-JSON
|
|
71
94
|
// value) — exercises the symmetric schema encode/decode so the client receives a REAL
|
|
72
95
|
// `Option`, not its raw `{_tag,value}` wire shape. ──────────────────────────────────
|
|
@@ -86,7 +109,7 @@ export const optionScene = (initial: Option.Option<string> = Option.some('hi')):
|
|
|
86
109
|
const app = Layer.mergeAll(
|
|
87
110
|
Composition.live(Optional, function* () {
|
|
88
111
|
const label = yield* StateGroup.select(Labels, 'label')
|
|
89
|
-
return (
|
|
112
|
+
return mount({ props: { label }, slots: {} })
|
|
90
113
|
}),
|
|
91
114
|
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
92
115
|
return scene(Optional, { provide: [app] })
|
|
@@ -117,15 +140,14 @@ export const slottedScene = (): Scene => {
|
|
|
117
140
|
)
|
|
118
141
|
const app = Layer.mergeAll(
|
|
119
142
|
Composition.live(Shell, function* () {
|
|
120
|
-
|
|
121
|
-
return view({})
|
|
143
|
+
return mount({ props: {}, slots: { Main: one({}) } })
|
|
122
144
|
}),
|
|
123
145
|
Composition.live(Panel, function* () {
|
|
124
146
|
const greeting = yield* Greeting
|
|
125
147
|
const greet = yield* Event.trigger(Greeted)
|
|
126
148
|
const clearTrigger = yield* Event.trigger(Cleared)
|
|
127
149
|
const clear = (): void => clearTrigger({})
|
|
128
|
-
return (
|
|
150
|
+
return mount({ props: { greeting }, slots: {}, events: { greet, clear } })
|
|
129
151
|
}),
|
|
130
152
|
Reducer.live(SetGreeting, (_greeting, event) => event.text),
|
|
131
153
|
Reducer.live(ClearGreeting, () => ''),
|
|
@@ -171,12 +193,12 @@ export const listScene = (
|
|
|
171
193
|
const presentation = Layer.mergeAll(
|
|
172
194
|
provide(
|
|
173
195
|
ListUi,
|
|
174
|
-
Ui.make(ListUi, (
|
|
196
|
+
Ui.make(ListUi, (_props, slots) =>
|
|
175
197
|
createElement(
|
|
176
198
|
Fragment,
|
|
177
199
|
null,
|
|
178
200
|
createElement(slots.Bar, {}),
|
|
179
|
-
|
|
201
|
+
createElement(slots.Item, {}),
|
|
180
202
|
),
|
|
181
203
|
),
|
|
182
204
|
),
|
|
@@ -189,12 +211,20 @@ export const listScene = (
|
|
|
189
211
|
const app = Layer.mergeAll(
|
|
190
212
|
Composition.live(ListComp, function* () {
|
|
191
213
|
const items = yield* Items
|
|
192
|
-
|
|
193
|
-
|
|
214
|
+
return mount({
|
|
215
|
+
props: { items },
|
|
216
|
+
slots: {
|
|
217
|
+
Bar: one({}),
|
|
218
|
+
Item: each(items, {
|
|
219
|
+
key: (item) => item.id,
|
|
220
|
+
props: (item) => ({ id: item.id, label: item.label }),
|
|
221
|
+
}),
|
|
222
|
+
},
|
|
223
|
+
})
|
|
194
224
|
}),
|
|
195
225
|
Composition.live(Bar, function* () {
|
|
196
226
|
const add = yield* Event.trigger(Added)
|
|
197
|
-
return (
|
|
227
|
+
return mount({ props: {}, slots: {}, events: { add } })
|
|
198
228
|
}),
|
|
199
229
|
Composition.live(ItemComp, function* () {
|
|
200
230
|
const item = (yield* Props) as { id: string; label: string }
|
|
@@ -202,7 +232,7 @@ export const listScene = (
|
|
|
202
232
|
// The item binds its own id, so the wire `remove` carries no payload — the
|
|
203
233
|
// client fires it knowing only the handle.
|
|
204
234
|
const remove = (): void => removeTrigger({ id: item.id })
|
|
205
|
-
return (
|
|
235
|
+
return mount({ props: { label: item.label }, slots: {}, events: { remove } })
|
|
206
236
|
}),
|
|
207
237
|
Reducer.live(AddItem, (items, event) => [...items, { id: event.id, label: event.label }]),
|
|
208
238
|
Reducer.live(RemoveItem, (items, event) => items.filter((item) => item.id !== event.id)),
|
|
@@ -228,7 +258,7 @@ export const asyncCounterScene = (delay: Duration.DurationInput = '40 millis'):
|
|
|
228
258
|
Composition.live(Counter, function* () {
|
|
229
259
|
const count = yield* StateGroup.select(Counters, 'count')
|
|
230
260
|
const bump = yield* Event.trigger(Bumped)
|
|
231
|
-
return (
|
|
261
|
+
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
232
262
|
}),
|
|
233
263
|
Reducer.live(Bump, (n, event) => n + event.by),
|
|
234
264
|
// The procedure runs on the bus AFTER the scene boots: it sleeps past the opening
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ export * as Transport from './transport'
|
|
|
14
14
|
export * as Memory from './memory'
|
|
15
15
|
export * as React from './react'
|
|
16
16
|
|
|
17
|
-
export { makeRemoteServer, type RemoteServer } from './server'
|
|
17
|
+
export { makeRemoteServer, type RemoteServer, renderSceneToWire } from './server'
|
|
18
18
|
export {
|
|
19
19
|
type ClientConfig,
|
|
20
20
|
type RegisteredRemoteView,
|
package/src/server.test.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
OptionalUi,
|
|
15
15
|
optionScene,
|
|
16
16
|
slottedScene,
|
|
17
|
+
structureCounterScene,
|
|
17
18
|
} from './fixtures'
|
|
18
19
|
|
|
19
20
|
// The client renders views as React components, so a test runs them by RENDERING
|
|
@@ -65,6 +66,27 @@ test('invoking a trigger handle dispatches into the runtime and the next render
|
|
|
65
66
|
}
|
|
66
67
|
})
|
|
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
|
+
|
|
68
90
|
test('the wire payload is validated at the seam — a bad payload rejects', async () => {
|
|
69
91
|
const server = makeRemoteServer(counterScene())
|
|
70
92
|
try {
|
package/src/server.ts
CHANGED
|
@@ -1,24 +1,23 @@
|
|
|
1
|
-
import { Effect, Layer, ManagedRuntime, type ParseResult, PubSub, Queue, Schema } from 'effect'
|
|
2
|
-
import { isValidElement } from 'react'
|
|
1
|
+
import { Effect, Layer, Match, ManagedRuntime, type ParseResult, PubSub, Queue, Schema } from 'effect'
|
|
3
2
|
import {
|
|
4
3
|
Bus,
|
|
5
|
-
CaptureSink,
|
|
6
|
-
type CaptureSinkApi,
|
|
7
4
|
Composition,
|
|
8
5
|
type CompositionClass,
|
|
9
6
|
type CompositionService,
|
|
10
7
|
forceSync,
|
|
11
8
|
isFeatureBinding,
|
|
12
|
-
|
|
9
|
+
isStructure,
|
|
13
10
|
publish,
|
|
14
11
|
type RenderEnv,
|
|
15
12
|
type Scene,
|
|
16
13
|
type SlotChild,
|
|
17
14
|
type SlotClass,
|
|
18
|
-
type
|
|
15
|
+
type SlotFill,
|
|
16
|
+
type Structure,
|
|
19
17
|
Triggers,
|
|
18
|
+
type Trigger,
|
|
20
19
|
type TriggerRegistryApi,
|
|
21
|
-
type
|
|
20
|
+
type UiContract,
|
|
22
21
|
type UiManifest,
|
|
23
22
|
Wire,
|
|
24
23
|
type WireNode,
|
|
@@ -30,11 +29,16 @@ import {
|
|
|
30
29
|
/**
|
|
31
30
|
* The server side of the remote transport (REMOTE_UI.md §5): render a closed
|
|
32
31
|
* `Scene` to a serializable `WireTree`, registering each trigger behind a handle.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
+
* The render walk (`renderSceneToWire`) is a free-standing effect that requires only
|
|
39
|
+
* `CompositionService | SlotChild` — so any runtime that supplies a scene's services
|
|
40
|
+
* can render a frame, not just `makeRemoteServer`'s own. `@playfast/reform-driver-shot`
|
|
41
|
+
* runs it against `@playfast/reform-drive`'s runtime to screenshot a driven state.
|
|
38
42
|
*/
|
|
39
43
|
|
|
40
44
|
// The runtime services a closed scene exposes (mirrors proof's erasure boundary:
|
|
@@ -45,24 +49,13 @@ type RuntimeServices = CompositionService | SlotChild | Bus
|
|
|
45
49
|
const SETTLE_DRAIN = 30
|
|
46
50
|
const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
}
|
|
52
|
+
// The event triggers a `Structure` frame carries (plan 03b). A composition's logic
|
|
53
|
+
// returns its acquired triggers ON the structure value (it never evaluates a view),
|
|
54
|
+
// so the server reads them straight from there. At the erased `UiContract` boundary the contract's event map
|
|
55
|
+
// is `Record<never, never>`, so each value is `never` — assignable to
|
|
56
|
+
// `Trigger<unknown>` without a cast — and an omitted `events` defaults to empty.
|
|
57
|
+
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
|
|
58
|
+
Object.fromEntries(Object.entries(structure.events ?? {}))
|
|
66
59
|
|
|
67
60
|
const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
|
|
68
61
|
scene.provide.reduce((a, b) => Layer.merge(a, b)) as unknown as Layer.Layer<
|
|
@@ -86,35 +79,196 @@ interface Mounted {
|
|
|
86
79
|
const childComposition = (child: SlotChild): CompositionClass<unknown> =>
|
|
87
80
|
isFeatureBinding(child) ? child.composition : child
|
|
88
81
|
|
|
82
|
+
const handlesOf = (node: WireNode): ReadonlyArray<string> =>
|
|
83
|
+
node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
|
|
84
|
+
|
|
85
|
+
// A static render (a screenshot) has no client to receive trigger handles, so the
|
|
86
|
+
// default registrar is a no-op: the wire node still carries its `Event` props (with
|
|
87
|
+
// deterministic handles), they're just never registered for invocation.
|
|
88
|
+
const noRegister: TriggerRegistryApi['register'] = () => Effect.void
|
|
89
|
+
|
|
90
|
+
// Structure → wire encoding (plan 02 + 03b). Props come straight from the
|
|
91
|
+
// `Structure` value (data, never a view render). Event triggers ride ON the
|
|
92
|
+
// structure (`structure.events`) and are registered through `register` (the live
|
|
93
|
+
// registry for a streaming server, a no-op for a one-shot render). Produces the
|
|
94
|
+
// wire shape the transport and `Wire.diff` consume.
|
|
95
|
+
const toWireNodeFromStructure = (
|
|
96
|
+
m: Mounted,
|
|
97
|
+
structure: Structure<UiContract>,
|
|
98
|
+
register: TriggerRegistryApi['register'],
|
|
99
|
+
): Effect.Effect<WireNode, ParseResult.ParseError> =>
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
const manifest = m.comp.manifest.ui.manifest as UiManifest
|
|
102
|
+
const name = manifest.name
|
|
103
|
+
|
|
104
|
+
const dataProps: WireProp[] = []
|
|
105
|
+
if (manifest.props !== undefined) {
|
|
106
|
+
const encoded = yield* Schema.encodeUnknown(manifest.props)(structure.props)
|
|
107
|
+
for (const [propName, value] of Object.entries(encoded as Record<string, unknown>)) {
|
|
108
|
+
dataProps.push({ _tag: 'Data', name: propName, value })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const eventSchemas = manifest.events ?? {}
|
|
113
|
+
const eventProps: WireProp[] = []
|
|
114
|
+
const events = eventsOf(structure)
|
|
115
|
+
for (const [eventName, trigger] of Object.entries(events)) {
|
|
116
|
+
const schema = eventSchemas[eventName]
|
|
117
|
+
if (schema === undefined) continue
|
|
118
|
+
const handle = `${m.id}:${eventName}`
|
|
119
|
+
yield* register(handle, trigger, schema)
|
|
120
|
+
eventProps.push({ _tag: 'Event', name: eventName, handle })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
id: m.id,
|
|
125
|
+
name,
|
|
126
|
+
parentId: m.parentId,
|
|
127
|
+
childIndex: m.childIndex,
|
|
128
|
+
slot: m.slot,
|
|
129
|
+
key: m.key,
|
|
130
|
+
props: [...dataProps, ...eventProps],
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
|
|
135
|
+
// UiContract>` erases its per-slot fill types at this boundary (the strict typing
|
|
136
|
+
// lives on the concrete contract), so the runtime fills arrive as `unknown` and
|
|
137
|
+
// are recovered structurally — no `as`.
|
|
138
|
+
const isSlotFill = (u: unknown): u is SlotFill<unknown> =>
|
|
139
|
+
typeof u === 'object' &&
|
|
140
|
+
u !== null &&
|
|
141
|
+
'_tag' in u &&
|
|
142
|
+
(u._tag === 'Each' || u._tag === 'One' || u._tag === 'Absent')
|
|
143
|
+
|
|
144
|
+
// Read a structure's fills by slot name, recovering each erased value via the
|
|
145
|
+
// discriminant guard. Returns a plain record keyed by declared slot name.
|
|
146
|
+
const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
|
|
147
|
+
Object.fromEntries(
|
|
148
|
+
Object.entries(structure.slots).flatMap(([name, value]) =>
|
|
149
|
+
isSlotFill(value) ? [[name, value] as const] : [],
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
// Enqueue the children a single declared slot is filled with, reading the fill
|
|
154
|
+
// (Each / One / Absent) from the returned `Structure`. Multiplicity, per-item
|
|
155
|
+
// `key`, and props are carried as data — no view walk, no reconstruction.
|
|
156
|
+
const enqueueStructureSlot = (
|
|
157
|
+
parent: Mounted,
|
|
158
|
+
slotName: string,
|
|
159
|
+
child: CompositionClass<unknown>,
|
|
160
|
+
fill: SlotFill<unknown>,
|
|
161
|
+
next: Mounted[],
|
|
162
|
+
): void =>
|
|
163
|
+
Match.value(fill).pipe(
|
|
164
|
+
Match.tag('Each', (each) => {
|
|
165
|
+
each.items.forEach((item, index) => {
|
|
166
|
+
next.push({
|
|
167
|
+
comp: child,
|
|
168
|
+
props: item.props,
|
|
169
|
+
id: `${parent.id}.${slotName}.${index}`,
|
|
170
|
+
parentId: parent.id,
|
|
171
|
+
slot: slotName,
|
|
172
|
+
childIndex: index,
|
|
173
|
+
key: item.key,
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
}),
|
|
177
|
+
Match.tag('One', (single) => {
|
|
178
|
+
next.push({
|
|
179
|
+
comp: child,
|
|
180
|
+
props: single.props,
|
|
181
|
+
id: `${parent.id}.${slotName}.0`,
|
|
182
|
+
parentId: parent.id,
|
|
183
|
+
slot: slotName,
|
|
184
|
+
childIndex: 0,
|
|
185
|
+
key: null,
|
|
186
|
+
})
|
|
187
|
+
}),
|
|
188
|
+
Match.tag('Absent', () => {}),
|
|
189
|
+
Match.exhaustive,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const collect = (
|
|
193
|
+
root: CompositionClass<unknown>,
|
|
194
|
+
): Effect.Effect<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> =>
|
|
195
|
+
Effect.gen(function* () {
|
|
196
|
+
const bindings = new Map<SlotClass, CompositionClass<unknown>>()
|
|
197
|
+
const walk = (comp: CompositionClass<unknown>): Effect.Effect<void, never, SlotChild> =>
|
|
198
|
+
Effect.gen(function* () {
|
|
199
|
+
for (const slotClass of Object.values(comp.manifest.slots ?? {})) {
|
|
200
|
+
if (bindings.has(slotClass)) continue
|
|
201
|
+
const child = childComposition(yield* slotClass.tag)
|
|
202
|
+
bindings.set(slotClass, child)
|
|
203
|
+
yield* walk(child)
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
yield* walk(root)
|
|
207
|
+
return bindings
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const renderLevel = (
|
|
211
|
+
frontier: ReadonlyArray<Mounted>,
|
|
212
|
+
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
213
|
+
register: TriggerRegistryApi['register'],
|
|
214
|
+
): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> =>
|
|
215
|
+
Effect.gen(function* () {
|
|
216
|
+
if (frontier.length === 0) return []
|
|
217
|
+
const next: Mounted[] = []
|
|
218
|
+
const nodes: WireNode[] = []
|
|
219
|
+
for (const mounted of frontier) {
|
|
220
|
+
const service = yield* mounted.comp.tag
|
|
221
|
+
const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
|
|
222
|
+
const frame = yield* Composition.render(service, env)
|
|
223
|
+
// Every composition returns a `Structure` (the Node render path is gone): its
|
|
224
|
+
// wire node's props come from `frame.props`, event triggers from `frame.events`,
|
|
225
|
+
// and children are enqueued by reading each declared slot's fill — no view eval.
|
|
226
|
+
if (!isStructure(frame)) {
|
|
227
|
+
return yield* Effect.dieMessage(
|
|
228
|
+
`reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
nodes.push(yield* toWireNodeFromStructure(mounted, frame, register))
|
|
232
|
+
const fills = structureFills(frame)
|
|
233
|
+
for (const [slotName, slotClass] of Object.entries(mounted.comp.manifest.slots ?? {})) {
|
|
234
|
+
const child = bindings.get(slotClass)
|
|
235
|
+
if (child === undefined) continue
|
|
236
|
+
const fill = fills[slotName]
|
|
237
|
+
if (fill === undefined) continue
|
|
238
|
+
enqueueStructureSlot(mounted, slotName, child, fill, next)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const rest = yield* renderLevel(next, bindings, register)
|
|
242
|
+
return [...nodes, ...rest]
|
|
243
|
+
})
|
|
244
|
+
|
|
89
245
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
246
|
+
* Render the scene's CURRENT frame to a serializable `WireTree`, breadth-first from
|
|
247
|
+
* the root composition. Requires only the scene's render services
|
|
248
|
+
* (`CompositionService | SlotChild`), so it runs on any runtime built from the same
|
|
249
|
+
* closed scene — `makeRemoteServer`'s, or `@playfast/reform-drive`'s (the screenshot
|
|
250
|
+
* path). `register` defaults to a no-op: a one-shot render needs no live trigger
|
|
251
|
+
* registry, while `makeRemoteServer` passes its connection registry so the streamed
|
|
252
|
+
* client can invoke handles.
|
|
93
253
|
*/
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
):
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
enqueue(node.props, node.key)
|
|
110
|
-
return
|
|
254
|
+
export const renderSceneToWire = (
|
|
255
|
+
scene: Scene,
|
|
256
|
+
options?: { readonly register?: TriggerRegistryApi['register'] },
|
|
257
|
+
): Effect.Effect<WireTree, ParseResult.ParseError, CompositionService | SlotChild> =>
|
|
258
|
+
Effect.gen(function* () {
|
|
259
|
+
const register = options?.register ?? noRegister
|
|
260
|
+
const bindings = yield* collect(scene.composition)
|
|
261
|
+
const root: Mounted = {
|
|
262
|
+
comp: scene.composition,
|
|
263
|
+
props: {},
|
|
264
|
+
id: '0',
|
|
265
|
+
parentId: null,
|
|
266
|
+
slot: null,
|
|
267
|
+
childIndex: 0,
|
|
268
|
+
key: null,
|
|
111
269
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const handlesOf = (node: WireNode): ReadonlyArray<string> =>
|
|
117
|
-
node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
|
|
270
|
+
return yield* renderLevel([root], bindings, register)
|
|
271
|
+
})
|
|
118
272
|
|
|
119
273
|
export interface RemoteServer {
|
|
120
274
|
/** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
|
|
@@ -141,126 +295,14 @@ export interface RemoteServer {
|
|
|
141
295
|
}
|
|
142
296
|
|
|
143
297
|
export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
144
|
-
const
|
|
145
|
-
const runtime = ManagedRuntime.make(
|
|
146
|
-
Layer.provideMerge(closedSceneLayer(scene), Layer.succeed(CaptureSink, sink.api)),
|
|
147
|
-
)
|
|
298
|
+
const runtime = ManagedRuntime.make(closedSceneLayer(scene))
|
|
148
299
|
const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
|
|
149
300
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
Effect.
|
|
154
|
-
|
|
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
|
-
})
|
|
301
|
+
// The shared render walk, registering triggers in this connection's registry so the
|
|
302
|
+
// client can invoke them. `settleDrain` first so an in-flight batch folds into state.
|
|
303
|
+
const renderEffect = settleDrain.pipe(
|
|
304
|
+
Effect.zipRight(renderSceneToWire(scene, { register: registry.register })),
|
|
305
|
+
)
|
|
264
306
|
|
|
265
307
|
// Server-initiated change notification: a forked daemon subscribes to the engine bus
|
|
266
308
|
// and, after each batch settles, fans out to registered listeners. This is what lets a
|
|
@@ -291,7 +333,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
291
333
|
runtime.runSync(Effect.forEach(scene.boot ?? [], (event) => publish('High', event)))
|
|
292
334
|
|
|
293
335
|
// The last emitted frame, to diff against. A const holder whose field we swap
|
|
294
|
-
// (no reassigned binding),
|
|
336
|
+
// (no reassigned binding — house rule), not a `let`.
|
|
295
337
|
const frame: { tree: WireTree } = { tree: [] }
|
|
296
338
|
|
|
297
339
|
const render = (): Promise<WireTree> =>
|