@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
@@ -0,0 +1,249 @@
1
+ import { Context, Effect, Match, Option, type ParseResult, Record as Rec } from 'effect'
2
+ import {
3
+ Composition,
4
+ type CompositionClass,
5
+ isStructure,
6
+ type SlotFill,
7
+ type Structure,
8
+ type UiContract,
9
+ } from '@playfast/reform'
10
+ import {
11
+ type AnyComposition,
12
+ type AnyScene,
13
+ type AnySlot,
14
+ type AnySlotChild,
15
+ type TriggerRegistryApi,
16
+ type WireNode,
17
+ type WireTree,
18
+ } from '@playfast/reform/internal'
19
+ import {
20
+ childComposition,
21
+ type Mounted,
22
+ noRegister,
23
+ slotsOf,
24
+ structureFills,
25
+ toWireNodeFromStructure,
26
+ } from './wireNode'
27
+
28
+ export interface RenderSceneToWireOptions {
29
+ readonly register: TriggerRegistryApi['register']
30
+ }
31
+
32
+ export const serviceFromContext = <Services, Identifier, Service>(
33
+ context: Context.Context<Services>,
34
+ tag: Context.Tag<Identifier, Service>,
35
+ missing: string,
36
+ ): Effect.Effect<Service, never, never> =>
37
+ Option.match(Context.getOption(context, tag), {
38
+ onNone: () => Effect.dieMessage(missing),
39
+ onSome: Effect.succeed,
40
+ })
41
+
42
+ export const capturedSlotProvider = <Services>(
43
+ context: Context.Context<Services>,
44
+ slotDefinition: AnySlot,
45
+ ): Effect.Effect<AnySlotChild, never, never> =>
46
+ slotDefinition.capture((exact) =>
47
+ serviceFromContext(
48
+ context,
49
+ exact.provider,
50
+ 'reform-remote: scene does not provide a declared slot',
51
+ ),
52
+ )
53
+
54
+ export const renderCapturedComposition = <Services>(
55
+ context: Context.Context<Services>,
56
+ composition: AnyComposition,
57
+ props: unknown,
58
+ ): Effect.Effect<Structure<UiContract>, never, never> =>
59
+ composition.capture<Effect.Effect<Structure<UiContract>, never, never>>(
60
+ <P, C extends UiContract, S extends ReadonlyArray<unknown>, N extends string, Identity>(
61
+ exact: CompositionClass<P, C, S, N, Identity>,
62
+ ): Effect.Effect<Structure<UiContract>, never, never> =>
63
+ Option.match(exact.parseProps(props), {
64
+ onNone: () =>
65
+ Effect.dieMessage(
66
+ `reform-remote: invalid props for composition ${composition.manifest.name}`,
67
+ ),
68
+ onSome: (parsed) =>
69
+ serviceFromContext(
70
+ context,
71
+ exact.tag,
72
+ `reform-remote: scene does not provide composition ${composition.manifest.name}`,
73
+ ).pipe(
74
+ Effect.flatMap((service) =>
75
+ Composition.render<P, C>(service, {
76
+ props: parsed,
77
+ tracker: { add: () => {} },
78
+ }),
79
+ ),
80
+ Effect.flatMap((frame) => {
81
+ if (isStructure(frame)) {
82
+ return Effect.succeed(frame)
83
+ }
84
+ return Effect.dieMessage(
85
+ `reform-remote: composition ${composition.manifest.name} did not return a Structure`,
86
+ )
87
+ }),
88
+ ),
89
+ }),
90
+ )
91
+
92
+ export const enqueueStructureSlot = (
93
+ parent: Mounted,
94
+ slotName: string,
95
+ child: AnyComposition,
96
+ fill: SlotFill<unknown>,
97
+ ): ReadonlyArray<Mounted> =>
98
+ Match.value(fill).pipe(
99
+ Match.tag('Each', (each) => {
100
+ // Keyed, not positional: a trigger handle embeds the node id, so an id built from
101
+ // the index silently re-points a handle the client already holds at whatever row
102
+ // slid into that slot. An accidental duplicate key falls back to the index rather
103
+ // than minting two nodes with one id.
104
+ const used = new Set<string>()
105
+ // The disambiguator is retried rather than assumed free: a key that literally ends
106
+ // in `#<n>` can already hold the string a later repeat would mint, and two nodes
107
+ // sharing an id costs a whole row on the wire.
108
+ const unusedId = (base: string): string => {
109
+ const attempt = (round: number): string => {
110
+ const candidate = round === 0 ? base : `${base}#${round}`
111
+ return used.has(candidate) ? attempt(round + 1) : candidate
112
+ }
113
+ return attempt(0)
114
+ }
115
+ return each.items.map((entry, index): Mounted => {
116
+ const id = unusedId(`${parent.id}.${slotName}.${entry.key ?? index}`)
117
+ used.add(id)
118
+ return {
119
+ comp: child,
120
+ props: entry.props,
121
+ id,
122
+ parentId: Option.some(parent.id),
123
+ slot: Option.some(slotName),
124
+ childIndex: index,
125
+ key: Option.fromNullable(entry.key),
126
+ }
127
+ })
128
+ }),
129
+ Match.tag('One', (single) => [
130
+ {
131
+ comp: child,
132
+ props: single.props,
133
+ id: `${parent.id}.${slotName}.0`,
134
+ parentId: Option.some(parent.id),
135
+ slot: Option.some(slotName),
136
+ childIndex: 0,
137
+ key: Option.none(),
138
+ } satisfies Mounted,
139
+ ]),
140
+ Match.tag('Absent', () => []),
141
+ Match.exhaustive,
142
+ )
143
+
144
+ export type SlotProvider<Services> = (
145
+ slotDefinition: AnySlot,
146
+ ) => Effect.Effect<AnySlotChild, never, Services>
147
+
148
+ export const collect: <Services>(
149
+ root: AnyComposition,
150
+ provideSlot: SlotProvider<Services>,
151
+ ) => Effect.Effect<Map<AnySlot, AnyComposition>, never, Services> = Effect.fn('collect')(function* <
152
+ Services,
153
+ >(
154
+ root: AnyComposition,
155
+ provideSlot: SlotProvider<Services>,
156
+ ): Effect.fn.Return<Map<AnySlot, AnyComposition>, never, Services> {
157
+ const bindings = new Map<AnySlot, AnyComposition>()
158
+ // Explicit type keeps the recursive reference cast-free.
159
+ const walk: (comp: AnyComposition) => Effect.Effect<void, never, Services> = Effect.fn('walk')(
160
+ function* (comp: AnyComposition): Effect.fn.Return<void, never, Services> {
161
+ yield* Effect.forEach(Rec.values(slotsOf(comp)), (slotClass) =>
162
+ Effect.gen(function* () {
163
+ if (bindings.has(slotClass)) {
164
+ return
165
+ }
166
+ const child = childComposition(yield* provideSlot(slotClass))
167
+ bindings.set(slotClass, child)
168
+ yield* walk(child)
169
+ }),
170
+ )
171
+ },
172
+ )
173
+ yield* walk(root)
174
+ return bindings
175
+ })
176
+
177
+ export type CompositionRenderer<Services> = (
178
+ composition: AnyComposition,
179
+ props: unknown,
180
+ ) => Effect.Effect<Structure<UiContract>, never, Services>
181
+
182
+ export interface RenderLevel {
183
+ <Services>(
184
+ frontier: ReadonlyArray<Mounted>,
185
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
186
+ register: TriggerRegistryApi['register'],
187
+ render: CompositionRenderer<Services>,
188
+ ): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, Services>
189
+ }
190
+
191
+ export const renderLevel: RenderLevel = Effect.fn('renderLevel')(function* <Services>(
192
+ frontier: ReadonlyArray<Mounted>,
193
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
194
+ register: TriggerRegistryApi['register'],
195
+ render: CompositionRenderer<Services>,
196
+ ): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, Services> {
197
+ if (frontier.length === 0) {
198
+ return []
199
+ }
200
+ const rendered = yield* Effect.forEach(frontier, (mounted) =>
201
+ Effect.gen(function* () {
202
+ const frame = yield* render(mounted.comp, mounted.props)
203
+ const node = yield* toWireNodeFromStructure(mounted, frame, register)
204
+ const fills = structureFills(frame)
205
+ const children = Rec.toEntries(slotsOf(mounted.comp)).flatMap(([slotName, slotClass]) => {
206
+ const child = bindings.get(slotClass)
207
+ if (child === undefined) {
208
+ return []
209
+ }
210
+ const fill = fills[slotName]
211
+ if (fill === undefined) {
212
+ return []
213
+ }
214
+ return enqueueStructureSlot(mounted, slotName, child, fill)
215
+ })
216
+ return { node, children }
217
+ }),
218
+ )
219
+ const nodes = rendered.map((entry) => entry.node)
220
+ const next = rendered.flatMap((entry) => entry.children)
221
+ const rest = yield* renderLevel(next, bindings, register, render)
222
+ return [...nodes, ...rest]
223
+ })
224
+
225
+ export const renderSceneWith: <SlotServices, CompositionServices>(
226
+ scene: AnyScene,
227
+ options: RenderSceneToWireOptions | undefined,
228
+ provideSlot: SlotProvider<SlotServices>,
229
+ render: CompositionRenderer<CompositionServices>,
230
+ ) => Effect.Effect<WireTree, ParseResult.ParseError, SlotServices | CompositionServices> =
231
+ Effect.fn('renderSceneWith')(function* <SlotServices, CompositionServices>(
232
+ scene: AnyScene,
233
+ options: RenderSceneToWireOptions | undefined,
234
+ provideSlot: SlotProvider<SlotServices>,
235
+ render: CompositionRenderer<CompositionServices>,
236
+ ): Effect.fn.Return<WireTree, ParseResult.ParseError, SlotServices | CompositionServices> {
237
+ const register = options?.register ?? noRegister
238
+ const bindings = yield* collect(scene.composition, provideSlot)
239
+ const root: Mounted = {
240
+ comp: scene.composition,
241
+ props: {},
242
+ id: '0',
243
+ parentId: Option.none(),
244
+ slot: Option.none(),
245
+ childIndex: 0,
246
+ key: Option.none(),
247
+ }
248
+ return yield* renderLevel([root], bindings, register, render)
249
+ })