@playfast/reform-remote 0.1.0 → 1.0.2
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/package.json +1 -1
- package/src/client-keyed-slot.test.ts +0 -22
- package/src/client-remount.test.ts +0 -23
- package/src/client.ts +10 -152
- package/src/connect.ts +44 -0
- package/src/contract.ts +3 -0
- package/src/fixtures.ts +4 -34
- package/src/index.ts +0 -10
- package/src/memory.ts +0 -9
- package/src/react.ts +0 -26
- package/src/remote-server.ts +109 -0
- package/src/remote-view.typecheck.ts +0 -15
- package/src/server.test.ts +2 -20
- package/src/server.ts +6 -209
- package/src/transport.test.ts +0 -24
- package/src/transport.ts +20 -162
- package/src/transport.types.ts +29 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Effect, Layer, ManagedRuntime, Option, PubSub, Queue } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
Bus,
|
|
4
|
+
type CompositionService,
|
|
5
|
+
forceSync,
|
|
6
|
+
publish,
|
|
7
|
+
type Scene,
|
|
8
|
+
type SlotChild,
|
|
9
|
+
Triggers,
|
|
10
|
+
type TriggerRegistryApi,
|
|
11
|
+
Wire,
|
|
12
|
+
type WirePatch,
|
|
13
|
+
type WireTree,
|
|
14
|
+
type WireNode,
|
|
15
|
+
} from '@playfast/reform'
|
|
16
|
+
import { renderSceneToWire } from './server'
|
|
17
|
+
|
|
18
|
+
type RuntimeServices = CompositionService | SlotChild | Bus
|
|
19
|
+
const SETTLE_DRAIN = 30
|
|
20
|
+
const settleDrain: Effect.Effect<void> = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
|
|
21
|
+
const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
|
|
22
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- closed-scene erasure: scene.provide is typed to MountedServices, recovered structurally as RuntimeServices
|
|
23
|
+
scene.provide.reduce((acc, layer) => Layer.merge(acc, layer)) as unknown as Layer.Layer<
|
|
24
|
+
RuntimeServices,
|
|
25
|
+
never,
|
|
26
|
+
never
|
|
27
|
+
>
|
|
28
|
+
const handlesOf = (node: WireNode): ReadonlyArray<string> =>
|
|
29
|
+
node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
|
|
30
|
+
|
|
31
|
+
export interface RemoteServer {
|
|
32
|
+
readonly render: () => Promise<WireTree>
|
|
33
|
+
readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
|
|
34
|
+
readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>
|
|
35
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
36
|
+
readonly currentTree: () => WireTree
|
|
37
|
+
readonly dispose: () => Promise<void>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
41
|
+
const runtime = ManagedRuntime.make(closedSceneLayer(scene))
|
|
42
|
+
const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
|
|
43
|
+
const renderEffect = settleDrain.pipe(
|
|
44
|
+
Effect.zipRight(renderSceneToWire(scene, { register: registry.register })),
|
|
45
|
+
)
|
|
46
|
+
const changeListeners = new Set<() => void>()
|
|
47
|
+
const notifyChange = (): void => {
|
|
48
|
+
changeListeners.forEach((listener) => listener())
|
|
49
|
+
}
|
|
50
|
+
runtime.runFork(
|
|
51
|
+
Effect.scoped(
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
const bus = yield* Bus
|
|
54
|
+
const subscription = yield* PubSub.subscribe(bus)
|
|
55
|
+
yield* Queue.take(subscription).pipe(
|
|
56
|
+
Effect.zipRight(Effect.sync(notifyChange)),
|
|
57
|
+
Effect.forever,
|
|
58
|
+
)
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
runtime.runSync(
|
|
63
|
+
Effect.forEach(
|
|
64
|
+
Option.getOrElse(Option.fromNullable(scene.boot), () => []),
|
|
65
|
+
(event) => publish('High', event),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
const frame: { tree: WireTree } = { tree: [] }
|
|
69
|
+
const render = (): Promise<WireTree> =>
|
|
70
|
+
runtime.runPromise(
|
|
71
|
+
renderEffect.pipe(
|
|
72
|
+
Effect.tap((tree) =>
|
|
73
|
+
Effect.sync(() => {
|
|
74
|
+
frame.tree = tree
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
const renderDiff = (): Promise<ReadonlyArray<WirePatch>> =>
|
|
80
|
+
runtime.runPromise(
|
|
81
|
+
Effect.gen(function* () {
|
|
82
|
+
const next = yield* renderEffect
|
|
83
|
+
const previous = frame.tree
|
|
84
|
+
const patches = Wire.diff(previous, next)
|
|
85
|
+
frame.tree = next
|
|
86
|
+
const nextIds = new Set(next.map((node) => node.id))
|
|
87
|
+
const staleHandles = previous.filter((node) => !nextIds.has(node.id)).flatMap(handlesOf)
|
|
88
|
+
yield* Effect.forEach(staleHandles, (handle) => registry.revoke(handle))
|
|
89
|
+
return patches
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
return {
|
|
93
|
+
render,
|
|
94
|
+
renderDiff,
|
|
95
|
+
invoke: (handle, encodedPayload) =>
|
|
96
|
+
runtime.runPromise(
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
yield* registry.invoke(handle, encodedPayload)
|
|
99
|
+
yield* settleDrain
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
subscribe: (listener) => {
|
|
103
|
+
changeListeners.add(listener)
|
|
104
|
+
return () => void changeListeners.delete(listener)
|
|
105
|
+
},
|
|
106
|
+
currentTree: () => frame.tree,
|
|
107
|
+
dispose: () => runtime.dispose(),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -3,21 +3,11 @@ import { Schema as S } from 'effect'
|
|
|
3
3
|
import { Composition, slot, ui, Ui } from '@playfast/reform'
|
|
4
4
|
import { remoteContract, remoteViews } from './client'
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Type-level proof that a remote client view — authored with the SAME `Ui.make`
|
|
8
|
-
* the local renderer uses and registered with `remoteViews` — is strict: props,
|
|
9
|
-
* event payloads, and slot names are all derived from the contract, so a wrong
|
|
10
|
-
* access fails to compile. Compiled by `tsc` (the `@ts-expect-error`s would
|
|
11
|
-
* themselves error if the line below them ever stopped being an error); not
|
|
12
|
-
* bundled, not a runtime test.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
6
|
class FooUi extends ui('Foo', {
|
|
16
7
|
props: S.Struct({ n: S.Number }),
|
|
17
8
|
events: { go: S.Struct({ x: S.String }) },
|
|
18
9
|
}) {}
|
|
19
10
|
|
|
20
|
-
// ✓ props and event payloads are inferred from the contract.
|
|
21
11
|
const fooView = Ui.make(FooUi, (props, _slots, events) => {
|
|
22
12
|
events.go({ x: String(props.n) })
|
|
23
13
|
return null
|
|
@@ -41,12 +31,10 @@ Ui.make(FooUi, (_props, _slots, events) => {
|
|
|
41
31
|
return null
|
|
42
32
|
})
|
|
43
33
|
|
|
44
|
-
// A local contract with a slot.
|
|
45
34
|
class FooComp extends Composition.make('FooComp', { title: 'Foo', ui: FooUi }) {}
|
|
46
35
|
class FooSlot extends slot('Foo')<typeof FooComp>() {}
|
|
47
36
|
class ShellUi extends ui('Shell', { props: S.Struct({}), slots: { Main: FooSlot } }) {}
|
|
48
37
|
|
|
49
|
-
// ✓ slot components are keyed by the contract's slot names.
|
|
50
38
|
const shellView = Ui.make(ShellUi, (_props, slots) => createElement(slots.Main, {}))
|
|
51
39
|
|
|
52
40
|
Ui.make(ShellUi, (_props, slots) =>
|
|
@@ -54,12 +42,9 @@ Ui.make(ShellUi, (_props, slots) =>
|
|
|
54
42
|
createElement(slots.Other, {}),
|
|
55
43
|
)
|
|
56
44
|
|
|
57
|
-
// The server declares its shape ONCE and exports `typeof` it; the client implements that
|
|
58
|
-
// type with `remoteViews<AppContract>(…)` — importing only the TYPE, never the value.
|
|
59
45
|
const AppContractValue = remoteContract({ Foo: FooUi, Shell: ShellUi })
|
|
60
46
|
type AppContract = typeof AppContractValue
|
|
61
47
|
|
|
62
|
-
// ✓ a view per contract, each recovering its own name + schema; implements the full shape.
|
|
63
48
|
remoteViews<AppContract>({ Foo: fooView, Shell: shellView })
|
|
64
49
|
|
|
65
50
|
// @ts-expect-error — a view set MISSING `Shell` does not implement the server's shape.
|
package/src/server.test.ts
CHANGED
|
@@ -17,8 +17,6 @@ import {
|
|
|
17
17
|
structureCounterScene,
|
|
18
18
|
} from './fixtures'
|
|
19
19
|
|
|
20
|
-
// The client renders views as React components, so a test runs them by RENDERING
|
|
21
|
-
// `renderWireTree`'s output (node, no DOM needed) — the views capture as they render.
|
|
22
20
|
const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
|
|
23
21
|
|
|
24
22
|
const dataOf = (node: WireNode, name: string): WireProp | undefined =>
|
|
@@ -35,8 +33,6 @@ const upsertIds = (patches: ReadonlyArray<WirePatch>): ReadonlyArray<string> =>
|
|
|
35
33
|
const deleteIds = (patches: ReadonlyArray<WirePatch>): ReadonlyArray<string> =>
|
|
36
34
|
patches.flatMap((patch) => (patch._tag === 'Delete' ? [patch.id] : []))
|
|
37
35
|
|
|
38
|
-
// ── flat counter ──────────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
36
|
test('a scene renders to a wire tree with schema-encoded props and a trigger handle', async () => {
|
|
41
37
|
const server = makeRemoteServer(counterScene())
|
|
42
38
|
try {
|
|
@@ -66,7 +62,6 @@ test('invoking a trigger handle dispatches into the runtime and the next render
|
|
|
66
62
|
}
|
|
67
63
|
})
|
|
68
64
|
|
|
69
|
-
// ── structure-path counter: events ride ON the structure (plan 03b) ─────────────
|
|
70
65
|
|
|
71
66
|
test('a mount(...)-returning body registers its event from structure.events and invoking it dispatches', async () => {
|
|
72
67
|
const server = makeRemoteServer(structureCounterScene())
|
|
@@ -74,8 +69,6 @@ test('a mount(...)-returning body registers its event from structure.events and
|
|
|
74
69
|
const tree = await server.render()
|
|
75
70
|
const root = tree[0]!
|
|
76
71
|
expect(root.name).toBe('Counter')
|
|
77
|
-
// Props come from the structure value; the `bump` trigger rode on
|
|
78
|
-
// `structure.events` and was registered behind the standard handle.
|
|
79
72
|
expect(dataOf(root, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 0 })
|
|
80
73
|
expect(eventOf(root, 'bump')).toEqual({ _tag: 'Event', name: 'bump', handle: '0:bump' })
|
|
81
74
|
|
|
@@ -156,7 +149,6 @@ test('boot events are applied before the first render', async () => {
|
|
|
156
149
|
}
|
|
157
150
|
})
|
|
158
151
|
|
|
159
|
-
// ── nested slot: a child with its own state, two events ─────────────────────────
|
|
160
152
|
|
|
161
153
|
test('a slotted child renders nested with its own encoded props and handle', async () => {
|
|
162
154
|
const server = makeRemoteServer(slottedScene())
|
|
@@ -214,13 +206,11 @@ test('two events on one node register distinct handles and each fires independen
|
|
|
214
206
|
}
|
|
215
207
|
})
|
|
216
208
|
|
|
217
|
-
// ── dynamic list: add (upsert), remove (delete + handle revocation) ─────────────
|
|
218
209
|
|
|
219
210
|
test('a list renders one child per item under a single slot, in order', async () => {
|
|
220
211
|
const server = makeRemoteServer(listScene())
|
|
221
212
|
try {
|
|
222
213
|
const tree = await server.render()
|
|
223
|
-
// List + Bar + two items.
|
|
224
214
|
expect(tree).toHaveLength(4)
|
|
225
215
|
expect(byId(tree, '0')!.name).toBe('List')
|
|
226
216
|
expect(byId(tree, '0.Bar.0')!.slot).toBe('Bar')
|
|
@@ -238,14 +228,13 @@ test('a list renders one child per item under a single slot, in order', async ()
|
|
|
238
228
|
test('adding an item emits an Upsert for the new node; removing emits a Delete', async () => {
|
|
239
229
|
const server = makeRemoteServer(listScene())
|
|
240
230
|
try {
|
|
241
|
-
await server.renderDiff()
|
|
231
|
+
await server.renderDiff()
|
|
242
232
|
|
|
243
233
|
await server.invoke('0.Bar.0:add', { id: 'c', label: 'C' })
|
|
244
234
|
const added = await server.renderDiff()
|
|
245
235
|
expect(deleteIds(added)).toEqual([])
|
|
246
236
|
expect(upsertIds(added)).toContain('0.Item.2')
|
|
247
237
|
|
|
248
|
-
// Remove the last item — exactly its node leaves the tree.
|
|
249
238
|
await server.invoke('0.Item.2:remove', {})
|
|
250
239
|
const removed = await server.renderDiff()
|
|
251
240
|
expect(deleteIds(removed)).toEqual(['0.Item.2'])
|
|
@@ -258,25 +247,20 @@ test("a removed node's handle is revoked — a stale client invocation rejects",
|
|
|
258
247
|
const server = makeRemoteServer(listScene())
|
|
259
248
|
try {
|
|
260
249
|
await server.renderDiff()
|
|
261
|
-
// The handle works while the item is mounted…
|
|
262
250
|
await server.invoke('0.Item.1:remove', {})
|
|
263
|
-
await server.renderDiff()
|
|
264
|
-
// …and fails cleanly once the node has been unmounted.
|
|
251
|
+
await server.renderDiff()
|
|
265
252
|
await expect(server.invoke('0.Item.1:remove', {})).rejects.toBeDefined()
|
|
266
253
|
} finally {
|
|
267
254
|
await server.dispose()
|
|
268
255
|
}
|
|
269
256
|
})
|
|
270
257
|
|
|
271
|
-
// ── Option props: Effect-native values survive the wire as real instances ───────
|
|
272
258
|
|
|
273
259
|
test('an Option prop round-trips through JSON as a real Option (symmetric schema decode)', async () => {
|
|
274
260
|
const server = makeRemoteServer(optionScene(Option.some('hi')))
|
|
275
261
|
const captured: { label?: unknown } = {}
|
|
276
262
|
try {
|
|
277
263
|
const tree = await server.render()
|
|
278
|
-
// Round-trip the tree through JSON exactly as the real WebSocket transport does —
|
|
279
|
-
// the Option's `toJSON` flattens it to a plain `{_tag,value}` shape on the way out.
|
|
280
264
|
const wire: WireTree = JSON.parse(JSON.stringify(tree))
|
|
281
265
|
draw(
|
|
282
266
|
renderWireTree(wire, {
|
|
@@ -289,7 +273,6 @@ test('an Option prop round-trips through JSON as a real Option (symmetric schema
|
|
|
289
273
|
invoke: () => undefined,
|
|
290
274
|
}),
|
|
291
275
|
)
|
|
292
|
-
// The client decoded it back through the contract schema → a REAL Option instance.
|
|
293
276
|
expect(Option.isOption(captured.label)).toBe(true)
|
|
294
277
|
expect(captured.label).toStrictEqual(Option.some('hi'))
|
|
295
278
|
} finally {
|
|
@@ -322,7 +305,6 @@ test('a data-only change upserts only the changed node, leaving siblings untouch
|
|
|
322
305
|
await server.renderDiff()
|
|
323
306
|
await server.invoke('0.Main.0:greet', { text: 'changed' })
|
|
324
307
|
const patches = await server.renderDiff()
|
|
325
|
-
// Only the panel changed; the shell is unchanged, so no patch for it.
|
|
326
308
|
expect(upsertIds(patches)).toEqual(['0.Main.0'])
|
|
327
309
|
expect(deleteIds(patches)).toEqual([])
|
|
328
310
|
} finally {
|
package/src/server.ts
CHANGED
|
@@ -1,86 +1,40 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Array as Arr,
|
|
3
3
|
Effect,
|
|
4
|
-
Layer,
|
|
5
4
|
Match,
|
|
6
|
-
ManagedRuntime,
|
|
7
5
|
Option,
|
|
8
6
|
type ParseResult,
|
|
9
|
-
PubSub,
|
|
10
|
-
Queue,
|
|
11
7
|
Record as Rec,
|
|
12
8
|
Schema,
|
|
13
9
|
} from 'effect'
|
|
14
10
|
import {
|
|
15
|
-
Bus,
|
|
16
11
|
Composition,
|
|
17
12
|
type CompositionClass,
|
|
18
13
|
type CompositionService,
|
|
19
|
-
forceSync,
|
|
20
14
|
isFeatureBinding,
|
|
21
15
|
isStructure,
|
|
22
|
-
publish,
|
|
23
16
|
type RenderEnv,
|
|
24
17
|
type Scene,
|
|
25
18
|
type SlotChild,
|
|
26
19
|
type SlotClass,
|
|
27
20
|
type SlotFill,
|
|
28
21
|
type Structure,
|
|
29
|
-
Triggers,
|
|
30
22
|
type Trigger,
|
|
31
23
|
type TriggerRegistryApi,
|
|
32
24
|
type UiContract,
|
|
33
25
|
type UiManifest,
|
|
34
|
-
Wire,
|
|
35
26
|
type WireNode,
|
|
36
|
-
type WirePatch,
|
|
37
27
|
type WireProp,
|
|
38
28
|
type WireTree,
|
|
39
29
|
} from '@playfast/reform'
|
|
40
30
|
|
|
41
|
-
|
|
42
|
-
* The server side of the remote transport (REMOTE_UI.md §5): render a closed
|
|
43
|
-
* `Scene` to a serializable `WireTree`, registering each trigger behind a handle.
|
|
44
|
-
* Reads each composition's returned `Structure` value directly — props, per-slot
|
|
45
|
-
* fills, and event triggers are all DATA — so the server imports no React and never
|
|
46
|
-
* evaluates a view. The render walk is breadth-first over the structure tree,
|
|
47
|
-
* assigning stable ids and encoding props via each contract's own wire schema
|
|
48
|
-
* (`WiredUiManifest`, Phase 1).
|
|
49
|
-
*
|
|
50
|
-
* The render walk (`renderSceneToWire`) is a free-standing effect that requires only
|
|
51
|
-
* `CompositionService | SlotChild` — so any runtime that supplies a scene's services
|
|
52
|
-
* can render a frame, not just `makeRemoteServer`'s own. `@playfast/reform-driver-shot`
|
|
53
|
-
* runs it against `@playfast/reform-drive`'s runtime to screenshot a driven state.
|
|
54
|
-
*/
|
|
55
|
-
|
|
56
|
-
// The runtime services a closed scene exposes (mirrors proof's erasure boundary:
|
|
57
|
-
// the scene's `provide` is typed to `MountedServices`, but slot tags resolve
|
|
58
|
-
// `SlotChild` from the same closed layer).
|
|
59
|
-
type RuntimeServices = CompositionService | SlotChild | Bus
|
|
60
|
-
|
|
61
|
-
const SETTLE_DRAIN = 30
|
|
62
|
-
const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
|
|
63
|
-
|
|
64
|
-
// The event triggers a `Structure` frame carries (plan 03b). A composition's logic
|
|
65
|
-
// returns its acquired triggers ON the structure value (it never evaluates a view),
|
|
66
|
-
// so the server reads them straight from there. At the erased `UiContract` boundary the contract's event map
|
|
67
|
-
// is `Record<never, never>`, so each value is `never` — assignable to
|
|
68
|
-
// `Trigger<unknown>` without a cast — and an omitted `events` defaults to empty.
|
|
31
|
+
// At erased UiContract boundary, event map is Record<never, never> → assignable to Trigger<unknown> without cast.
|
|
69
32
|
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
|
|
70
33
|
Option.match(Option.fromNullable(structure.events), {
|
|
71
34
|
onNone: () => ({}),
|
|
72
35
|
onSome: (events) => events,
|
|
73
36
|
})
|
|
74
37
|
|
|
75
|
-
const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
|
|
76
|
-
// oxlint-disable-next-line reform-rules/no-type-assertion -- closed-scene erasure: scene.provide is typed to MountedServices, recovered structurally as RuntimeServices
|
|
77
|
-
scene.provide.reduce((acc, layer) => Layer.merge(acc, layer)) as unknown as Layer.Layer<
|
|
78
|
-
RuntimeServices,
|
|
79
|
-
never,
|
|
80
|
-
never
|
|
81
|
-
>
|
|
82
|
-
|
|
83
|
-
/** A composition queued to render, with the props its parent handed it and its place in the tree. */
|
|
84
38
|
interface Mounted {
|
|
85
39
|
readonly comp: CompositionClass<unknown>
|
|
86
40
|
readonly props: unknown
|
|
@@ -88,26 +42,15 @@ interface Mounted {
|
|
|
88
42
|
readonly parentId: Option.Option<string>
|
|
89
43
|
readonly slot: Option.Option<string>
|
|
90
44
|
readonly childIndex: number
|
|
91
|
-
/** The React `key` the parent gave this slot child, if any — the keyed-slot selector. */
|
|
92
45
|
readonly key: Option.Option<string>
|
|
93
46
|
}
|
|
94
47
|
|
|
95
48
|
const childComposition = (child: SlotChild): CompositionClass<unknown> =>
|
|
96
49
|
isFeatureBinding(child) ? child.composition : child
|
|
97
50
|
|
|
98
|
-
|
|
99
|
-
node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
|
|
100
|
-
|
|
101
|
-
// A static render (a screenshot) has no client to receive trigger handles, so the
|
|
102
|
-
// default registrar is a no-op: the wire node still carries its `Event` props (with
|
|
103
|
-
// deterministic handles), they're just never registered for invocation.
|
|
51
|
+
// One-shot render (screenshot): wire still has Event handles; registrar is no-op.
|
|
104
52
|
const noRegister: TriggerRegistryApi['register'] = () => Effect.void
|
|
105
53
|
|
|
106
|
-
// Structure → wire encoding (plan 02 + 03b). Props come straight from the
|
|
107
|
-
// `Structure` value (data, never a view render). Event triggers ride ON the
|
|
108
|
-
// structure (`structure.events`) and are registered through `register` (the live
|
|
109
|
-
// registry for a streaming server, a no-op for a one-shot render). Produces the
|
|
110
|
-
// wire shape the transport and `Wire.diff` consume.
|
|
111
54
|
const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
|
|
112
55
|
mounted: Mounted,
|
|
113
56
|
structure: Structure<UiContract>,
|
|
@@ -116,8 +59,6 @@ const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
|
|
|
116
59
|
const manifest: UiManifest = mounted.comp.manifest.ui.manifest
|
|
117
60
|
const name = manifest.name
|
|
118
61
|
|
|
119
|
-
// `manifest.props` is `Schema.Schema<any, any>`, so the encoded shape is `any` — a record
|
|
120
|
-
// of wire-encoded prop values keyed by name (no view eval). Absent on the type-only form.
|
|
121
62
|
const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
|
|
122
63
|
onNone: () => Effect.succeed({}),
|
|
123
64
|
onSome: (schema) => Schema.encodeUnknown(schema)(structure.props),
|
|
@@ -151,18 +92,13 @@ const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
|
|
|
151
92
|
}
|
|
152
93
|
})
|
|
153
94
|
|
|
154
|
-
//
|
|
155
|
-
// UiContract>` erases its per-slot fill types at this boundary (the strict typing
|
|
156
|
-
// lives on the concrete contract), so the runtime fills arrive as `unknown` and
|
|
157
|
-
// are recovered structurally — no `as`.
|
|
95
|
+
// Structure erases per-slot fill types; recover SlotFill structurally without `as`.
|
|
158
96
|
const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
|
|
159
97
|
typeof candidate === 'object' &&
|
|
160
98
|
candidate !== null &&
|
|
161
99
|
'_tag' in candidate &&
|
|
162
100
|
(candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
|
|
163
101
|
|
|
164
|
-
// Read a structure's fills by slot name, recovering each erased value via the
|
|
165
|
-
// discriminant guard. Returns a plain record keyed by declared slot name.
|
|
166
102
|
const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
|
|
167
103
|
Rec.fromEntries(
|
|
168
104
|
Rec.toEntries(structure.slots).flatMap(([slotName, fillValue]) =>
|
|
@@ -170,10 +106,6 @@ const structureFills = (structure: Structure<UiContract>): Record<string, SlotFi
|
|
|
170
106
|
),
|
|
171
107
|
)
|
|
172
108
|
|
|
173
|
-
// Enqueue the children a single declared slot is filled with, reading the fill
|
|
174
|
-
// (Each / One / Absent) from the returned `Structure`. Multiplicity, per-item
|
|
175
|
-
// `key`, and props are carried as data — no view walk, no reconstruction.
|
|
176
|
-
/** A composition's declared slot map, defaulting to an empty record when it declares none. */
|
|
177
109
|
const slotsOf = (comp: CompositionClass<unknown>): Record<string, SlotClass> =>
|
|
178
110
|
Option.getOrElse(Option.fromNullable(comp.manifest.slots), () => ({}))
|
|
179
111
|
|
|
@@ -216,7 +148,7 @@ const collect = Effect.fn('collect')(function* (
|
|
|
216
148
|
root: CompositionClass<unknown>,
|
|
217
149
|
): Effect.fn.Return<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> {
|
|
218
150
|
const bindings = new Map<SlotClass, CompositionClass<unknown>>()
|
|
219
|
-
//
|
|
151
|
+
// Explicit type so recursive walk references itself cast-free.
|
|
220
152
|
const walk: (comp: CompositionClass<unknown>) => Effect.Effect<void, never, SlotChild> = Effect.fn(
|
|
221
153
|
'walk',
|
|
222
154
|
)(function* (comp: CompositionClass<unknown>): Effect.fn.Return<void, never, SlotChild> {
|
|
@@ -235,8 +167,7 @@ const collect = Effect.fn('collect')(function* (
|
|
|
235
167
|
return bindings
|
|
236
168
|
})
|
|
237
169
|
|
|
238
|
-
//
|
|
239
|
-
// reference itself cast-free.
|
|
170
|
+
// Explicit type so recursive renderLevel references itself cast-free.
|
|
240
171
|
const renderLevel: (
|
|
241
172
|
frontier: ReadonlyArray<Mounted>,
|
|
242
173
|
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
@@ -256,9 +187,6 @@ const renderLevel: (
|
|
|
256
187
|
const service = yield* mounted.comp.tag
|
|
257
188
|
const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
|
|
258
189
|
const frame = yield* Composition.render(service, env)
|
|
259
|
-
// Every composition returns a `Structure` (the Node render path is gone): its
|
|
260
|
-
// wire node's props come from `frame.props`, event triggers from `frame.events`,
|
|
261
|
-
// and children are enqueued by reading each declared slot's fill — no view eval.
|
|
262
190
|
if (!isStructure(frame)) {
|
|
263
191
|
return yield* Effect.dieMessage(
|
|
264
192
|
`reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
|
|
@@ -286,16 +214,6 @@ const renderLevel: (
|
|
|
286
214
|
return [...nodes, ...rest]
|
|
287
215
|
})
|
|
288
216
|
|
|
289
|
-
/**
|
|
290
|
-
* Render the scene's CURRENT frame to a serializable `WireTree`, breadth-first from
|
|
291
|
-
* the root composition. Requires only the scene's render services
|
|
292
|
-
* (`CompositionService | SlotChild`), so it runs on any runtime built from the same
|
|
293
|
-
* closed scene — `makeRemoteServer`'s, or `@playfast/reform-drive`'s (the screenshot
|
|
294
|
-
* path). `register` defaults to a no-op: a one-shot render needs no live trigger
|
|
295
|
-
* registry, while `makeRemoteServer` passes its connection registry so the streamed
|
|
296
|
-
* client can invoke handles.
|
|
297
|
-
*/
|
|
298
|
-
/** Options for {@link renderSceneToWire}; the registrar defaults to a no-op for one-shot renders. */
|
|
299
217
|
export interface RenderSceneToWireOptions {
|
|
300
218
|
readonly register: TriggerRegistryApi['register']
|
|
301
219
|
}
|
|
@@ -320,125 +238,4 @@ export const renderSceneToWire = (
|
|
|
320
238
|
return yield* renderLevel([root], bindings, register)
|
|
321
239
|
})
|
|
322
240
|
|
|
323
|
-
export
|
|
324
|
-
/** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
|
|
325
|
-
readonly render: () => Promise<WireTree>
|
|
326
|
-
/**
|
|
327
|
-
* Render and return only the patches since the previous frame (the streaming
|
|
328
|
-
* form). Handles of deleted nodes are revoked, so a stale client invocation
|
|
329
|
-
* fails cleanly rather than firing a dangling trigger.
|
|
330
|
-
*/
|
|
331
|
-
readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
|
|
332
|
-
/** Fire a trigger the client referenced by handle, then settle the runtime. */
|
|
333
|
-
readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>
|
|
334
|
-
/**
|
|
335
|
-
* Observe SERVER-INITIATED state changes. The listener fires (after the drain
|
|
336
|
-
* settles) whenever an event flows on the engine bus — i.e. when an async procedure
|
|
337
|
-
* resolves, a boot loader completes, or a scheduler ticks — NOT just in response to a
|
|
338
|
-
* client invoke. `serve` registers a `renderDiff`-and-push listener here so the client
|
|
339
|
-
* sees background updates (a repo list that finishes loading, a reconcile sweep) that
|
|
340
|
-
* no user interaction triggered. Returns an unsubscribe handle.
|
|
341
|
-
*/
|
|
342
|
-
readonly subscribe: (listener: () => void) => () => void
|
|
343
|
-
/**
|
|
344
|
-
* The last emitted frame — the baseline every `renderDiff` diffs against. A new
|
|
345
|
-
* client attaching to a SHARED server snapshots off this (rather than a fresh
|
|
346
|
-
* `render()`, which would re-key the baseline and desync clients mid-stream).
|
|
347
|
-
*/
|
|
348
|
-
readonly currentTree: () => WireTree
|
|
349
|
-
/** Tear down the runtime and its forked fibers. */
|
|
350
|
-
readonly dispose: () => Promise<void>
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
354
|
-
const runtime = ManagedRuntime.make(closedSceneLayer(scene))
|
|
355
|
-
const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
|
|
356
|
-
|
|
357
|
-
// The shared render walk, registering triggers in this connection's registry so the
|
|
358
|
-
// client can invoke them. `settleDrain` first so an in-flight batch folds into state.
|
|
359
|
-
const renderEffect = settleDrain.pipe(
|
|
360
|
-
Effect.zipRight(renderSceneToWire(scene, { register: registry.register })),
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
// Server-initiated change notification: a forked daemon subscribes to the engine bus
|
|
364
|
-
// and, after each batch settles, fans out to registered listeners. This is what lets a
|
|
365
|
-
// streaming binding push frames the client never asked for — a background load resolving
|
|
366
|
-
// long after connect. Forked BEFORE the boot publish below so nothing is missed (the
|
|
367
|
-
// listener set is empty until `subscribe` runs, so early ticks are harmless no-ops; the
|
|
368
|
-
// binding's opening snapshot captures all state up to `start`).
|
|
369
|
-
const changeListeners = new Set<() => void>()
|
|
370
|
-
const notifyChange = (): void => {
|
|
371
|
-
changeListeners.forEach((listener) => listener())
|
|
372
|
-
}
|
|
373
|
-
runtime.runFork(
|
|
374
|
-
Effect.scoped(
|
|
375
|
-
Effect.gen(function* () {
|
|
376
|
-
const bus = yield* Bus
|
|
377
|
-
const subscription = yield* PubSub.subscribe(bus)
|
|
378
|
-
// Notify on each event; the consumer (`serve`) debounces, so the render it triggers
|
|
379
|
-
// runs a tick later — after the drain has folded this event into state. No settle
|
|
380
|
-
// here: it would only perturb the drain's own scheduling for no benefit.
|
|
381
|
-
yield* Queue.take(subscription).pipe(
|
|
382
|
-
Effect.zipRight(Effect.sync(notifyChange)),
|
|
383
|
-
Effect.forever,
|
|
384
|
-
)
|
|
385
|
-
}),
|
|
386
|
-
),
|
|
387
|
-
)
|
|
388
|
-
|
|
389
|
-
runtime.runSync(
|
|
390
|
-
Effect.forEach(
|
|
391
|
-
Option.getOrElse(Option.fromNullable(scene.boot), () => []),
|
|
392
|
-
(event) => publish('High', event),
|
|
393
|
-
),
|
|
394
|
-
)
|
|
395
|
-
|
|
396
|
-
// The last emitted frame, to diff against. A const holder whose field we swap
|
|
397
|
-
// (no reassigned binding — house rule), not a `let`.
|
|
398
|
-
const frame: { tree: WireTree } = { tree: [] }
|
|
399
|
-
|
|
400
|
-
const render = (): Promise<WireTree> =>
|
|
401
|
-
runtime.runPromise(
|
|
402
|
-
renderEffect.pipe(
|
|
403
|
-
Effect.tap((tree) =>
|
|
404
|
-
Effect.sync(() => {
|
|
405
|
-
frame.tree = tree
|
|
406
|
-
}),
|
|
407
|
-
),
|
|
408
|
-
),
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
const renderDiff = (): Promise<ReadonlyArray<WirePatch>> =>
|
|
412
|
-
runtime.runPromise(
|
|
413
|
-
Effect.gen(function* () {
|
|
414
|
-
const next = yield* renderEffect
|
|
415
|
-
const previous = frame.tree
|
|
416
|
-
const patches = Wire.diff(previous, next)
|
|
417
|
-
frame.tree = next
|
|
418
|
-
const nextIds = new Set(next.map((node) => node.id))
|
|
419
|
-
const staleHandles = previous
|
|
420
|
-
.filter((node) => !nextIds.has(node.id))
|
|
421
|
-
.flatMap(handlesOf)
|
|
422
|
-
yield* Effect.forEach(staleHandles, (handle) => registry.revoke(handle))
|
|
423
|
-
return patches
|
|
424
|
-
}),
|
|
425
|
-
)
|
|
426
|
-
|
|
427
|
-
return {
|
|
428
|
-
render,
|
|
429
|
-
renderDiff,
|
|
430
|
-
invoke: (handle, encodedPayload) =>
|
|
431
|
-
runtime.runPromise(
|
|
432
|
-
Effect.gen(function* () {
|
|
433
|
-
yield* registry.invoke(handle, encodedPayload)
|
|
434
|
-
yield* settleDrain
|
|
435
|
-
}),
|
|
436
|
-
),
|
|
437
|
-
subscribe: (listener) => {
|
|
438
|
-
changeListeners.add(listener)
|
|
439
|
-
return () => void changeListeners.delete(listener)
|
|
440
|
-
},
|
|
441
|
-
currentTree: () => frame.tree,
|
|
442
|
-
dispose: () => runtime.dispose(),
|
|
443
|
-
}
|
|
444
|
-
}
|
|
241
|
+
export { makeRemoteServer, type RemoteServer } from './remote-server'
|