@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.
- package/dist/client.d.ts +33 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +80 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/memory.d.ts +7 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +21 -0
- package/dist/memory.js.map +1 -0
- package/dist/react.d.ts +15 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +11 -0
- package/dist/react.js.map +1 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +56 -0
- package/dist/server.js.map +1 -0
- package/dist/transport.d.ts +53 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +171 -0
- package/dist/transport.js.map +1 -0
- package/dist/wireNode.d.ts +24 -0
- package/dist/wireNode.d.ts.map +1 -0
- package/dist/wireNode.js +80 -0
- package/dist/wireNode.js.map +1 -0
- package/dist/wireRender.d.ts +20 -0
- package/dist/wireRender.d.ts.map +1 -0
- package/dist/wireRender.js +97 -0
- package/dist/wireRender.js.map +1 -0
- package/package.json +17 -5
- package/src/counterFixtures.ts +142 -0
- package/src/fixtures.ts +10 -453
- package/src/listFixtures.ts +174 -0
- package/src/optionalFixtures.ts +64 -0
- package/src/server.ts +11 -349
- package/src/slottedFixtures.ts +120 -0
- package/src/wireNode.ts +153 -0
- package/src/wireRender.ts +235 -0
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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/wireNode.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Array as Arr, Effect, Option, type ParseResult, Record as Rec, Schema } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
isFeatureBinding,
|
|
4
|
+
isSlot,
|
|
5
|
+
type SlotFill,
|
|
6
|
+
type Structure,
|
|
7
|
+
type Trigger,
|
|
8
|
+
type UiContract,
|
|
9
|
+
} from '@playfast/reform'
|
|
10
|
+
import {
|
|
11
|
+
type AnyComposition,
|
|
12
|
+
type AnySlot,
|
|
13
|
+
type AnySlotChild,
|
|
14
|
+
type TriggerRegistryApi,
|
|
15
|
+
type UiManifest,
|
|
16
|
+
type WireNode,
|
|
17
|
+
type WireProp,
|
|
18
|
+
} from '@playfast/reform/internal'
|
|
19
|
+
|
|
20
|
+
export const SETTLE_DRAIN = 30
|
|
21
|
+
export const settleDrain: Effect.Effect<void> = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
|
|
22
|
+
|
|
23
|
+
// The erased event map's never values remain assignable to Trigger<unknown> without a cast.
|
|
24
|
+
export const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
|
|
25
|
+
Option.match(Option.fromNullable(structure.events), {
|
|
26
|
+
onNone: () => ({}),
|
|
27
|
+
onSome: (events) => events,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export interface Mounted {
|
|
31
|
+
readonly comp: AnyComposition
|
|
32
|
+
readonly props: unknown
|
|
33
|
+
readonly id: string
|
|
34
|
+
readonly parentId: Option.Option<string>
|
|
35
|
+
readonly slot: Option.Option<string>
|
|
36
|
+
readonly childIndex: number
|
|
37
|
+
readonly key: Option.Option<string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const childComposition = (child: AnySlotChild): AnyComposition => {
|
|
41
|
+
if (!isFeatureBinding(child)) {
|
|
42
|
+
return child
|
|
43
|
+
}
|
|
44
|
+
return child.capture<AnyComposition>((binding): AnyComposition => binding.composition)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
48
|
+
typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)
|
|
49
|
+
|
|
50
|
+
export const handlesOf = (node: WireNode): ReadonlyArray<string> =>
|
|
51
|
+
node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
|
|
52
|
+
|
|
53
|
+
// One-shot renders still emit deterministic handles but have no client registry.
|
|
54
|
+
export const noRegister: TriggerRegistryApi['register'] = () => Effect.void
|
|
55
|
+
|
|
56
|
+
export const toWireNodeFromStructure: (
|
|
57
|
+
mounted: Mounted,
|
|
58
|
+
structure: Structure<UiContract>,
|
|
59
|
+
register: TriggerRegistryApi['register'],
|
|
60
|
+
) => Effect.Effect<WireNode, ParseResult.ParseError> = Effect.fn('toWireNodeFromStructure')(
|
|
61
|
+
function* (
|
|
62
|
+
mounted: Mounted,
|
|
63
|
+
structure: Structure<UiContract>,
|
|
64
|
+
register: TriggerRegistryApi['register'],
|
|
65
|
+
): Effect.fn.Return<WireNode, ParseResult.ParseError> {
|
|
66
|
+
const manifest: UiManifest = mounted.comp.manifest.ui.manifest
|
|
67
|
+
const name = manifest.name
|
|
68
|
+
|
|
69
|
+
// The manifest stores schema AST only; reconstruct an unknown-safe schema at this wire seam.
|
|
70
|
+
const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
|
|
71
|
+
onNone: () => Effect.succeed({}),
|
|
72
|
+
onSome: (reflection) =>
|
|
73
|
+
Schema.encodeUnknown(Schema.make<unknown, unknown>(reflection.ast))(structure.props).pipe(
|
|
74
|
+
Effect.flatMap((encoded) => {
|
|
75
|
+
if (isRecord(encoded)) {
|
|
76
|
+
return Effect.succeed(encoded)
|
|
77
|
+
}
|
|
78
|
+
return Effect.dieMessage(
|
|
79
|
+
`reform-remote: ${manifest.name} props did not encode to a record`,
|
|
80
|
+
)
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
})
|
|
84
|
+
const dataProps: ReadonlyArray<WireProp> = Rec.toEntries(encodedProps).map(
|
|
85
|
+
([propName, propValue]): WireProp => ({
|
|
86
|
+
_tag: 'Data',
|
|
87
|
+
name: propName,
|
|
88
|
+
value: propValue,
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const eventSchemas = Option.fromNullable(manifest.events)
|
|
93
|
+
const events = eventsOf(structure)
|
|
94
|
+
const eventProps: ReadonlyArray<WireProp> = yield* Effect.forEach(
|
|
95
|
+
Rec.toEntries(events),
|
|
96
|
+
([eventName, trigger]) =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const schema = Option.flatMap(eventSchemas, (schemas) => Rec.get(schemas, eventName))
|
|
99
|
+
if (Option.isNone(schema)) {
|
|
100
|
+
return Option.none<WireProp>()
|
|
101
|
+
}
|
|
102
|
+
const handle = `${mounted.id}:${eventName}`
|
|
103
|
+
yield* register(handle, trigger, Schema.make<unknown, unknown>(schema.value.ast))
|
|
104
|
+
return Option.some<WireProp>({
|
|
105
|
+
_tag: 'Event',
|
|
106
|
+
name: eventName,
|
|
107
|
+
handle,
|
|
108
|
+
})
|
|
109
|
+
}),
|
|
110
|
+
).pipe(Effect.map(Arr.getSomes))
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
id: mounted.id,
|
|
114
|
+
name,
|
|
115
|
+
parentId: Option.getOrNull(mounted.parentId),
|
|
116
|
+
childIndex: mounted.childIndex,
|
|
117
|
+
slot: Option.getOrNull(mounted.slot),
|
|
118
|
+
key: Option.getOrNull(mounted.key),
|
|
119
|
+
props: [...dataProps, ...eventProps],
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Structure erases per-slot fill types; recover SlotFill structurally without `as`.
|
|
124
|
+
export const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
|
|
125
|
+
typeof candidate === 'object' &&
|
|
126
|
+
candidate !== null &&
|
|
127
|
+
'_tag' in candidate &&
|
|
128
|
+
(candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
|
|
129
|
+
|
|
130
|
+
export const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
|
|
131
|
+
Rec.fromEntries(
|
|
132
|
+
Rec.toEntries(structure.slots).flatMap(
|
|
133
|
+
([slotName, fillValue]): ReadonlyArray<readonly [string, SlotFill<unknown>]> =>
|
|
134
|
+
isSlotFill(fillValue) ? [[slotName, fillValue]] : [],
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
export const slotsOf = (comp: AnyComposition): Record<string, AnySlot> =>
|
|
139
|
+
comp.capture<Record<string, AnySlot>>((exact) => {
|
|
140
|
+
if (!('slots' in exact.manifest)) {
|
|
141
|
+
return {}
|
|
142
|
+
}
|
|
143
|
+
const candidate: unknown = exact.manifest.slots
|
|
144
|
+
if (!isRecord(candidate)) {
|
|
145
|
+
return {}
|
|
146
|
+
}
|
|
147
|
+
return Rec.fromEntries(
|
|
148
|
+
Rec.toEntries(candidate).flatMap(
|
|
149
|
+
([name, slotDefinition]): ReadonlyArray<readonly [string, AnySlot]> =>
|
|
150
|
+
isSlot(slotDefinition) ? [[name, slotDefinition]] : [],
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
})
|