@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.
- package/dist/client.d.ts +33 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +97 -0
- package/dist/client.js.map +1 -0
- package/dist/clientBinding.d.ts +18 -0
- package/dist/clientBinding.d.ts.map +1 -0
- package/dist/clientBinding.js +70 -0
- package/dist/clientBinding.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 +16 -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 +41 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +157 -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 +118 -0
- package/dist/wireRender.js.map +1 -0
- package/package.json +17 -5
- package/src/client-prop-decode.test.ts +98 -0
- package/src/client.ts +25 -9
- package/src/clientBinding.ts +103 -0
- 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/react.ts +14 -3
- package/src/server.test.ts +6 -6
- package/src/server.ts +11 -349
- package/src/slottedFixtures.ts +120 -0
- package/src/transport.ts +15 -47
- package/src/wire-id-uniqueness.test.ts +287 -0
- package/src/wire-identity.test.ts +369 -0
- package/src/wire-keyed-identity.test.ts +434 -0
- package/src/wireNode.ts +153 -0
- package/src/wireRender.ts +249 -0
package/src/react.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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()
|
package/src/server.test.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
251
|
+
expect(upsertIds(added)).toContain('0.Item.c')
|
|
252
252
|
|
|
253
|
-
await server.invoke('0.Item.
|
|
253
|
+
await server.invoke('0.Item.c:remove', {})
|
|
254
254
|
const removed = await server.renderDiff()
|
|
255
|
-
expect(deleteIds(removed)).toEqual(['0.Item.
|
|
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.
|
|
265
|
+
await server.invoke('0.Item.b:remove', {})
|
|
266
266
|
await server.renderDiff()
|
|
267
|
-
await expect(server.invoke('0.Item.
|
|
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
|
-
|
|
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/transport.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import { Effect, Fiber,
|
|
2
|
-
import type
|
|
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
|
|
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'
|