@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/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
- type CompositionService,
19
+ forceSync,
14
20
  isFeatureBinding,
21
+ isSlot,
15
22
  isStructure,
16
- type RenderEnv,
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
- // At erased UiContract boundary, event map is Record<never, never> assignable to Trigger<unknown> without cast.
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: CompositionClass<unknown>
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: SlotChild): CompositionClass<unknown> =>
49
- isFeatureBinding(child) ? child.composition : 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
- // One-shot render (screenshot): wire still has Event handles; registrar is no-op.
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: (schema) => Schema.encodeUnknown(schema)(structure.props),
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 => ({ _tag: 'Data', name: propName, value: propValue }),
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(Rec.toEntries(events), ([eventName, trigger]) =>
73
- Effect.gen(function* () {
74
- const schema = Option.flatMap(eventSchemas, (schemas) => Rec.get(schemas, eventName))
75
- if (Option.isNone(schema)) {
76
- return Option.none<WireProp>()
77
- }
78
- const handle = `${mounted.id}:${eventName}`
79
- yield* register(handle, trigger, schema.value)
80
- return Option.some<WireProp>({ _tag: 'Event', name: eventName, handle })
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(([slotName, fillValue]) =>
105
- isSlotFill(fillValue) ? [[slotName, fillValue] as const] : [],
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: CompositionClass<unknown>): Record<string, SlotClass> =>
110
- Option.getOrElse(Option.fromNullable(comp.manifest.slots), () => ({}))
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: CompositionClass<unknown>,
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
- const collect = Effect.fn('collect')(function* (
148
- root: CompositionClass<unknown>,
149
- ): Effect.fn.Return<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> {
150
- const bindings = new Map<SlotClass, CompositionClass<unknown>>()
151
- // Explicit type so recursive walk references itself cast-free.
152
- const walk: (comp: CompositionClass<unknown>) => Effect.Effect<void, never, SlotChild> = Effect.fn(
153
- 'walk',
154
- )(function* (comp: CompositionClass<unknown>): Effect.fn.Return<void, never, SlotChild> {
155
- yield* Effect.forEach(Rec.values(slotsOf(comp)), (slotClass) =>
156
- Effect.gen(function* () {
157
- if (bindings.has(slotClass)) {
158
- return
159
- }
160
- const child = childComposition(yield* slotClass.tag)
161
- bindings.set(slotClass, child)
162
- yield* walk(child)
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
- // Explicit type so recursive renderLevel references itself cast-free.
171
- const renderLevel: (
172
- frontier: ReadonlyArray<Mounted>,
173
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
174
- register: TriggerRegistryApi['register'],
175
- ) => Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> = Effect.fn(
176
- 'renderLevel',
177
- )(function* (
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<SlotClass, CompositionClass<unknown>>,
314
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
180
315
  register: TriggerRegistryApi['register'],
181
- ): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> {
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 service = yield* mounted.comp.tag
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
- scene: Scene,
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, CompositionService | SlotChild> =>
226
- Effect.gen(function* () {
227
- const register = options?.register ?? noRegister
228
- const bindings = yield* collect(scene.composition)
229
- const root: Mounted = {
230
- comp: scene.composition,
231
- props: {},
232
- id: '0',
233
- parentId: Option.none(),
234
- slot: Option.none(),
235
- childIndex: 0,
236
- key: Option.none(),
237
- }
238
- return yield* renderLevel([root], bindings, register)
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
- export { makeRemoteServer, type RemoteServer } from './remote-server'
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
+ )
@@ -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 => void renderToStaticMarkup(createElement(Fragment, null, node))
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>