@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/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
@@ -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
+ })