@playfast/reform-remote 0.0.3 → 0.0.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.3",
4
+ "version": "0.0.4",
5
5
  "type": "module",
6
6
  "description": "Run a reform scene's logic on the server and stream its rendered UI to a thin client over any duplex transport.",
7
7
  "keywords": [
package/src/fixtures.ts CHANGED
@@ -15,6 +15,9 @@ import {
15
15
  StateGroup,
16
16
  Ui,
17
17
  type WiredUi,
18
+ each,
19
+ mount,
20
+ one,
18
21
  provide,
19
22
  scene,
20
23
  slot,
@@ -25,10 +28,10 @@ import {
25
28
  * Shared scenes the remote-transport tests render server-side. Each is a closed
26
29
  * `Scene` wired the canonical way — compositions + reducers in `Layer.mergeAll`,
27
30
  * then `provideMerge(presentation)` (views + seeded state) and `provideMerge(Engine)`
28
- * — so they exercise the real runtime, not a stub. The presentations here are the
29
- * server-side `Ui.make` views that *expand* the tree (a list view maps its state to
30
- * N slot children); the client renders the resulting wire tree with its own
31
- * `RemoteView`s (thunk slots), which the transport tests supply separately.
31
+ * — so they exercise the real runtime, not a stub. The compositions return
32
+ * headless structures; the server expands slot fills from those structures and the
33
+ * client renders the resulting wire tree with its own `RemoteView`s (thunk slots),
34
+ * which the transport tests supply separately.
32
35
  */
33
36
 
34
37
  // ── counter: a flat composition with own state + one event ────────────────────
@@ -60,13 +63,33 @@ export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number
60
63
  Composition.live(Counter, function* () {
61
64
  const count = yield* StateGroup.select(Counters, 'count')
62
65
  const bump = yield* Event.trigger(Bumped)
63
- return (yield* CounterUi)({ count }, { bump })
66
+ return mount({ props: { count }, slots: {}, events: { bump } })
64
67
  }),
65
68
  Reducer.live(Bump, (n, event) => n + event.by),
66
69
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
67
70
  return scene(Counter, boot === undefined ? { provide: [app] } : { provide: [app], boot })
68
71
  }
69
72
 
73
+ // ── structure counter: kept as explicit structure-event coverage. The `bump`
74
+ // trigger rides ON the structure, so the server reads it from `structure.events`,
75
+ // registers the handle, and produces the same wire shape as a legacy view body.
76
+ // ───────────────────────────────────────────────────────────────────────────────
77
+ export const structureCounterScene = (): Scene => {
78
+ const presentation = Layer.mergeAll(
79
+ provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
80
+ StateGroup.live(Counters, { count: 0 }),
81
+ )
82
+ const app = Layer.mergeAll(
83
+ Composition.live(Counter, function* () {
84
+ const count = yield* StateGroup.select(Counters, 'count')
85
+ const bump = yield* Event.trigger(Bumped)
86
+ return mount({ props: { count }, slots: {}, events: { bump } })
87
+ }),
88
+ Reducer.live(Bump, (n, event) => n + event.by),
89
+ ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
90
+ return scene(Counter, { provide: [app] })
91
+ }
92
+
70
93
  // ── option: a flat composition whose prop is an `Option` (an Effect-native, non-JSON
71
94
  // value) — exercises the symmetric schema encode/decode so the client receives a REAL
72
95
  // `Option`, not its raw `{_tag,value}` wire shape. ──────────────────────────────────
@@ -86,7 +109,7 @@ export const optionScene = (initial: Option.Option<string> = Option.some('hi')):
86
109
  const app = Layer.mergeAll(
87
110
  Composition.live(Optional, function* () {
88
111
  const label = yield* StateGroup.select(Labels, 'label')
89
- return (yield* OptionalUi)({ label })
112
+ return mount({ props: { label }, slots: {} })
90
113
  }),
91
114
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
92
115
  return scene(Optional, { provide: [app] })
@@ -117,15 +140,14 @@ export const slottedScene = (): Scene => {
117
140
  )
118
141
  const app = Layer.mergeAll(
119
142
  Composition.live(Shell, function* () {
120
- const view = yield* ShellUi
121
- return view({})
143
+ return mount({ props: {}, slots: { Main: one({}) } })
122
144
  }),
123
145
  Composition.live(Panel, function* () {
124
146
  const greeting = yield* Greeting
125
147
  const greet = yield* Event.trigger(Greeted)
126
148
  const clearTrigger = yield* Event.trigger(Cleared)
127
149
  const clear = (): void => clearTrigger({})
128
- return (yield* PanelUi)({ greeting }, { greet, clear })
150
+ return mount({ props: { greeting }, slots: {}, events: { greet, clear } })
129
151
  }),
130
152
  Reducer.live(SetGreeting, (_greeting, event) => event.text),
131
153
  Reducer.live(ClearGreeting, () => ''),
@@ -171,12 +193,12 @@ export const listScene = (
171
193
  const presentation = Layer.mergeAll(
172
194
  provide(
173
195
  ListUi,
174
- Ui.make(ListUi, ({ items }, slots) =>
196
+ Ui.make(ListUi, (_props, slots) =>
175
197
  createElement(
176
198
  Fragment,
177
199
  null,
178
200
  createElement(slots.Bar, {}),
179
- ...items.map((item) => createElement(slots.Item, { id: item.id, label: item.label })),
201
+ createElement(slots.Item, {}),
180
202
  ),
181
203
  ),
182
204
  ),
@@ -189,12 +211,20 @@ export const listScene = (
189
211
  const app = Layer.mergeAll(
190
212
  Composition.live(ListComp, function* () {
191
213
  const items = yield* Items
192
- const view = yield* ListUi
193
- return view({ items })
214
+ return mount({
215
+ props: { items },
216
+ slots: {
217
+ Bar: one({}),
218
+ Item: each(items, {
219
+ key: (item) => item.id,
220
+ props: (item) => ({ id: item.id, label: item.label }),
221
+ }),
222
+ },
223
+ })
194
224
  }),
195
225
  Composition.live(Bar, function* () {
196
226
  const add = yield* Event.trigger(Added)
197
- return (yield* BarUi)({}, { add })
227
+ return mount({ props: {}, slots: {}, events: { add } })
198
228
  }),
199
229
  Composition.live(ItemComp, function* () {
200
230
  const item = (yield* Props) as { id: string; label: string }
@@ -202,7 +232,7 @@ export const listScene = (
202
232
  // The item binds its own id, so the wire `remove` carries no payload — the
203
233
  // client fires it knowing only the handle.
204
234
  const remove = (): void => removeTrigger({ id: item.id })
205
- return (yield* ItemUi)({ label: item.label }, { remove })
235
+ return mount({ props: { label: item.label }, slots: {}, events: { remove } })
206
236
  }),
207
237
  Reducer.live(AddItem, (items, event) => [...items, { id: event.id, label: event.label }]),
208
238
  Reducer.live(RemoveItem, (items, event) => items.filter((item) => item.id !== event.id)),
@@ -228,7 +258,7 @@ export const asyncCounterScene = (delay: Duration.DurationInput = '40 millis'):
228
258
  Composition.live(Counter, function* () {
229
259
  const count = yield* StateGroup.select(Counters, 'count')
230
260
  const bump = yield* Event.trigger(Bumped)
231
- return (yield* CounterUi)({ count }, { bump })
261
+ return mount({ props: { count }, slots: {}, events: { bump } })
232
262
  }),
233
263
  Reducer.live(Bump, (n, event) => n + event.by),
234
264
  // The procedure runs on the bus AFTER the scene boots: it sleeps past the opening
@@ -14,6 +14,7 @@ import {
14
14
  OptionalUi,
15
15
  optionScene,
16
16
  slottedScene,
17
+ structureCounterScene,
17
18
  } from './fixtures'
18
19
 
19
20
  // The client renders views as React components, so a test runs them by RENDERING
@@ -65,6 +66,27 @@ test('invoking a trigger handle dispatches into the runtime and the next render
65
66
  }
66
67
  })
67
68
 
69
+ // ── structure-path counter: events ride ON the structure (plan 03b) ─────────────
70
+
71
+ test('a mount(...)-returning body registers its event from structure.events and invoking it dispatches', async () => {
72
+ const server = makeRemoteServer(structureCounterScene())
73
+ try {
74
+ const tree = await server.render()
75
+ const root = tree[0]!
76
+ 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
+ expect(dataOf(root, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 0 })
80
+ expect(eventOf(root, 'bump')).toEqual({ _tag: 'Event', name: 'bump', handle: '0:bump' })
81
+
82
+ await server.invoke('0:bump', { by: 4 })
83
+ const next = await server.render()
84
+ expect(dataOf(next[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 4 })
85
+ } finally {
86
+ await server.dispose()
87
+ }
88
+ })
89
+
68
90
  test('the wire payload is validated at the seam — a bad payload rejects', async () => {
69
91
  const server = makeRemoteServer(counterScene())
70
92
  try {
package/src/server.ts CHANGED
@@ -1,24 +1,23 @@
1
- import { Effect, Layer, ManagedRuntime, type ParseResult, PubSub, Queue, Schema } from 'effect'
2
- import { isValidElement } from 'react'
1
+ import { Effect, Layer, Match, ManagedRuntime, type ParseResult, PubSub, Queue, Schema } from 'effect'
3
2
  import {
4
3
  Bus,
5
- CaptureSink,
6
- type CaptureSinkApi,
7
4
  Composition,
8
5
  type CompositionClass,
9
6
  type CompositionService,
10
7
  forceSync,
11
8
  isFeatureBinding,
12
- type Node,
9
+ isStructure,
13
10
  publish,
14
11
  type RenderEnv,
15
12
  type Scene,
16
13
  type SlotChild,
17
14
  type SlotClass,
18
- type SlotHost,
15
+ type SlotFill,
16
+ type Structure,
19
17
  Triggers,
18
+ type Trigger,
20
19
  type TriggerRegistryApi,
21
- type UiCapture,
20
+ type UiContract,
22
21
  type UiManifest,
23
22
  Wire,
24
23
  type WireNode,
@@ -30,11 +29,11 @@ import {
30
29
  /**
31
30
  * The server side of the remote transport (REMOTE_UI.md §5): render a closed
32
31
  * `Scene` to a serializable `WireTree`, registering each trigger behind a handle.
33
- * Built on the existing `CaptureSink` seamthe same one proofs use headlessly —
34
- * so nothing in core changes: production has no sink (renders to React), a host
35
- * provides one (here, to serialize). The render walk lifts proof's breadth-first
36
- * `collect`/`drive`, assigning stable ids and encoding props via each contract's
37
- * own wire schema (`WiredUiManifest`, Phase 1).
32
+ * Reads each composition's returned `Structure` value directly props, per-slot
33
+ * fills, and event triggers are all DATA so the server imports no React and never
34
+ * evaluates a view. The render walk is breadth-first over the structure tree,
35
+ * assigning stable ids and encoding props via each contract's own wire schema
36
+ * (`WiredUiManifest`, Phase 1).
38
37
  */
39
38
 
40
39
  // The runtime services a closed scene exposes (mirrors proof's erasure boundary:
@@ -45,24 +44,13 @@ type RuntimeServices = CompositionService | SlotChild | Bus
45
44
  const SETTLE_DRAIN = 30
46
45
  const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
47
46
 
48
- interface Sink {
49
- readonly api: CaptureSinkApi
50
- readonly captures: ReadonlyArray<UiCapture>
51
- reset(): void
52
- }
53
-
54
- const makeSink = (): Sink => {
55
- const state: { captures: UiCapture[] } = { captures: [] }
56
- return {
57
- api: { record: (capture) => state.captures.push(capture) },
58
- get captures() {
59
- return state.captures
60
- },
61
- reset: () => {
62
- state.captures = []
63
- },
64
- }
65
- }
47
+ // The event triggers a `Structure` frame carries (plan 03b). A composition's logic
48
+ // returns its acquired triggers ON the structure value (it never evaluates a view),
49
+ // so the server reads them straight from there. At the erased `UiContract` boundary the contract's event map
50
+ // is `Record<never, never>`, so each value is `never` — assignable to
51
+ // `Trigger<unknown>` without a cast — and an omitted `events` defaults to empty.
52
+ const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
53
+ Object.fromEntries(Object.entries(structure.events ?? {}))
66
54
 
67
55
  const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
68
56
  scene.provide.reduce((a, b) => Layer.merge(a, b)) as unknown as Layer.Layer<
@@ -86,33 +74,6 @@ interface Mounted {
86
74
  const childComposition = (child: SlotChild): CompositionClass<unknown> =>
87
75
  isFeatureBinding(child) ? child.composition : child
88
76
 
89
- /**
90
- * Walk a rendered node and enqueue the child each slot placeholder stands for.
91
- * Slot components are matched by identity (the host defers them as JSX), so this
92
- * never invokes — nor needs hooks from — ordinary presentation components.
93
- */
94
- const drive = (
95
- node: Node,
96
- enqueueBy: Map<Function, (props: unknown, key: string | null) => void>,
97
- ): void => {
98
- if (Array.isArray(node)) {
99
- for (const child of node) drive(child, enqueueBy)
100
- return
101
- }
102
- if (!isValidElement<{ readonly children?: Node }>(node)) return
103
- if (node.type instanceof Function) {
104
- const enqueue = enqueueBy.get(node.type)
105
- if (enqueue !== undefined) {
106
- // `node.key` is the React key the parent set (`createElement(slots.Row, { key })`),
107
- // carried onto the wire node so a KEYED slot can select this child on the client
108
- // (see WireNode.key). React coerces a set key to a string; absent → null.
109
- enqueue(node.props, node.key)
110
- return
111
- }
112
- }
113
- drive(node.props.children, enqueueBy)
114
- }
115
-
116
77
  const handlesOf = (node: WireNode): ReadonlyArray<string> =>
117
78
  node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
118
79
 
@@ -141,10 +102,7 @@ export interface RemoteServer {
141
102
  }
142
103
 
143
104
  export const makeRemoteServer = (scene: Scene): RemoteServer => {
144
- const sink = makeSink()
145
- const runtime = ManagedRuntime.make(
146
- Layer.provideMerge(closedSceneLayer(scene), Layer.succeed(CaptureSink, sink.api)),
147
- )
105
+ const runtime = ManagedRuntime.make(closedSceneLayer(scene))
148
106
  const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
149
107
 
150
108
  const collect = (
@@ -165,33 +123,40 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
165
123
  return bindings
166
124
  })
167
125
 
168
- const toWireNode = (m: Mounted, capture: UiCapture): Effect.Effect<WireNode, ParseResult.ParseError> =>
126
+ // Structure wire encoding (plan 02 + 03b). Props come straight from the
127
+ // `Structure` value (data, never a view render). Event triggers ride ON the
128
+ // structure (`structure.events`) and are registered from there. Produces the wire
129
+ // shape the transport and `Wire.diff` consume.
130
+ const toWireNodeFromStructure = (
131
+ m: Mounted,
132
+ structure: Structure<UiContract>,
133
+ ): Effect.Effect<WireNode, ParseResult.ParseError> =>
169
134
  Effect.gen(function* () {
170
- // The composition's contract carries the wire schemas at runtime (the type is
171
- // the erased `{ manifest }` carrier — `composition.ts:34`); read them as `UiManifest`.
172
135
  const manifest = m.comp.manifest.ui.manifest as UiManifest
136
+ const name = manifest.name
173
137
 
174
138
  const dataProps: WireProp[] = []
175
139
  if (manifest.props !== undefined) {
176
- const encoded = yield* Schema.encodeUnknown(manifest.props)(capture.props)
177
- for (const [name, value] of Object.entries(encoded as Record<string, unknown>)) {
178
- dataProps.push({ _tag: 'Data', name, value })
140
+ const encoded = yield* Schema.encodeUnknown(manifest.props)(structure.props)
141
+ for (const [propName, value] of Object.entries(encoded as Record<string, unknown>)) {
142
+ dataProps.push({ _tag: 'Data', name: propName, value })
179
143
  }
180
144
  }
181
145
 
182
146
  const eventSchemas = manifest.events ?? {}
183
147
  const eventProps: WireProp[] = []
184
- for (const [name, trigger] of Object.entries(capture.events)) {
185
- const schema = eventSchemas[name]
148
+ const events = eventsOf(structure)
149
+ for (const [eventName, trigger] of Object.entries(events)) {
150
+ const schema = eventSchemas[eventName]
186
151
  if (schema === undefined) continue
187
- const handle = `${m.id}:${name}`
152
+ const handle = `${m.id}:${eventName}`
188
153
  yield* registry.register(handle, trigger, schema)
189
- eventProps.push({ _tag: 'Event', name, handle })
154
+ eventProps.push({ _tag: 'Event', name: eventName, handle })
190
155
  }
191
156
 
192
157
  return {
193
158
  id: m.id,
194
- name: capture.name,
159
+ name,
195
160
  parentId: m.parentId,
196
161
  childIndex: m.childIndex,
197
162
  slot: m.slot,
@@ -200,6 +165,64 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
200
165
  }
201
166
  })
202
167
 
168
+ // Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
169
+ // UiContract>` erases its per-slot fill types at this boundary (the strict typing
170
+ // lives on the concrete contract), so the runtime fills arrive as `unknown` and
171
+ // are recovered structurally — no `as`.
172
+ const isSlotFill = (u: unknown): u is SlotFill<unknown> =>
173
+ typeof u === 'object' &&
174
+ u !== null &&
175
+ '_tag' in u &&
176
+ (u._tag === 'Each' || u._tag === 'One' || u._tag === 'Absent')
177
+
178
+ // Read a structure's fills by slot name, recovering each erased value via the
179
+ // discriminant guard. Returns a plain record keyed by declared slot name.
180
+ const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
181
+ Object.fromEntries(
182
+ Object.entries(structure.slots).flatMap(([name, value]) =>
183
+ isSlotFill(value) ? [[name, value] as const] : [],
184
+ ),
185
+ )
186
+
187
+ // Enqueue the children a single declared slot is filled with, reading the fill
188
+ // (Each / One / Absent) from the returned `Structure`. Multiplicity, per-item
189
+ // `key`, and props are carried as data — no view walk, no reconstruction.
190
+ const enqueueStructureSlot = (
191
+ parent: Mounted,
192
+ slotName: string,
193
+ child: CompositionClass<unknown>,
194
+ fill: SlotFill<unknown>,
195
+ next: Mounted[],
196
+ ): void =>
197
+ Match.value(fill).pipe(
198
+ Match.tag('Each', (each) => {
199
+ each.items.forEach((item, index) => {
200
+ next.push({
201
+ comp: child,
202
+ props: item.props,
203
+ id: `${parent.id}.${slotName}.${index}`,
204
+ parentId: parent.id,
205
+ slot: slotName,
206
+ childIndex: index,
207
+ key: item.key,
208
+ })
209
+ })
210
+ }),
211
+ Match.tag('One', (single) => {
212
+ next.push({
213
+ comp: child,
214
+ props: single.props,
215
+ id: `${parent.id}.${slotName}.0`,
216
+ parentId: parent.id,
217
+ slot: slotName,
218
+ childIndex: 0,
219
+ key: null,
220
+ })
221
+ }),
222
+ Match.tag('Absent', () => {}),
223
+ Match.exhaustive,
224
+ )
225
+
203
226
  const renderLevel = (
204
227
  frontier: ReadonlyArray<Mounted>,
205
228
  bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
@@ -210,37 +233,25 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
210
233
  const nodes: WireNode[] = []
211
234
  for (const mounted of frontier) {
212
235
  const service = yield* mounted.comp.tag
213
- const slotIndex = new Map<string, number>()
214
- const enqueueBy = new Map<Function, (childProps: unknown, key: string | null) => void>()
215
- const slots: SlotHost = {
216
- slot: (name) => {
217
- const slotClass = mounted.comp.manifest.slots?.[name]
218
- const child = slotClass ? bindings.get(slotClass) : undefined
219
- const component: (childProps: unknown) => Node = () => null
220
- if (child !== undefined) {
221
- enqueueBy.set(component, (childProps, key) => {
222
- const index = slotIndex.get(name) ?? 0
223
- slotIndex.set(name, index + 1)
224
- next.push({
225
- comp: child,
226
- props: childProps,
227
- id: `${mounted.id}.${name}.${index}`,
228
- parentId: mounted.id,
229
- slot: name,
230
- childIndex: index,
231
- key,
232
- })
233
- })
234
- }
235
- return component
236
- },
236
+ const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
237
+ const frame = yield* Composition.render(service, env)
238
+ // Every composition returns a `Structure` (the Node render path is gone): its
239
+ // wire node's props come from `frame.props`, event triggers from `frame.events`,
240
+ // and children are enqueued by reading each declared slot's fill — no view eval.
241
+ if (!isStructure(frame)) {
242
+ return yield* Effect.dieMessage(
243
+ `reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
244
+ )
245
+ }
246
+ nodes.push(yield* toWireNodeFromStructure(mounted, frame))
247
+ const fills = structureFills(frame)
248
+ for (const [slotName, slotClass] of Object.entries(mounted.comp.manifest.slots ?? {})) {
249
+ const child = bindings.get(slotClass)
250
+ if (child === undefined) continue
251
+ const fill = fills[slotName]
252
+ if (fill === undefined) continue
253
+ enqueueStructureSlot(mounted, slotName, child, fill, next)
237
254
  }
238
- const before = sink.captures.length
239
- const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} }, slots }
240
- const node = yield* Composition.render(service, env)
241
- const capture = sink.captures[before]
242
- if (capture !== undefined) nodes.push(yield* toWireNode(mounted, capture))
243
- drive(node, enqueueBy)
244
255
  }
245
256
  const rest = yield* renderLevel(next, bindings)
246
257
  return [...nodes, ...rest]
@@ -248,7 +259,6 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
248
259
 
249
260
  const renderEffect = Effect.gen(function* () {
250
261
  yield* settleDrain
251
- sink.reset()
252
262
  const bindings = yield* collect(scene.composition)
253
263
  const root: Mounted = {
254
264
  comp: scene.composition,
@@ -291,7 +301,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
291
301
  runtime.runSync(Effect.forEach(scene.boot ?? [], (event) => publish('High', event)))
292
302
 
293
303
  // The last emitted frame, to diff against. A const holder whose field we swap
294
- // (no reassigned binding), matching the sink's immutable-swap idiom.
304
+ // (no reassigned binding — house rule), not a `let`.
295
305
  const frame: { tree: WireTree } = { tree: [] }
296
306
 
297
307
  const render = (): Promise<WireTree> =>