@playfast/reform-remote 1.0.1 → 1.1.0
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/README.md +16 -16
- package/package.json +18 -18
- package/src/client-keyed-slot.test.ts +19 -28
- package/src/client-remount.test.ts +14 -28
- package/src/client.ts +73 -182
- package/src/fixtures.ts +268 -92
- package/src/index.ts +0 -10
- package/src/memory.ts +0 -9
- package/src/react.ts +1 -32
- package/src/remote-view.typecheck.ts +3 -17
- package/src/server.test.ts +28 -32
- package/src/server.ts +269 -195
- package/src/transport.test.ts +3 -26
- package/src/transport.ts +18 -96
package/src/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Array as Arr,
|
|
3
|
+
Context,
|
|
3
4
|
Effect,
|
|
4
5
|
Layer,
|
|
5
6
|
Match,
|
|
@@ -12,102 +13,71 @@ import {
|
|
|
12
13
|
Schema,
|
|
13
14
|
} from 'effect'
|
|
14
15
|
import {
|
|
15
|
-
|
|
16
|
+
type CapturedScene,
|
|
16
17
|
Composition,
|
|
17
18
|
type CompositionClass,
|
|
18
|
-
type CompositionService,
|
|
19
19
|
forceSync,
|
|
20
20
|
isFeatureBinding,
|
|
21
|
+
isSlot,
|
|
21
22
|
isStructure,
|
|
22
23
|
publish,
|
|
23
|
-
type RenderEnv,
|
|
24
|
-
type Scene,
|
|
25
|
-
type SlotChild,
|
|
26
|
-
type SlotClass,
|
|
27
24
|
type SlotFill,
|
|
28
25
|
type Structure,
|
|
29
|
-
Triggers,
|
|
30
26
|
type Trigger,
|
|
31
|
-
type TriggerRegistryApi,
|
|
32
27
|
type UiContract,
|
|
28
|
+
} from '@playfast/reform'
|
|
29
|
+
import {
|
|
30
|
+
type AnyComposition,
|
|
31
|
+
type AnyScene,
|
|
32
|
+
type AnySlot,
|
|
33
|
+
type AnySlotChild,
|
|
34
|
+
Bus,
|
|
35
|
+
Triggers,
|
|
36
|
+
type TriggerRegistryApi,
|
|
33
37
|
type UiManifest,
|
|
34
38
|
Wire,
|
|
35
39
|
type WireNode,
|
|
36
40
|
type WirePatch,
|
|
37
41
|
type WireProp,
|
|
38
42
|
type WireTree,
|
|
39
|
-
} from '@playfast/reform'
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The server side of the remote transport (REMOTE_UI.md §5): render a closed
|
|
43
|
-
* `Scene` to a serializable `WireTree`, registering each trigger behind a handle.
|
|
44
|
-
* Reads each composition's returned `Structure` value directly — props, per-slot
|
|
45
|
-
* fills, and event triggers are all DATA — so the server imports no React and never
|
|
46
|
-
* evaluates a view. The render walk is breadth-first over the structure tree,
|
|
47
|
-
* assigning stable ids and encoding props via each contract's own wire schema
|
|
48
|
-
* (`WiredUiManifest`, Phase 1).
|
|
49
|
-
*
|
|
50
|
-
* The render walk (`renderSceneToWire`) is a free-standing effect that requires only
|
|
51
|
-
* `CompositionService | SlotChild` — so any runtime that supplies a scene's services
|
|
52
|
-
* can render a frame, not just `makeRemoteServer`'s own. `@playfast/reform-driver-shot`
|
|
53
|
-
* runs it against `@playfast/reform-drive`'s runtime to screenshot a driven state.
|
|
54
|
-
*/
|
|
55
|
-
|
|
56
|
-
// The runtime services a closed scene exposes (mirrors proof's erasure boundary:
|
|
57
|
-
// the scene's `provide` is typed to `MountedServices`, but slot tags resolve
|
|
58
|
-
// `SlotChild` from the same closed layer).
|
|
59
|
-
type RuntimeServices = CompositionService | SlotChild | Bus
|
|
43
|
+
} from '@playfast/reform/internal'
|
|
60
44
|
|
|
61
45
|
const SETTLE_DRAIN = 30
|
|
62
46
|
const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
|
|
63
47
|
|
|
64
|
-
// The event
|
|
65
|
-
// returns its acquired triggers ON the structure value (it never evaluates a view),
|
|
66
|
-
// so the server reads them straight from there. At the erased `UiContract` boundary the contract's event map
|
|
67
|
-
// is `Record<never, never>`, so each value is `never` — assignable to
|
|
68
|
-
// `Trigger<unknown>` without a cast — and an omitted `events` defaults to empty.
|
|
48
|
+
// The erased event map's never values remain assignable to Trigger<unknown> without a cast.
|
|
69
49
|
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
|
|
70
50
|
Option.match(Option.fromNullable(structure.events), {
|
|
71
51
|
onNone: () => ({}),
|
|
72
52
|
onSome: (events) => events,
|
|
73
53
|
})
|
|
74
54
|
|
|
75
|
-
const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
|
|
76
|
-
// oxlint-disable-next-line reform-rules/no-type-assertion -- closed-scene erasure: scene.provide is typed to MountedServices, recovered structurally as RuntimeServices
|
|
77
|
-
scene.provide.reduce((acc, layer) => Layer.merge(acc, layer)) as unknown as Layer.Layer<
|
|
78
|
-
RuntimeServices,
|
|
79
|
-
never,
|
|
80
|
-
never
|
|
81
|
-
>
|
|
82
|
-
|
|
83
|
-
/** A composition queued to render, with the props its parent handed it and its place in the tree. */
|
|
84
55
|
interface Mounted {
|
|
85
|
-
readonly comp:
|
|
56
|
+
readonly comp: AnyComposition
|
|
86
57
|
readonly props: unknown
|
|
87
58
|
readonly id: string
|
|
88
59
|
readonly parentId: Option.Option<string>
|
|
89
60
|
readonly slot: Option.Option<string>
|
|
90
61
|
readonly childIndex: number
|
|
91
|
-
/** The React `key` the parent gave this slot child, if any — the keyed-slot selector. */
|
|
92
62
|
readonly key: Option.Option<string>
|
|
93
63
|
}
|
|
94
64
|
|
|
95
|
-
const childComposition = (child:
|
|
96
|
-
isFeatureBinding(child)
|
|
65
|
+
const childComposition = (child: AnySlotChild): AnyComposition => {
|
|
66
|
+
if (!isFeatureBinding(child)) {
|
|
67
|
+
return child
|
|
68
|
+
}
|
|
69
|
+
return child.capture<AnyComposition>((binding): AnyComposition => binding.composition)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
73
|
+
typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)
|
|
97
74
|
|
|
98
75
|
const handlesOf = (node: WireNode): ReadonlyArray<string> =>
|
|
99
76
|
node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
|
|
100
77
|
|
|
101
|
-
//
|
|
102
|
-
// default registrar is a no-op: the wire node still carries its `Event` props (with
|
|
103
|
-
// deterministic handles), they're just never registered for invocation.
|
|
78
|
+
// One-shot renders still emit deterministic handles but have no client registry.
|
|
104
79
|
const noRegister: TriggerRegistryApi['register'] = () => Effect.void
|
|
105
80
|
|
|
106
|
-
// Structure → wire encoding (plan 02 + 03b). Props come straight from the
|
|
107
|
-
// `Structure` value (data, never a view render). Event triggers ride ON the
|
|
108
|
-
// structure (`structure.events`) and are registered through `register` (the live
|
|
109
|
-
// registry for a streaming server, a no-op for a one-shot render). Produces the
|
|
110
|
-
// wire shape the transport and `Wire.diff` consume.
|
|
111
81
|
const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
|
|
112
82
|
mounted: Mounted,
|
|
113
83
|
structure: Structure<UiContract>,
|
|
@@ -116,28 +86,47 @@ const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
|
|
|
116
86
|
const manifest: UiManifest = mounted.comp.manifest.ui.manifest
|
|
117
87
|
const name = manifest.name
|
|
118
88
|
|
|
119
|
-
//
|
|
120
|
-
// of wire-encoded prop values keyed by name (no view eval). Absent on the type-only form.
|
|
89
|
+
// The manifest stores schema AST only; reconstruct an unknown-safe schema at this wire seam.
|
|
121
90
|
const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
|
|
122
91
|
onNone: () => Effect.succeed({}),
|
|
123
|
-
onSome: (
|
|
92
|
+
onSome: (reflection) =>
|
|
93
|
+
Schema.encodeUnknown(Schema.make<unknown, unknown>(reflection.ast))(structure.props).pipe(
|
|
94
|
+
Effect.flatMap((encoded) => {
|
|
95
|
+
if (isRecord(encoded)) {
|
|
96
|
+
return Effect.succeed(encoded)
|
|
97
|
+
}
|
|
98
|
+
return Effect.dieMessage(
|
|
99
|
+
`reform-remote: ${manifest.name} props did not encode to a record`,
|
|
100
|
+
)
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
124
103
|
})
|
|
125
104
|
const dataProps: ReadonlyArray<WireProp> = Rec.toEntries(encodedProps).map(
|
|
126
|
-
([propName, propValue]): WireProp => ({
|
|
105
|
+
([propName, propValue]): WireProp => ({
|
|
106
|
+
_tag: 'Data',
|
|
107
|
+
name: propName,
|
|
108
|
+
value: propValue,
|
|
109
|
+
}),
|
|
127
110
|
)
|
|
128
111
|
|
|
129
112
|
const eventSchemas = Option.fromNullable(manifest.events)
|
|
130
113
|
const events = eventsOf(structure)
|
|
131
|
-
const eventProps: ReadonlyArray<WireProp> = yield* Effect.forEach(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
114
|
+
const eventProps: ReadonlyArray<WireProp> = yield* Effect.forEach(
|
|
115
|
+
Rec.toEntries(events),
|
|
116
|
+
([eventName, trigger]) =>
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
const schema = Option.flatMap(eventSchemas, (schemas) => Rec.get(schemas, eventName))
|
|
119
|
+
if (Option.isNone(schema)) {
|
|
120
|
+
return Option.none<WireProp>()
|
|
121
|
+
}
|
|
122
|
+
const handle = `${mounted.id}:${eventName}`
|
|
123
|
+
yield* register(handle, trigger, Schema.make<unknown, unknown>(schema.value.ast))
|
|
124
|
+
return Option.some<WireProp>({
|
|
125
|
+
_tag: 'Event',
|
|
126
|
+
name: eventName,
|
|
127
|
+
handle,
|
|
128
|
+
})
|
|
129
|
+
}),
|
|
141
130
|
).pipe(Effect.map(Arr.getSomes))
|
|
142
131
|
|
|
143
132
|
return {
|
|
@@ -151,36 +140,102 @@ const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
|
|
|
151
140
|
}
|
|
152
141
|
})
|
|
153
142
|
|
|
154
|
-
//
|
|
155
|
-
// UiContract>` erases its per-slot fill types at this boundary (the strict typing
|
|
156
|
-
// lives on the concrete contract), so the runtime fills arrive as `unknown` and
|
|
157
|
-
// are recovered structurally — no `as`.
|
|
143
|
+
// Structure erases per-slot fill types; recover SlotFill structurally without `as`.
|
|
158
144
|
const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
|
|
159
145
|
typeof candidate === 'object' &&
|
|
160
146
|
candidate !== null &&
|
|
161
147
|
'_tag' in candidate &&
|
|
162
148
|
(candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
|
|
163
149
|
|
|
164
|
-
// Read a structure's fills by slot name, recovering each erased value via the
|
|
165
|
-
// discriminant guard. Returns a plain record keyed by declared slot name.
|
|
166
150
|
const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
|
|
167
151
|
Rec.fromEntries(
|
|
168
|
-
Rec.toEntries(structure.slots).flatMap(
|
|
169
|
-
|
|
152
|
+
Rec.toEntries(structure.slots).flatMap(
|
|
153
|
+
([slotName, fillValue]): ReadonlyArray<readonly [string, SlotFill<unknown>]> =>
|
|
154
|
+
isSlotFill(fillValue) ? [[slotName, fillValue]] : [],
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const slotsOf = (comp: AnyComposition): Record<string, AnySlot> =>
|
|
159
|
+
comp.capture<Record<string, AnySlot>>((exact) => {
|
|
160
|
+
if (!('slots' in exact.manifest)) {
|
|
161
|
+
return {}
|
|
162
|
+
}
|
|
163
|
+
const candidate: unknown = exact.manifest.slots
|
|
164
|
+
if (!isRecord(candidate)) {
|
|
165
|
+
return {}
|
|
166
|
+
}
|
|
167
|
+
return Rec.fromEntries(
|
|
168
|
+
Rec.toEntries(candidate).flatMap(
|
|
169
|
+
([name, slotDefinition]): ReadonlyArray<readonly [string, AnySlot]> =>
|
|
170
|
+
isSlot(slotDefinition) ? [[name, slotDefinition]] : [],
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const serviceFromContext = <Services, Identifier, Service>(
|
|
176
|
+
context: Context.Context<Services>,
|
|
177
|
+
tag: Context.Tag<Identifier, Service>,
|
|
178
|
+
missing: string,
|
|
179
|
+
): Effect.Effect<Service, never, never> =>
|
|
180
|
+
Option.match(Context.getOption(context, tag), {
|
|
181
|
+
onNone: () => Effect.dieMessage(missing),
|
|
182
|
+
onSome: Effect.succeed,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const capturedSlotProvider = <Services>(
|
|
186
|
+
context: Context.Context<Services>,
|
|
187
|
+
slotDefinition: AnySlot,
|
|
188
|
+
): Effect.Effect<AnySlotChild, never, never> =>
|
|
189
|
+
slotDefinition.capture((exact) =>
|
|
190
|
+
serviceFromContext(
|
|
191
|
+
context,
|
|
192
|
+
exact.provider,
|
|
193
|
+
'reform-remote: scene does not provide a declared slot',
|
|
170
194
|
),
|
|
171
195
|
)
|
|
172
196
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
197
|
+
const renderCapturedComposition = <Services>(
|
|
198
|
+
context: Context.Context<Services>,
|
|
199
|
+
composition: AnyComposition,
|
|
200
|
+
props: unknown,
|
|
201
|
+
): Effect.Effect<Structure<UiContract>, never, never> =>
|
|
202
|
+
composition.capture<Effect.Effect<Structure<UiContract>, never, never>>(
|
|
203
|
+
<P, C extends UiContract, S extends ReadonlyArray<unknown>, N extends string, Identity>(
|
|
204
|
+
exact: CompositionClass<P, C, S, N, Identity>,
|
|
205
|
+
): Effect.Effect<Structure<UiContract>, never, never> =>
|
|
206
|
+
Option.match(exact.parseProps(props), {
|
|
207
|
+
onNone: () =>
|
|
208
|
+
Effect.dieMessage(
|
|
209
|
+
`reform-remote: invalid props for composition ${composition.manifest.name}`,
|
|
210
|
+
),
|
|
211
|
+
onSome: (parsed) =>
|
|
212
|
+
serviceFromContext(
|
|
213
|
+
context,
|
|
214
|
+
exact.tag,
|
|
215
|
+
`reform-remote: scene does not provide composition ${composition.manifest.name}`,
|
|
216
|
+
).pipe(
|
|
217
|
+
Effect.flatMap((service) =>
|
|
218
|
+
Composition.render<P, C>(service, {
|
|
219
|
+
props: parsed,
|
|
220
|
+
tracker: { add: () => {} },
|
|
221
|
+
}),
|
|
222
|
+
),
|
|
223
|
+
Effect.flatMap((frame) => {
|
|
224
|
+
if (isStructure(frame)) {
|
|
225
|
+
return Effect.succeed(frame)
|
|
226
|
+
}
|
|
227
|
+
return Effect.dieMessage(
|
|
228
|
+
`reform-remote: composition ${composition.manifest.name} did not return a Structure`,
|
|
229
|
+
)
|
|
230
|
+
}),
|
|
231
|
+
),
|
|
232
|
+
}),
|
|
233
|
+
)
|
|
179
234
|
|
|
180
235
|
const enqueueStructureSlot = (
|
|
181
236
|
parent: Mounted,
|
|
182
237
|
slotName: string,
|
|
183
|
-
child:
|
|
238
|
+
child: AnyComposition,
|
|
184
239
|
fill: SlotFill<unknown>,
|
|
185
240
|
): ReadonlyArray<Mounted> =>
|
|
186
241
|
Match.value(fill).pipe(
|
|
@@ -212,58 +267,60 @@ const enqueueStructureSlot = (
|
|
|
212
267
|
Match.exhaustive,
|
|
213
268
|
)
|
|
214
269
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
270
|
+
type SlotProvider<Services> = (
|
|
271
|
+
slotDefinition: AnySlot,
|
|
272
|
+
) => Effect.Effect<AnySlotChild, never, Services>
|
|
273
|
+
|
|
274
|
+
const collect = Effect.fn('collect')(function* <Services>(
|
|
275
|
+
root: AnyComposition,
|
|
276
|
+
provideSlot: SlotProvider<Services>,
|
|
277
|
+
): Effect.fn.Return<Map<AnySlot, AnyComposition>, never, Services> {
|
|
278
|
+
const bindings = new Map<AnySlot, AnyComposition>()
|
|
279
|
+
// Explicit type keeps the recursive reference cast-free.
|
|
280
|
+
const walk: (comp: AnyComposition) => Effect.Effect<void, never, Services> = Effect.fn('walk')(
|
|
281
|
+
function* (comp: AnyComposition): Effect.fn.Return<void, never, Services> {
|
|
282
|
+
yield* Effect.forEach(Rec.values(slotsOf(comp)), (slotClass) =>
|
|
283
|
+
Effect.gen(function* () {
|
|
284
|
+
if (bindings.has(slotClass)) {
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
const child = childComposition(yield* provideSlot(slotClass))
|
|
288
|
+
bindings.set(slotClass, child)
|
|
289
|
+
yield* walk(child)
|
|
290
|
+
}),
|
|
291
|
+
)
|
|
292
|
+
},
|
|
293
|
+
)
|
|
234
294
|
yield* walk(root)
|
|
235
295
|
return bindings
|
|
236
296
|
})
|
|
237
297
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
298
|
+
type CompositionRenderer<Services> = (
|
|
299
|
+
composition: AnyComposition,
|
|
300
|
+
props: unknown,
|
|
301
|
+
) => Effect.Effect<Structure<UiContract>, never, Services>
|
|
302
|
+
|
|
303
|
+
interface RenderLevel {
|
|
304
|
+
<Services>(
|
|
305
|
+
frontier: ReadonlyArray<Mounted>,
|
|
306
|
+
bindings: ReadonlyMap<AnySlot, AnyComposition>,
|
|
307
|
+
register: TriggerRegistryApi['register'],
|
|
308
|
+
render: CompositionRenderer<Services>,
|
|
309
|
+
): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, Services>
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const renderLevel: RenderLevel = Effect.fn('renderLevel')(function* <Services>(
|
|
247
313
|
frontier: ReadonlyArray<Mounted>,
|
|
248
|
-
bindings: ReadonlyMap<
|
|
314
|
+
bindings: ReadonlyMap<AnySlot, AnyComposition>,
|
|
249
315
|
register: TriggerRegistryApi['register'],
|
|
250
|
-
|
|
316
|
+
render: CompositionRenderer<Services>,
|
|
317
|
+
): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, Services> {
|
|
251
318
|
if (frontier.length === 0) {
|
|
252
319
|
return []
|
|
253
320
|
}
|
|
254
321
|
const rendered = yield* Effect.forEach(frontier, (mounted) =>
|
|
255
322
|
Effect.gen(function* () {
|
|
256
|
-
const
|
|
257
|
-
const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
|
|
258
|
-
const frame = yield* Composition.render(service, env)
|
|
259
|
-
// Every composition returns a `Structure` (the Node render path is gone): its
|
|
260
|
-
// wire node's props come from `frame.props`, event triggers from `frame.events`,
|
|
261
|
-
// and children are enqueued by reading each declared slot's fill — no view eval.
|
|
262
|
-
if (!isStructure(frame)) {
|
|
263
|
-
return yield* Effect.dieMessage(
|
|
264
|
-
`reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
|
|
265
|
-
)
|
|
266
|
-
}
|
|
323
|
+
const frame = yield* render(mounted.comp, mounted.props)
|
|
267
324
|
const node = yield* toWireNodeFromStructure(mounted, frame, register)
|
|
268
325
|
const fills = structureFills(frame)
|
|
269
326
|
const children = Rec.toEntries(slotsOf(mounted.comp)).flatMap(([slotName, slotClass]) => {
|
|
@@ -282,90 +339,96 @@ const renderLevel: (
|
|
|
282
339
|
)
|
|
283
340
|
const nodes = rendered.map((entry) => entry.node)
|
|
284
341
|
const next = rendered.flatMap((entry) => entry.children)
|
|
285
|
-
const rest = yield* renderLevel(next, bindings, register)
|
|
342
|
+
const rest = yield* renderLevel(next, bindings, register, render)
|
|
286
343
|
return [...nodes, ...rest]
|
|
287
344
|
})
|
|
288
345
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
346
|
+
const renderSceneWith = Effect.fn('renderSceneWith')(function* <SlotServices, CompositionServices>(
|
|
347
|
+
scene: AnyScene,
|
|
348
|
+
options: RenderSceneToWireOptions | undefined,
|
|
349
|
+
provideSlot: SlotProvider<SlotServices>,
|
|
350
|
+
render: CompositionRenderer<CompositionServices>,
|
|
351
|
+
): Effect.fn.Return<WireTree, ParseResult.ParseError, SlotServices | CompositionServices> {
|
|
352
|
+
const register = options?.register ?? noRegister
|
|
353
|
+
const bindings = yield* collect(scene.composition, provideSlot)
|
|
354
|
+
const root: Mounted = {
|
|
355
|
+
comp: scene.composition,
|
|
356
|
+
props: {},
|
|
357
|
+
id: '0',
|
|
358
|
+
parentId: Option.none(),
|
|
359
|
+
slot: Option.none(),
|
|
360
|
+
childIndex: 0,
|
|
361
|
+
key: Option.none(),
|
|
362
|
+
}
|
|
363
|
+
return yield* renderLevel([root], bindings, register, render)
|
|
364
|
+
})
|
|
299
365
|
export interface RenderSceneToWireOptions {
|
|
300
366
|
readonly register: TriggerRegistryApi['register']
|
|
301
367
|
}
|
|
302
368
|
|
|
303
369
|
// oxlint-disable-next-line reform-rules/prefer-effect-fn -- exported binding: Effect.fn's inferred type isn't portable under isolatedDeclarations
|
|
304
|
-
export const renderSceneToWire =
|
|
305
|
-
|
|
370
|
+
export const renderSceneToWire = <
|
|
371
|
+
C extends UiContract,
|
|
372
|
+
S extends ReadonlyArray<unknown>,
|
|
373
|
+
Services,
|
|
374
|
+
P,
|
|
375
|
+
N extends string,
|
|
376
|
+
Identity,
|
|
377
|
+
>(
|
|
378
|
+
scene: CapturedScene<C, S, Services, P, N, Identity>,
|
|
306
379
|
options?: RenderSceneToWireOptions,
|
|
307
|
-
): Effect.Effect<WireTree, ParseResult.ParseError,
|
|
308
|
-
Effect.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
key: Option.none(),
|
|
319
|
-
}
|
|
320
|
-
return yield* renderLevel([root], bindings, register)
|
|
321
|
-
})
|
|
380
|
+
): Effect.Effect<WireTree, ParseResult.ParseError, Services> =>
|
|
381
|
+
Effect.context<Services>().pipe(
|
|
382
|
+
Effect.flatMap((context) =>
|
|
383
|
+
renderSceneWith(
|
|
384
|
+
scene,
|
|
385
|
+
options,
|
|
386
|
+
(slotDefinition) => capturedSlotProvider(context, slotDefinition),
|
|
387
|
+
(composition, props) => renderCapturedComposition(context, composition, props),
|
|
388
|
+
),
|
|
389
|
+
),
|
|
390
|
+
)
|
|
322
391
|
|
|
323
392
|
export interface RemoteServer {
|
|
324
|
-
/** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
|
|
325
393
|
readonly render: () => Promise<WireTree>
|
|
326
|
-
/**
|
|
327
|
-
* Render and return only the patches since the previous frame (the streaming
|
|
328
|
-
* form). Handles of deleted nodes are revoked, so a stale client invocation
|
|
329
|
-
* fails cleanly rather than firing a dangling trigger.
|
|
330
|
-
*/
|
|
331
394
|
readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
|
|
332
|
-
/** Fire a trigger the client referenced by handle, then settle the runtime. */
|
|
333
395
|
readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>
|
|
334
|
-
/**
|
|
335
|
-
* Observe SERVER-INITIATED state changes. The listener fires (after the drain
|
|
336
|
-
* settles) whenever an event flows on the engine bus — i.e. when an async procedure
|
|
337
|
-
* resolves, a boot loader completes, or a scheduler ticks — NOT just in response to a
|
|
338
|
-
* client invoke. `serve` registers a `renderDiff`-and-push listener here so the client
|
|
339
|
-
* sees background updates (a repo list that finishes loading, a reconcile sweep) that
|
|
340
|
-
* no user interaction triggered. Returns an unsubscribe handle.
|
|
341
|
-
*/
|
|
342
396
|
readonly subscribe: (listener: () => void) => () => void
|
|
343
|
-
/**
|
|
344
|
-
* The last emitted frame — the baseline every `renderDiff` diffs against. A new
|
|
345
|
-
* client attaching to a SHARED server snapshots off this (rather than a fresh
|
|
346
|
-
* `render()`, which would re-key the baseline and desync clients mid-stream).
|
|
347
|
-
*/
|
|
348
397
|
readonly currentTree: () => WireTree
|
|
349
|
-
/** Tear down the runtime and its forked fibers. */
|
|
350
398
|
readonly dispose: () => Promise<void>
|
|
351
399
|
}
|
|
352
400
|
|
|
353
|
-
|
|
354
|
-
|
|
401
|
+
const makeCapturedRemoteServer = <
|
|
402
|
+
C extends UiContract,
|
|
403
|
+
S extends ReadonlyArray<unknown>,
|
|
404
|
+
Services,
|
|
405
|
+
P,
|
|
406
|
+
N extends string,
|
|
407
|
+
Identity,
|
|
408
|
+
>(
|
|
409
|
+
scene: CapturedScene<C, S, Services, P, N, Identity>,
|
|
410
|
+
): RemoteServer => {
|
|
411
|
+
const layer = scene.provide.reduce((acc, provided) => Layer.merge(acc, provided))
|
|
412
|
+
const runtime = ManagedRuntime.make(layer)
|
|
355
413
|
const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
|
|
414
|
+
const context = forceSync(() => runtime.runSync(Effect.context<Services>()))
|
|
415
|
+
const busFromContext = serviceFromContext(
|
|
416
|
+
context,
|
|
417
|
+
Bus,
|
|
418
|
+
'reform-remote: scene does not provide Bus',
|
|
419
|
+
)
|
|
356
420
|
|
|
357
|
-
// The shared render walk, registering triggers in this connection's registry so the
|
|
358
|
-
// client can invoke them. `settleDrain` first so an in-flight batch folds into state.
|
|
359
421
|
const renderEffect = settleDrain.pipe(
|
|
360
|
-
Effect.zipRight(
|
|
422
|
+
Effect.zipRight(
|
|
423
|
+
renderSceneWith(
|
|
424
|
+
scene,
|
|
425
|
+
{ register: registry.register },
|
|
426
|
+
(slotDefinition) => capturedSlotProvider(context, slotDefinition),
|
|
427
|
+
(composition, props) => renderCapturedComposition(context, composition, props),
|
|
428
|
+
),
|
|
429
|
+
),
|
|
361
430
|
)
|
|
362
431
|
|
|
363
|
-
// Server-initiated change notification: a forked daemon subscribes to the engine bus
|
|
364
|
-
// and, after each batch settles, fans out to registered listeners. This is what lets a
|
|
365
|
-
// streaming binding push frames the client never asked for — a background load resolving
|
|
366
|
-
// long after connect. Forked BEFORE the boot publish below so nothing is missed (the
|
|
367
|
-
// listener set is empty until `subscribe` runs, so early ticks are harmless no-ops; the
|
|
368
|
-
// binding's opening snapshot captures all state up to `start`).
|
|
369
432
|
const changeListeners = new Set<() => void>()
|
|
370
433
|
const notifyChange = (): void => {
|
|
371
434
|
changeListeners.forEach((listener) => listener())
|
|
@@ -373,11 +436,9 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
373
436
|
runtime.runFork(
|
|
374
437
|
Effect.scoped(
|
|
375
438
|
Effect.gen(function* () {
|
|
376
|
-
const bus = yield*
|
|
439
|
+
const bus = yield* busFromContext
|
|
377
440
|
const subscription = yield* PubSub.subscribe(bus)
|
|
378
|
-
//
|
|
379
|
-
// runs a tick later — after the drain has folded this event into state. No settle
|
|
380
|
-
// here: it would only perturb the drain's own scheduling for no benefit.
|
|
441
|
+
// The transport debounces; settling here would perturb the drain's scheduling.
|
|
381
442
|
yield* Queue.take(subscription).pipe(
|
|
382
443
|
Effect.zipRight(Effect.sync(notifyChange)),
|
|
383
444
|
Effect.forever,
|
|
@@ -389,12 +450,13 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
389
450
|
runtime.runSync(
|
|
390
451
|
Effect.forEach(
|
|
391
452
|
Option.getOrElse(Option.fromNullable(scene.boot), () => []),
|
|
392
|
-
(event) =>
|
|
453
|
+
(event) =>
|
|
454
|
+
busFromContext.pipe(
|
|
455
|
+
Effect.flatMap((bus) => publish('High', event).pipe(Effect.provideService(Bus, bus))),
|
|
456
|
+
),
|
|
393
457
|
),
|
|
394
458
|
)
|
|
395
459
|
|
|
396
|
-
// The last emitted frame, to diff against. A const holder whose field we swap
|
|
397
|
-
// (no reassigned binding — house rule), not a `let`.
|
|
398
460
|
const frame: { tree: WireTree } = { tree: [] }
|
|
399
461
|
|
|
400
462
|
const render = (): Promise<WireTree> =>
|
|
@@ -416,9 +478,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
416
478
|
const patches = Wire.diff(previous, next)
|
|
417
479
|
frame.tree = next
|
|
418
480
|
const nextIds = new Set(next.map((node) => node.id))
|
|
419
|
-
const staleHandles = previous
|
|
420
|
-
.filter((node) => !nextIds.has(node.id))
|
|
421
|
-
.flatMap(handlesOf)
|
|
481
|
+
const staleHandles = previous.filter((node) => !nextIds.has(node.id)).flatMap(handlesOf)
|
|
422
482
|
yield* Effect.forEach(staleHandles, (handle) => registry.revoke(handle))
|
|
423
483
|
return patches
|
|
424
484
|
}),
|
|
@@ -442,3 +502,17 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
442
502
|
dispose: () => runtime.dispose(),
|
|
443
503
|
}
|
|
444
504
|
}
|
|
505
|
+
|
|
506
|
+
export const makeRemoteServer = (scene: AnyScene): RemoteServer =>
|
|
507
|
+
scene.captureAny<RemoteServer>(
|
|
508
|
+
<
|
|
509
|
+
C extends UiContract,
|
|
510
|
+
S extends ReadonlyArray<unknown>,
|
|
511
|
+
Services,
|
|
512
|
+
P,
|
|
513
|
+
N extends string,
|
|
514
|
+
Identity,
|
|
515
|
+
>(
|
|
516
|
+
exactScene: CapturedScene<C, S, Services, P, N, Identity>,
|
|
517
|
+
) => makeCapturedRemoteServer(exactScene),
|
|
518
|
+
)
|