@playfast/reform-remote 1.0.2 → 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 -6
- package/src/client-remount.test.ts +14 -5
- package/src/client.ts +74 -41
- package/src/fixtures.ts +266 -60
- package/src/react.ts +1 -6
- package/src/remote-view.typecheck.ts +3 -2
- package/src/server.test.ts +26 -12
- package/src/server.ts +362 -85
- package/src/transport.test.ts +2 -1
- package/src/transport.ts +88 -24
- package/src/connect.ts +0 -44
- package/src/contract.ts +0 -3
- package/src/remote-server.ts +0 -109
- package/src/transport.types.ts +0 -29
package/src/server.ts
CHANGED
|
@@ -1,34 +1,51 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Array as Arr,
|
|
3
|
+
Context,
|
|
3
4
|
Effect,
|
|
5
|
+
Layer,
|
|
4
6
|
Match,
|
|
7
|
+
ManagedRuntime,
|
|
5
8
|
Option,
|
|
6
9
|
type ParseResult,
|
|
10
|
+
PubSub,
|
|
11
|
+
Queue,
|
|
7
12
|
Record as Rec,
|
|
8
13
|
Schema,
|
|
9
14
|
} from 'effect'
|
|
10
15
|
import {
|
|
16
|
+
type CapturedScene,
|
|
11
17
|
Composition,
|
|
12
18
|
type CompositionClass,
|
|
13
|
-
|
|
19
|
+
forceSync,
|
|
14
20
|
isFeatureBinding,
|
|
21
|
+
isSlot,
|
|
15
22
|
isStructure,
|
|
16
|
-
|
|
17
|
-
type Scene,
|
|
18
|
-
type SlotChild,
|
|
19
|
-
type SlotClass,
|
|
23
|
+
publish,
|
|
20
24
|
type SlotFill,
|
|
21
25
|
type Structure,
|
|
22
26
|
type Trigger,
|
|
23
|
-
type TriggerRegistryApi,
|
|
24
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,
|
|
25
37
|
type UiManifest,
|
|
38
|
+
Wire,
|
|
26
39
|
type WireNode,
|
|
40
|
+
type WirePatch,
|
|
27
41
|
type WireProp,
|
|
28
42
|
type WireTree,
|
|
29
|
-
} from '@playfast/reform'
|
|
43
|
+
} from '@playfast/reform/internal'
|
|
44
|
+
|
|
45
|
+
const SETTLE_DRAIN = 30
|
|
46
|
+
const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
|
|
30
47
|
|
|
31
|
-
//
|
|
48
|
+
// The erased event map's never values remain assignable to Trigger<unknown> without a cast.
|
|
32
49
|
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
|
|
33
50
|
Option.match(Option.fromNullable(structure.events), {
|
|
34
51
|
onNone: () => ({}),
|
|
@@ -36,7 +53,7 @@ const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unkn
|
|
|
36
53
|
})
|
|
37
54
|
|
|
38
55
|
interface Mounted {
|
|
39
|
-
readonly comp:
|
|
56
|
+
readonly comp: AnyComposition
|
|
40
57
|
readonly props: unknown
|
|
41
58
|
readonly id: string
|
|
42
59
|
readonly parentId: Option.Option<string>
|
|
@@ -45,10 +62,20 @@ interface Mounted {
|
|
|
45
62
|
readonly key: Option.Option<string>
|
|
46
63
|
}
|
|
47
64
|
|
|
48
|
-
const childComposition = (child:
|
|
49
|
-
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)
|
|
50
74
|
|
|
51
|
-
|
|
75
|
+
const handlesOf = (node: WireNode): ReadonlyArray<string> =>
|
|
76
|
+
node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
|
|
77
|
+
|
|
78
|
+
// One-shot renders still emit deterministic handles but have no client registry.
|
|
52
79
|
const noRegister: TriggerRegistryApi['register'] = () => Effect.void
|
|
53
80
|
|
|
54
81
|
const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
|
|
@@ -59,26 +86,47 @@ const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
|
|
|
59
86
|
const manifest: UiManifest = mounted.comp.manifest.ui.manifest
|
|
60
87
|
const name = manifest.name
|
|
61
88
|
|
|
89
|
+
// The manifest stores schema AST only; reconstruct an unknown-safe schema at this wire seam.
|
|
62
90
|
const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
|
|
63
91
|
onNone: () => Effect.succeed({}),
|
|
64
|
-
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
|
+
),
|
|
65
103
|
})
|
|
66
104
|
const dataProps: ReadonlyArray<WireProp> = Rec.toEntries(encodedProps).map(
|
|
67
|
-
([propName, propValue]): WireProp => ({
|
|
105
|
+
([propName, propValue]): WireProp => ({
|
|
106
|
+
_tag: 'Data',
|
|
107
|
+
name: propName,
|
|
108
|
+
value: propValue,
|
|
109
|
+
}),
|
|
68
110
|
)
|
|
69
111
|
|
|
70
112
|
const eventSchemas = Option.fromNullable(manifest.events)
|
|
71
113
|
const events = eventsOf(structure)
|
|
72
|
-
const eventProps: ReadonlyArray<WireProp> = yield* Effect.forEach(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
}),
|
|
82
130
|
).pipe(Effect.map(Arr.getSomes))
|
|
83
131
|
|
|
84
132
|
return {
|
|
@@ -101,18 +149,93 @@ const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
|
|
|
101
149
|
|
|
102
150
|
const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
|
|
103
151
|
Rec.fromEntries(
|
|
104
|
-
Rec.toEntries(structure.slots).flatMap(
|
|
105
|
-
|
|
152
|
+
Rec.toEntries(structure.slots).flatMap(
|
|
153
|
+
([slotName, fillValue]): ReadonlyArray<readonly [string, SlotFill<unknown>]> =>
|
|
154
|
+
isSlotFill(fillValue) ? [[slotName, fillValue]] : [],
|
|
106
155
|
),
|
|
107
156
|
)
|
|
108
157
|
|
|
109
|
-
const slotsOf = (comp:
|
|
110
|
-
|
|
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',
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
|
|
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
|
+
)
|
|
111
234
|
|
|
112
235
|
const enqueueStructureSlot = (
|
|
113
236
|
parent: Mounted,
|
|
114
237
|
slotName: string,
|
|
115
|
-
child:
|
|
238
|
+
child: AnyComposition,
|
|
116
239
|
fill: SlotFill<unknown>,
|
|
117
240
|
): ReadonlyArray<Mounted> =>
|
|
118
241
|
Match.value(fill).pipe(
|
|
@@ -144,54 +267,60 @@ const enqueueStructureSlot = (
|
|
|
144
267
|
Match.exhaustive,
|
|
145
268
|
)
|
|
146
269
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
)
|
|
166
294
|
yield* walk(root)
|
|
167
295
|
return bindings
|
|
168
296
|
})
|
|
169
297
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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>(
|
|
178
313
|
frontier: ReadonlyArray<Mounted>,
|
|
179
|
-
bindings: ReadonlyMap<
|
|
314
|
+
bindings: ReadonlyMap<AnySlot, AnyComposition>,
|
|
180
315
|
register: TriggerRegistryApi['register'],
|
|
181
|
-
|
|
316
|
+
render: CompositionRenderer<Services>,
|
|
317
|
+
): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, Services> {
|
|
182
318
|
if (frontier.length === 0) {
|
|
183
319
|
return []
|
|
184
320
|
}
|
|
185
321
|
const rendered = yield* Effect.forEach(frontier, (mounted) =>
|
|
186
322
|
Effect.gen(function* () {
|
|
187
|
-
const
|
|
188
|
-
const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
|
|
189
|
-
const frame = yield* Composition.render(service, env)
|
|
190
|
-
if (!isStructure(frame)) {
|
|
191
|
-
return yield* Effect.dieMessage(
|
|
192
|
-
`reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
|
|
193
|
-
)
|
|
194
|
-
}
|
|
323
|
+
const frame = yield* render(mounted.comp, mounted.props)
|
|
195
324
|
const node = yield* toWireNodeFromStructure(mounted, frame, register)
|
|
196
325
|
const fills = structureFills(frame)
|
|
197
326
|
const children = Rec.toEntries(slotsOf(mounted.comp)).flatMap(([slotName, slotClass]) => {
|
|
@@ -210,32 +339,180 @@ const renderLevel: (
|
|
|
210
339
|
)
|
|
211
340
|
const nodes = rendered.map((entry) => entry.node)
|
|
212
341
|
const next = rendered.flatMap((entry) => entry.children)
|
|
213
|
-
const rest = yield* renderLevel(next, bindings, register)
|
|
342
|
+
const rest = yield* renderLevel(next, bindings, register, render)
|
|
214
343
|
return [...nodes, ...rest]
|
|
215
344
|
})
|
|
216
345
|
|
|
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
|
+
})
|
|
217
365
|
export interface RenderSceneToWireOptions {
|
|
218
366
|
readonly register: TriggerRegistryApi['register']
|
|
219
367
|
}
|
|
220
368
|
|
|
221
369
|
// oxlint-disable-next-line reform-rules/prefer-effect-fn -- exported binding: Effect.fn's inferred type isn't portable under isolatedDeclarations
|
|
222
|
-
export const renderSceneToWire =
|
|
223
|
-
|
|
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>,
|
|
224
379
|
options?: RenderSceneToWireOptions,
|
|
225
|
-
): Effect.Effect<WireTree, ParseResult.ParseError,
|
|
226
|
-
Effect.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
)
|
|
391
|
+
|
|
392
|
+
export interface RemoteServer {
|
|
393
|
+
readonly render: () => Promise<WireTree>
|
|
394
|
+
readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
|
|
395
|
+
readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>
|
|
396
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
397
|
+
readonly currentTree: () => WireTree
|
|
398
|
+
readonly dispose: () => Promise<void>
|
|
399
|
+
}
|
|
400
|
+
|
|
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)
|
|
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
|
+
)
|
|
420
|
+
|
|
421
|
+
const renderEffect = settleDrain.pipe(
|
|
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
|
+
),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
const changeListeners = new Set<() => void>()
|
|
433
|
+
const notifyChange = (): void => {
|
|
434
|
+
changeListeners.forEach((listener) => listener())
|
|
435
|
+
}
|
|
436
|
+
runtime.runFork(
|
|
437
|
+
Effect.scoped(
|
|
438
|
+
Effect.gen(function* () {
|
|
439
|
+
const bus = yield* busFromContext
|
|
440
|
+
const subscription = yield* PubSub.subscribe(bus)
|
|
441
|
+
// The transport debounces; settling here would perturb the drain's scheduling.
|
|
442
|
+
yield* Queue.take(subscription).pipe(
|
|
443
|
+
Effect.zipRight(Effect.sync(notifyChange)),
|
|
444
|
+
Effect.forever,
|
|
445
|
+
)
|
|
446
|
+
}),
|
|
447
|
+
),
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
runtime.runSync(
|
|
451
|
+
Effect.forEach(
|
|
452
|
+
Option.getOrElse(Option.fromNullable(scene.boot), () => []),
|
|
453
|
+
(event) =>
|
|
454
|
+
busFromContext.pipe(
|
|
455
|
+
Effect.flatMap((bus) => publish('High', event).pipe(Effect.provideService(Bus, bus))),
|
|
456
|
+
),
|
|
457
|
+
),
|
|
458
|
+
)
|
|
240
459
|
|
|
241
|
-
|
|
460
|
+
const frame: { tree: WireTree } = { tree: [] }
|
|
461
|
+
|
|
462
|
+
const render = (): Promise<WireTree> =>
|
|
463
|
+
runtime.runPromise(
|
|
464
|
+
renderEffect.pipe(
|
|
465
|
+
Effect.tap((tree) =>
|
|
466
|
+
Effect.sync(() => {
|
|
467
|
+
frame.tree = tree
|
|
468
|
+
}),
|
|
469
|
+
),
|
|
470
|
+
),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
const renderDiff = (): Promise<ReadonlyArray<WirePatch>> =>
|
|
474
|
+
runtime.runPromise(
|
|
475
|
+
Effect.gen(function* () {
|
|
476
|
+
const next = yield* renderEffect
|
|
477
|
+
const previous = frame.tree
|
|
478
|
+
const patches = Wire.diff(previous, next)
|
|
479
|
+
frame.tree = next
|
|
480
|
+
const nextIds = new Set(next.map((node) => node.id))
|
|
481
|
+
const staleHandles = previous.filter((node) => !nextIds.has(node.id)).flatMap(handlesOf)
|
|
482
|
+
yield* Effect.forEach(staleHandles, (handle) => registry.revoke(handle))
|
|
483
|
+
return patches
|
|
484
|
+
}),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
render,
|
|
489
|
+
renderDiff,
|
|
490
|
+
invoke: (handle, encodedPayload) =>
|
|
491
|
+
runtime.runPromise(
|
|
492
|
+
Effect.gen(function* () {
|
|
493
|
+
yield* registry.invoke(handle, encodedPayload)
|
|
494
|
+
yield* settleDrain
|
|
495
|
+
}),
|
|
496
|
+
),
|
|
497
|
+
subscribe: (listener) => {
|
|
498
|
+
changeListeners.add(listener)
|
|
499
|
+
return () => void changeListeners.delete(listener)
|
|
500
|
+
},
|
|
501
|
+
currentTree: () => frame.tree,
|
|
502
|
+
dispose: () => runtime.dispose(),
|
|
503
|
+
}
|
|
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
|
+
)
|
package/src/transport.test.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { inMemoryTransportPair } from './memory'
|
|
|
9
9
|
import { remoteViews } from './client'
|
|
10
10
|
import { asyncCounterScene, CounterUi, counterScene } from './fixtures'
|
|
11
11
|
|
|
12
|
-
const draw = (node: ReactNode): void =>
|
|
12
|
+
const draw = (node: ReactNode): void =>
|
|
13
|
+
void renderToStaticMarkup(createElement(Fragment, null, node))
|
|
13
14
|
|
|
14
15
|
interface Probe {
|
|
15
16
|
props?: Record<string, unknown>
|