@playfast/reform-remote 0.0.4 → 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/index.ts +1 -1
- package/src/server.ts +198 -166
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/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.ts
CHANGED
|
@@ -34,6 +34,11 @@ import {
|
|
|
34
34
|
* evaluates a view. The render walk is breadth-first over the structure tree,
|
|
35
35
|
* assigning stable ids and encoding props via each contract's own wire schema
|
|
36
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.
|
|
37
42
|
*/
|
|
38
43
|
|
|
39
44
|
// The runtime services a closed scene exposes (mirrors proof's erasure boundary:
|
|
@@ -77,188 +82,181 @@ const childComposition = (child: SlotChild): CompositionClass<unknown> =>
|
|
|
77
82
|
const handlesOf = (node: WireNode): ReadonlyArray<string> =>
|
|
78
83
|
node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
}
|
|
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
|
|
103
89
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
107
103
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
}
|
|
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 })
|
|
144
109
|
}
|
|
110
|
+
}
|
|
145
111
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
}
|
|
156
122
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
+
})
|
|
167
133
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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')
|
|
177
143
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
)
|
|
186
152
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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) => {
|
|
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) => {
|
|
212
166
|
next.push({
|
|
213
167
|
comp: child,
|
|
214
|
-
props:
|
|
215
|
-
id: `${parent.id}.${slotName}
|
|
168
|
+
props: item.props,
|
|
169
|
+
id: `${parent.id}.${slotName}.${index}`,
|
|
216
170
|
parentId: parent.id,
|
|
217
171
|
slot: slotName,
|
|
218
|
-
childIndex:
|
|
219
|
-
key:
|
|
172
|
+
childIndex: index,
|
|
173
|
+
key: item.key,
|
|
220
174
|
})
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
)
|
|
225
191
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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)
|
|
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)
|
|
254
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
|
+
)
|
|
255
230
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
+
})
|
|
259
244
|
|
|
260
|
-
|
|
261
|
-
|
|
245
|
+
/**
|
|
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.
|
|
253
|
+
*/
|
|
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
|
|
262
260
|
const bindings = yield* collect(scene.composition)
|
|
263
261
|
const root: Mounted = {
|
|
264
262
|
comp: scene.composition,
|
|
@@ -269,9 +267,43 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
269
267
|
childIndex: 0,
|
|
270
268
|
key: null,
|
|
271
269
|
}
|
|
272
|
-
return yield* renderLevel([root], bindings)
|
|
270
|
+
return yield* renderLevel([root], bindings, register)
|
|
273
271
|
})
|
|
274
272
|
|
|
273
|
+
export interface RemoteServer {
|
|
274
|
+
/** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
|
|
275
|
+
readonly render: () => Promise<WireTree>
|
|
276
|
+
/**
|
|
277
|
+
* Render and return only the patches since the previous frame (the streaming
|
|
278
|
+
* form). Handles of deleted nodes are revoked, so a stale client invocation
|
|
279
|
+
* fails cleanly rather than firing a dangling trigger.
|
|
280
|
+
*/
|
|
281
|
+
readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
|
|
282
|
+
/** Fire a trigger the client referenced by handle, then settle the runtime. */
|
|
283
|
+
readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>
|
|
284
|
+
/**
|
|
285
|
+
* Observe SERVER-INITIATED state changes. The listener fires (after the drain
|
|
286
|
+
* settles) whenever an event flows on the engine bus — i.e. when an async procedure
|
|
287
|
+
* resolves, a boot loader completes, or a scheduler ticks — NOT just in response to a
|
|
288
|
+
* client invoke. `serve` registers a `renderDiff`-and-push listener here so the client
|
|
289
|
+
* sees background updates (a repo list that finishes loading, a reconcile sweep) that
|
|
290
|
+
* no user interaction triggered. Returns an unsubscribe handle.
|
|
291
|
+
*/
|
|
292
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
293
|
+
/** Tear down the runtime and its forked fibers. */
|
|
294
|
+
readonly dispose: () => Promise<void>
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
298
|
+
const runtime = ManagedRuntime.make(closedSceneLayer(scene))
|
|
299
|
+
const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
|
|
300
|
+
|
|
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
|
+
)
|
|
306
|
+
|
|
275
307
|
// Server-initiated change notification: a forked daemon subscribes to the engine bus
|
|
276
308
|
// and, after each batch settles, fans out to registered listeners. This is what lets a
|
|
277
309
|
// streaming binding push frames the client never asked for — a background load resolving
|