@playfast/reform-remote 1.1.0 → 1.2.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.
@@ -0,0 +1,235 @@
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
+ each.items.map(
101
+ (entry, index): Mounted => ({
102
+ comp: child,
103
+ props: entry.props,
104
+ id: `${parent.id}.${slotName}.${index}`,
105
+ parentId: Option.some(parent.id),
106
+ slot: Option.some(slotName),
107
+ childIndex: index,
108
+ key: Option.fromNullable(entry.key),
109
+ }),
110
+ ),
111
+ ),
112
+ Match.tag('One', (single) => [
113
+ {
114
+ comp: child,
115
+ props: single.props,
116
+ id: `${parent.id}.${slotName}.0`,
117
+ parentId: Option.some(parent.id),
118
+ slot: Option.some(slotName),
119
+ childIndex: 0,
120
+ key: Option.none(),
121
+ } satisfies Mounted,
122
+ ]),
123
+ Match.tag('Absent', () => []),
124
+ Match.exhaustive,
125
+ )
126
+
127
+ export type SlotProvider<Services> = (
128
+ slotDefinition: AnySlot,
129
+ ) => Effect.Effect<AnySlotChild, never, Services>
130
+
131
+ export const collect: <Services>(
132
+ root: AnyComposition,
133
+ provideSlot: SlotProvider<Services>,
134
+ ) => Effect.Effect<Map<AnySlot, AnyComposition>, never, Services> = Effect.fn('collect')(
135
+ function* <Services>(
136
+ root: AnyComposition,
137
+ provideSlot: SlotProvider<Services>,
138
+ ): Effect.fn.Return<Map<AnySlot, AnyComposition>, never, Services> {
139
+ const bindings = new Map<AnySlot, AnyComposition>()
140
+ // Explicit type keeps the recursive reference cast-free.
141
+ const walk: (comp: AnyComposition) => Effect.Effect<void, never, Services> = Effect.fn('walk')(
142
+ function* (comp: AnyComposition): Effect.fn.Return<void, never, Services> {
143
+ yield* Effect.forEach(Rec.values(slotsOf(comp)), (slotClass) =>
144
+ Effect.gen(function* () {
145
+ if (bindings.has(slotClass)) {
146
+ return
147
+ }
148
+ const child = childComposition(yield* provideSlot(slotClass))
149
+ bindings.set(slotClass, child)
150
+ yield* walk(child)
151
+ }),
152
+ )
153
+ },
154
+ )
155
+ yield* walk(root)
156
+ return bindings
157
+ })
158
+
159
+ export type CompositionRenderer<Services> = (
160
+ composition: AnyComposition,
161
+ props: unknown,
162
+ ) => Effect.Effect<Structure<UiContract>, never, Services>
163
+
164
+ export interface RenderLevel {
165
+ <Services>(
166
+ frontier: ReadonlyArray<Mounted>,
167
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
168
+ register: TriggerRegistryApi['register'],
169
+ render: CompositionRenderer<Services>,
170
+ ): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, Services>
171
+ }
172
+
173
+ export const renderLevel: RenderLevel = Effect.fn('renderLevel')(function* <Services>(
174
+ frontier: ReadonlyArray<Mounted>,
175
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
176
+ register: TriggerRegistryApi['register'],
177
+ render: CompositionRenderer<Services>,
178
+ ): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, Services> {
179
+ if (frontier.length === 0) {
180
+ return []
181
+ }
182
+ const rendered = yield* Effect.forEach(frontier, (mounted) =>
183
+ Effect.gen(function* () {
184
+ const frame = yield* render(mounted.comp, mounted.props)
185
+ const node = yield* toWireNodeFromStructure(mounted, frame, register)
186
+ const fills = structureFills(frame)
187
+ const children = Rec.toEntries(slotsOf(mounted.comp)).flatMap(([slotName, slotClass]) => {
188
+ const child = bindings.get(slotClass)
189
+ if (child === undefined) {
190
+ return []
191
+ }
192
+ const fill = fills[slotName]
193
+ if (fill === undefined) {
194
+ return []
195
+ }
196
+ return enqueueStructureSlot(mounted, slotName, child, fill)
197
+ })
198
+ return { node, children }
199
+ }),
200
+ )
201
+ const nodes = rendered.map((entry) => entry.node)
202
+ const next = rendered.flatMap((entry) => entry.children)
203
+ const rest = yield* renderLevel(next, bindings, register, render)
204
+ return [...nodes, ...rest]
205
+ })
206
+
207
+ export const renderSceneWith: <SlotServices, CompositionServices>(
208
+ scene: AnyScene,
209
+ options: RenderSceneToWireOptions | undefined,
210
+ provideSlot: SlotProvider<SlotServices>,
211
+ render: CompositionRenderer<CompositionServices>,
212
+ ) => Effect.Effect<
213
+ WireTree,
214
+ ParseResult.ParseError,
215
+ SlotServices | CompositionServices
216
+ > = Effect.fn('renderSceneWith')(
217
+ function* <SlotServices, CompositionServices>(
218
+ scene: AnyScene,
219
+ options: RenderSceneToWireOptions | undefined,
220
+ provideSlot: SlotProvider<SlotServices>,
221
+ render: CompositionRenderer<CompositionServices>,
222
+ ): Effect.fn.Return<WireTree, ParseResult.ParseError, SlotServices | CompositionServices> {
223
+ const register = options?.register ?? noRegister
224
+ const bindings = yield* collect(scene.composition, provideSlot)
225
+ const root: Mounted = {
226
+ comp: scene.composition,
227
+ props: {},
228
+ id: '0',
229
+ parentId: Option.none(),
230
+ slot: Option.none(),
231
+ childIndex: 0,
232
+ key: Option.none(),
233
+ }
234
+ return yield* renderLevel([root], bindings, register, render)
235
+ })