@playfast/reform-remote 1.1.0 → 1.3.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.
Files changed (54) hide show
  1. package/dist/client.d.ts +33 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +97 -0
  4. package/dist/client.js.map +1 -0
  5. package/dist/clientBinding.d.ts +18 -0
  6. package/dist/clientBinding.d.ts.map +1 -0
  7. package/dist/clientBinding.js +70 -0
  8. package/dist/clientBinding.js.map +1 -0
  9. package/dist/index.d.ts +11 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +11 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/memory.d.ts +7 -0
  14. package/dist/memory.d.ts.map +1 -0
  15. package/dist/memory.js +21 -0
  16. package/dist/memory.js.map +1 -0
  17. package/dist/react.d.ts +15 -0
  18. package/dist/react.d.ts.map +1 -0
  19. package/dist/react.js +16 -0
  20. package/dist/react.js.map +1 -0
  21. package/dist/server.d.ts +16 -0
  22. package/dist/server.d.ts.map +1 -0
  23. package/dist/server.js +56 -0
  24. package/dist/server.js.map +1 -0
  25. package/dist/transport.d.ts +41 -0
  26. package/dist/transport.d.ts.map +1 -0
  27. package/dist/transport.js +157 -0
  28. package/dist/transport.js.map +1 -0
  29. package/dist/wireNode.d.ts +24 -0
  30. package/dist/wireNode.d.ts.map +1 -0
  31. package/dist/wireNode.js +80 -0
  32. package/dist/wireNode.js.map +1 -0
  33. package/dist/wireRender.d.ts +20 -0
  34. package/dist/wireRender.d.ts.map +1 -0
  35. package/dist/wireRender.js +118 -0
  36. package/dist/wireRender.js.map +1 -0
  37. package/package.json +17 -5
  38. package/src/client-prop-decode.test.ts +98 -0
  39. package/src/client.ts +25 -9
  40. package/src/clientBinding.ts +103 -0
  41. package/src/counterFixtures.ts +142 -0
  42. package/src/fixtures.ts +10 -453
  43. package/src/listFixtures.ts +174 -0
  44. package/src/optionalFixtures.ts +64 -0
  45. package/src/react.ts +14 -3
  46. package/src/server.test.ts +6 -6
  47. package/src/server.ts +11 -349
  48. package/src/slottedFixtures.ts +120 -0
  49. package/src/transport.ts +15 -47
  50. package/src/wire-id-uniqueness.test.ts +287 -0
  51. package/src/wire-identity.test.ts +369 -0
  52. package/src/wire-keyed-identity.test.ts +434 -0
  53. package/src/wireNode.ts +153 -0
  54. package/src/wireRender.ts +249 -0
package/src/react.ts CHANGED
@@ -1,12 +1,23 @@
1
- import { type ReactNode, useEffect, useState, useSyncExternalStore } from 'react'
2
- import { connect, type InvokeMessage, type RemoteTransport, type ServerMessage } from './transport'
1
+ import { Option } from 'effect'
2
+ import { type ReactNode, useEffect, useRef, useSyncExternalStore } from 'react'
3
+ import {
4
+ type ClientBinding,
5
+ connect,
6
+ type InvokeMessage,
7
+ type RemoteTransport,
8
+ type ServerMessage,
9
+ } from './transport'
3
10
  import type { RemoteContract, RemoteViewSet } from './client'
4
11
 
5
12
  export const useRemoteUI = <C extends RemoteContract>(
6
13
  transport: RemoteTransport<InvokeMessage, ServerMessage>,
7
14
  views: RemoteViewSet<C>,
8
15
  ): ReactNode => {
9
- const [binding] = useState(() => connect({ transport, views }))
16
+ // A ref, not `useState`: StrictMode double-invokes a state initializer, and the
17
+ // binding React discards would keep its transport subscription for good.
18
+ const held = useRef<Option.Option<ClientBinding>>(Option.none())
19
+ const binding = Option.getOrElse(held.current, () => connect({ transport, views }))
20
+ held.current = Option.some(binding)
10
21
  useEffect(() => () => binding.dispose(), [binding])
11
22
  useSyncExternalStore(binding.subscribe, binding.snapshot, binding.snapshot)
12
23
  return binding.node()
@@ -231,7 +231,7 @@ test('a list renders one child per item under a single slot, in order', async ()
231
231
  expect(byId(tree, '0.Bar.0')!.slot).toBe('Bar')
232
232
 
233
233
  const items = Wire.childrenOf(tree, '0').filter((node) => node.slot === 'Item')
234
- expect(items.map((node) => node.id)).toEqual(['0.Item.0', '0.Item.1'])
234
+ expect(items.map((node) => node.id)).toEqual(['0.Item.a', '0.Item.b'])
235
235
  expect(items.map((node) => node.childIndex)).toEqual([0, 1])
236
236
  expect(dataOf(items[0]!, 'label')).toMatchObject({ value: 'A' })
237
237
  expect(dataOf(items[1]!, 'label')).toMatchObject({ value: 'B' })
@@ -248,11 +248,11 @@ test('adding an item emits an Upsert for the new node; removing emits a Delete',
248
248
  await server.invoke('0.Bar.0:add', { id: 'c', label: 'C' })
249
249
  const added = await server.renderDiff()
250
250
  expect(deleteIds(added)).toEqual([])
251
- expect(upsertIds(added)).toContain('0.Item.2')
251
+ expect(upsertIds(added)).toContain('0.Item.c')
252
252
 
253
- await server.invoke('0.Item.2:remove', {})
253
+ await server.invoke('0.Item.c:remove', {})
254
254
  const removed = await server.renderDiff()
255
- expect(deleteIds(removed)).toEqual(['0.Item.2'])
255
+ expect(deleteIds(removed)).toEqual(['0.Item.c'])
256
256
  } finally {
257
257
  await server.dispose()
258
258
  }
@@ -262,9 +262,9 @@ test("a removed node's handle is revoked — a stale client invocation rejects",
262
262
  const server = makeRemoteServer(listScene())
263
263
  try {
264
264
  await server.renderDiff()
265
- await server.invoke('0.Item.1:remove', {})
265
+ await server.invoke('0.Item.b:remove', {})
266
266
  await server.renderDiff()
267
- await expect(server.invoke('0.Item.1:remove', {})).rejects.toBeDefined()
267
+ await expect(server.invoke('0.Item.b:remove', {})).rejects.toBeDefined()
268
268
  } finally {
269
269
  await server.dispose()
270
270
  }
package/src/server.ts CHANGED
@@ -1,370 +1,32 @@
1
1
  import {
2
- Array as Arr,
3
- Context,
4
2
  Effect,
5
3
  Layer,
6
- Match,
7
4
  ManagedRuntime,
8
5
  Option,
9
6
  type ParseResult,
10
7
  PubSub,
11
8
  Queue,
12
- Record as Rec,
13
- Schema,
14
9
  } from 'effect'
10
+ import { type CapturedScene, forceSync, publish, type UiContract } from '@playfast/reform'
15
11
  import {
16
- type CapturedScene,
17
- Composition,
18
- type CompositionClass,
19
- forceSync,
20
- isFeatureBinding,
21
- isSlot,
22
- isStructure,
23
- publish,
24
- type SlotFill,
25
- type Structure,
26
- type Trigger,
27
- type UiContract,
28
- } from '@playfast/reform'
29
- import {
30
- type AnyComposition,
31
12
  type AnyScene,
32
- type AnySlot,
33
- type AnySlotChild,
34
13
  Bus,
35
14
  Triggers,
36
15
  type TriggerRegistryApi,
37
- type UiManifest,
38
16
  Wire,
39
- type WireNode,
40
17
  type WirePatch,
41
- type WireProp,
42
18
  type WireTree,
43
19
  } from '@playfast/reform/internal'
44
-
45
- const SETTLE_DRAIN = 30
46
- const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
47
-
48
- // The erased event map's never values remain assignable to Trigger<unknown> without a cast.
49
- const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
50
- Option.match(Option.fromNullable(structure.events), {
51
- onNone: () => ({}),
52
- onSome: (events) => events,
53
- })
54
-
55
- interface Mounted {
56
- readonly comp: AnyComposition
57
- readonly props: unknown
58
- readonly id: string
59
- readonly parentId: Option.Option<string>
60
- readonly slot: Option.Option<string>
61
- readonly childIndex: number
62
- readonly key: Option.Option<string>
63
- }
64
-
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)
74
-
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.
79
- const noRegister: TriggerRegistryApi['register'] = () => Effect.void
80
-
81
- const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
82
- mounted: Mounted,
83
- structure: Structure<UiContract>,
84
- register: TriggerRegistryApi['register'],
85
- ): Effect.fn.Return<WireNode, ParseResult.ParseError> {
86
- const manifest: UiManifest = mounted.comp.manifest.ui.manifest
87
- const name = manifest.name
88
-
89
- // The manifest stores schema AST only; reconstruct an unknown-safe schema at this wire seam.
90
- const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
91
- onNone: () => Effect.succeed({}),
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
- ),
103
- })
104
- const dataProps: ReadonlyArray<WireProp> = Rec.toEntries(encodedProps).map(
105
- ([propName, propValue]): WireProp => ({
106
- _tag: 'Data',
107
- name: propName,
108
- value: propValue,
109
- }),
110
- )
111
-
112
- const eventSchemas = Option.fromNullable(manifest.events)
113
- const events = eventsOf(structure)
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
- }),
130
- ).pipe(Effect.map(Arr.getSomes))
131
-
132
- return {
133
- id: mounted.id,
134
- name,
135
- parentId: Option.getOrNull(mounted.parentId),
136
- childIndex: mounted.childIndex,
137
- slot: Option.getOrNull(mounted.slot),
138
- key: Option.getOrNull(mounted.key),
139
- props: [...dataProps, ...eventProps],
140
- }
141
- })
142
-
143
- // Structure erases per-slot fill types; recover SlotFill structurally without `as`.
144
- const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
145
- typeof candidate === 'object' &&
146
- candidate !== null &&
147
- '_tag' in candidate &&
148
- (candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
149
-
150
- const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
151
- Rec.fromEntries(
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',
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
- )
234
-
235
- const enqueueStructureSlot = (
236
- parent: Mounted,
237
- slotName: string,
238
- child: AnyComposition,
239
- fill: SlotFill<unknown>,
240
- ): ReadonlyArray<Mounted> =>
241
- Match.value(fill).pipe(
242
- Match.tag('Each', (each) =>
243
- each.items.map(
244
- (entry, index): Mounted => ({
245
- comp: child,
246
- props: entry.props,
247
- id: `${parent.id}.${slotName}.${index}`,
248
- parentId: Option.some(parent.id),
249
- slot: Option.some(slotName),
250
- childIndex: index,
251
- key: Option.fromNullable(entry.key),
252
- }),
253
- ),
254
- ),
255
- Match.tag('One', (single) => [
256
- {
257
- comp: child,
258
- props: single.props,
259
- id: `${parent.id}.${slotName}.0`,
260
- parentId: Option.some(parent.id),
261
- slot: Option.some(slotName),
262
- childIndex: 0,
263
- key: Option.none(),
264
- } satisfies Mounted,
265
- ]),
266
- Match.tag('Absent', () => []),
267
- Match.exhaustive,
268
- )
269
-
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
- )
294
- yield* walk(root)
295
- return bindings
296
- })
297
-
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>(
313
- frontier: ReadonlyArray<Mounted>,
314
- bindings: ReadonlyMap<AnySlot, AnyComposition>,
315
- register: TriggerRegistryApi['register'],
316
- render: CompositionRenderer<Services>,
317
- ): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, Services> {
318
- if (frontier.length === 0) {
319
- return []
320
- }
321
- const rendered = yield* Effect.forEach(frontier, (mounted) =>
322
- Effect.gen(function* () {
323
- const frame = yield* render(mounted.comp, mounted.props)
324
- const node = yield* toWireNodeFromStructure(mounted, frame, register)
325
- const fills = structureFills(frame)
326
- const children = Rec.toEntries(slotsOf(mounted.comp)).flatMap(([slotName, slotClass]) => {
327
- const child = bindings.get(slotClass)
328
- if (child === undefined) {
329
- return []
330
- }
331
- const fill = fills[slotName]
332
- if (fill === undefined) {
333
- return []
334
- }
335
- return enqueueStructureSlot(mounted, slotName, child, fill)
336
- })
337
- return { node, children }
338
- }),
339
- )
340
- const nodes = rendered.map((entry) => entry.node)
341
- const next = rendered.flatMap((entry) => entry.children)
342
- const rest = yield* renderLevel(next, bindings, register, render)
343
- return [...nodes, ...rest]
344
- })
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
- })
365
- export interface RenderSceneToWireOptions {
366
- readonly register: TriggerRegistryApi['register']
367
- }
20
+ import {
21
+ capturedSlotProvider,
22
+ renderCapturedComposition,
23
+ type RenderSceneToWireOptions,
24
+ renderSceneWith,
25
+ serviceFromContext,
26
+ } from './wireRender'
27
+ import { handlesOf, settleDrain } from './wireNode'
28
+
29
+ export type { RenderSceneToWireOptions } from './wireRender'
368
30
 
369
31
  // oxlint-disable-next-line reform-rules/prefer-effect-fn -- exported binding: Effect.fn's inferred type isn't portable under isolatedDeclarations
370
32
  export const renderSceneToWire = <
@@ -0,0 +1,120 @@
1
+ import { createElement } from 'react'
2
+ import { Effect, Layer, Schema as S } from 'effect'
3
+ import {
4
+ Composition,
5
+ Engine,
6
+ Event,
7
+ Reducer,
8
+ State,
9
+ Ui,
10
+ mount,
11
+ one,
12
+ provide,
13
+ scene,
14
+ slot,
15
+ type CapturedScene,
16
+ type DerivedContract,
17
+ type SlotService,
18
+ ui,
19
+ } from '@playfast/reform'
20
+ import { type CompositionChildSummary, type EngineServices, type WiredUi } from '@playfast/reform/internal'
21
+
22
+ const GreetingBase: State.StateDefinition<'greeting', string, typeof S.String> = State.make(
23
+ 'greeting',
24
+ S.String,
25
+ )
26
+ class Greeting extends GreetingBase {}
27
+ class Greeted extends Event.make('Greeted', S.Struct({ text: S.String })) {}
28
+ class Cleared extends Event.make('Cleared', S.Struct({})) {}
29
+ class SetGreeting extends Reducer.make('SetGreeting', {
30
+ states: [Greeting],
31
+ events: [Greeted],
32
+ }) {}
33
+ class ClearGreeting extends Reducer.make('ClearGreeting', {
34
+ states: [Greeting],
35
+ events: [Cleared],
36
+ }) {}
37
+ const PanelUiBase: WiredUi<
38
+ DerivedContract<{
39
+ props: S.Struct<{ greeting: typeof S.String }>
40
+ events: {
41
+ greet: S.Struct<{ text: typeof S.String }>
42
+ clear: S.Struct<{}>
43
+ }
44
+ }>,
45
+ 'Panel'
46
+ > = ui('Panel', {
47
+ props: S.Struct({ greeting: S.String }),
48
+ events: { greet: S.Struct({ text: S.String }), clear: S.Struct({}) },
49
+ })
50
+ class PanelUi extends PanelUiBase {}
51
+
52
+ class Panel extends Composition.make('Panel', {
53
+ title: 'Panel',
54
+ ui: PanelUi,
55
+ states: [Greeting],
56
+ })<Panel>() {}
57
+
58
+ class MainSlot extends slot('Main')<MainSlot, typeof Panel>() {}
59
+ const ShellUiBase: Ui.UiClass<{ props: {}; slots: { Main: MainSlot } }, 'Shell'> = ui('Shell')<{
60
+ props: {}
61
+ slots: { Main: MainSlot }
62
+ }>()
63
+ class ShellUi extends ShellUiBase {}
64
+
65
+ class Shell extends Composition.make('Shell', {
66
+ title: 'Shell',
67
+ slots: { Main: MainSlot },
68
+ ui: ShellUi,
69
+ })<Shell>() {}
70
+
71
+ type SlottedScene = CapturedScene<
72
+ typeof ShellUi.Contract,
73
+ readonly [],
74
+ | EngineServices
75
+ | State.StateStore<'greeting', string>
76
+ | Ui.UiService<'Panel'>
77
+ | Ui.UiService<'Shell'>
78
+ | Composition.CompositionId<'Shell', Shell, MainSlot>
79
+ | Composition.CompositionId<'Panel', Panel, never>
80
+ | SlotService<'Main', MainSlot, CompositionChildSummary<Panel>>,
81
+ unknown,
82
+ 'Shell',
83
+ Shell
84
+ >
85
+
86
+ const makeSlottedScene = (): SlottedScene => {
87
+ const presentation = Layer.mergeAll(
88
+ provide(
89
+ ShellUi,
90
+ Ui.make(ShellUi, (_props, slots) => createElement(slots.Main, {})),
91
+ ),
92
+ provide(
93
+ PanelUi,
94
+ Ui.make(PanelUi, ({ greeting }) => greeting),
95
+ ),
96
+ provide(MainSlot, Panel),
97
+ State.live(Greeting, 'hi'),
98
+ )
99
+ const app = Layer.mergeAll(
100
+ Composition.live(Shell, function* () {
101
+ yield* Effect.void
102
+ return mount({ props: {}, slots: { Main: one({}) } })
103
+ }),
104
+ Composition.live(Panel, function* () {
105
+ const greeting = yield* Greeting
106
+ const greet = yield* Event.trigger(Greeted)
107
+ const clearTrigger = yield* Event.trigger(Cleared)
108
+ const clear = (): void => clearTrigger({})
109
+ return mount({
110
+ props: { greeting },
111
+ slots: {},
112
+ events: { greet, clear },
113
+ })
114
+ }),
115
+ Reducer.live(SetGreeting, (_greeting, event) => event.text),
116
+ Reducer.live(ClearGreeting, () => ''),
117
+ ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
118
+ return scene(Shell, { provide: [app] })
119
+ }
120
+ export const slottedScene: typeof makeSlottedScene = makeSlottedScene
package/src/transport.ts CHANGED
@@ -1,13 +1,6 @@
1
- import { Effect, Fiber, Match, Option } from 'effect'
2
- import type { ReactNode } from 'react'
3
- import { type AnyScene, Wire, type WirePatch, type WireTree } from '@playfast/reform/internal'
1
+ import { Effect, Fiber, Option } from 'effect'
2
+ import { type AnyScene, type WirePatch, type WireTree } from '@playfast/reform/internal'
4
3
  import { makeRemoteServer } from './server'
5
- import {
6
- renderWireTree,
7
- type ClientConfig,
8
- type RemoteContract,
9
- type RemoteViewSet,
10
- } from './client'
11
4
 
12
5
  export interface SnapshotMessage {
13
6
  readonly _tag: 'Snapshot'
@@ -108,6 +101,12 @@ export const serve = (options: ServeOptions): ServerBinding => {
108
101
  void Effect.runPromise(
109
102
  Effect.promise(() => server.invoke(message.handle, message.payload)).pipe(
110
103
  Effect.zipRight(Effect.promise(push)),
104
+ // A stale handle (its node was deleted by a patch still in flight) fails by
105
+ // design. Left unhandled that escapes as a rejected promise and, under Node's
106
+ // default --unhandled-rejections=throw, takes the server down for every client.
107
+ Effect.catchAllCause((cause) =>
108
+ Effect.logError(`reform-remote: invoke '${message.handle}' failed`, cause),
109
+ ),
111
110
  ),
112
111
  )
113
112
  })
@@ -215,6 +214,12 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
215
214
  void Effect.runPromise(
216
215
  Effect.promise(() => server.invoke(message.handle, message.payload)).pipe(
217
216
  Effect.zipRight(Effect.promise(push)),
217
+ // A stale handle (its node was deleted by a patch still in flight) fails by
218
+ // design. Left unhandled that escapes as a rejected promise and, under Node's
219
+ // default --unhandled-rejections=throw, takes the server down for every client.
220
+ Effect.catchAllCause((cause) =>
221
+ Effect.logError(`reform-remote: invoke '${message.handle}' failed`, cause),
222
+ ),
218
223
  ),
219
224
  )
220
225
  })
@@ -250,41 +255,4 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
250
255
  }
251
256
  }
252
257
 
253
- export interface ClientBinding {
254
- readonly node: () => ReactNode
255
- readonly subscribe: (listener: () => void) => () => void
256
- readonly snapshot: () => WireTree
257
- readonly dispose: () => void
258
- }
259
-
260
- export interface ConnectOptions<C extends RemoteContract> {
261
- readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
262
- readonly views: RemoteViewSet<C>
263
- }
264
-
265
- export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): ClientBinding => {
266
- const { transport, views } = options
267
- const state: { tree: WireTree } = { tree: [] }
268
- const listeners = new Set<() => void>()
269
- const config: ClientConfig<C> = {
270
- views,
271
- invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
272
- }
273
- const off = transport.onMessage((message) => {
274
- state.tree = Match.value(message).pipe(
275
- Match.tag('Snapshot', ({ tree }) => tree),
276
- Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
277
- Match.exhaustive,
278
- )
279
- listeners.forEach((listener) => listener())
280
- })
281
- return {
282
- node: () => renderWireTree(state.tree, config),
283
- subscribe: (listener) => {
284
- listeners.add(listener)
285
- return () => void listeners.delete(listener)
286
- },
287
- snapshot: () => state.tree,
288
- dispose: off,
289
- }
290
- }
258
+ export { type ClientBinding, connect, type ConnectOptions } from './clientBinding'