@playfast/reform-remote 1.0.1 → 1.1.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/fixtures.ts CHANGED
@@ -3,61 +3,83 @@ import { Duration, Effect, Layer, Option, Schema as S } from 'effect'
3
3
  import {
4
4
  Channel,
5
5
  Composition,
6
+ type CapturedScene,
6
7
  type DerivedContract,
7
8
  Engine,
8
9
  Event,
9
10
  type EventOf,
10
11
  Procedure,
11
- Props,
12
12
  Reducer,
13
- type Scene,
14
13
  State,
15
14
  StateGroup,
16
15
  Ui,
17
- type WiredUi,
18
16
  each,
19
17
  mount,
20
18
  one,
21
19
  provide,
22
20
  scene,
23
21
  slot,
22
+ type SlotService,
24
23
  ui,
25
24
  } from '@playfast/reform'
25
+ import {
26
+ type CompositionChildSummary,
27
+ type EngineServices,
28
+ type WiredUi,
29
+ } from '@playfast/reform/internal'
26
30
 
27
- /**
28
- * Shared scenes the remote-transport tests render server-side. Each is a closed
29
- * `Scene` wired the canonical way — compositions + reducers in `Layer.mergeAll`,
30
- * then `provideMerge(presentation)` (views + seeded state) and `provideMerge(Engine)`
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.
35
- */
36
-
37
- // ── counter: a flat composition with own state + one event ────────────────────
38
- class Count extends State.make('count', S.Number) {}
31
+ const CountBase: State.StateDefinition<'count', number, typeof S.Number> = State.make(
32
+ 'count',
33
+ S.Number,
34
+ )
35
+ class Count extends CountBase {}
39
36
  class Counters extends StateGroup.make(Count) {}
40
37
  class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
41
- class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {}
38
+ class Bump extends Reducer.make('Bump', {
39
+ states: [Count],
40
+ events: [Bumped],
41
+ }) {}
42
42
  const CounterUiBase: WiredUi<
43
43
  DerivedContract<{
44
44
  props: S.Struct<{ count: typeof S.Number }>
45
45
  events: { bump: S.Struct<{ by: typeof S.Number }> }
46
- }>
46
+ }>,
47
+ 'Counter'
47
48
  > = ui('Counter', {
48
49
  props: S.Struct({ count: S.Number }),
49
50
  events: { bump: S.Struct({ by: S.Number }) },
50
51
  })
51
52
  export class CounterUi extends CounterUiBase {}
52
- class Counter extends Composition.make('Counter', { title: 'Counter', ui: CounterUi, states: [Count] }) {}
53
53
 
54
- /** A boot event the counter scene dispatches before its first render. */
54
+ class Counter extends Composition.make('Counter', {
55
+ title: 'Counter',
56
+ ui: CounterUi,
57
+ states: [Count],
58
+ })<Counter>() {}
59
+
60
+ type CounterScene = CapturedScene<
61
+ typeof CounterUi.Contract,
62
+ readonly [typeof Count],
63
+ | EngineServices
64
+ | State.StateStore<'count', number>
65
+ | Ui.UiService<'Counter'>
66
+ | Composition.CompositionId<'Counter', Counter, never>,
67
+ unknown,
68
+ 'Counter',
69
+ Counter
70
+ >
71
+
55
72
  export const bumpedBy = (amount: number): EventOf<'Bumped', { by: number }> =>
56
73
  Event.construct(Bumped, { by: amount })
57
74
 
58
- export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number }>>): Scene => {
75
+ const makeCounterScene = (
76
+ boot?: ReadonlyArray<EventOf<'Bumped', { by: number }>>,
77
+ ): CounterScene => {
59
78
  const presentation = Layer.mergeAll(
60
- provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
79
+ provide(
80
+ CounterUi,
81
+ Ui.make(CounterUi, ({ count }) => `count:${count}`),
82
+ ),
61
83
  StateGroup.live(Counters, { count: 0 }),
62
84
  )
63
85
  const app = Layer.mergeAll(
@@ -70,14 +92,14 @@ export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number
70
92
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
71
93
  return scene(Counter, boot === undefined ? { provide: [app] } : { provide: [app], boot })
72
94
  }
95
+ export const counterScene: typeof makeCounterScene = makeCounterScene
73
96
 
74
- // ── structure counter: kept as explicit structure-event coverage. The `bump`
75
- // trigger rides ON the structure, so the server reads it from `structure.events`,
76
- // registers the handle, and produces the same wire shape as a legacy view body.
77
- // ───────────────────────────────────────────────────────────────────────────────
78
- export const structureCounterScene = (): Scene => {
97
+ const makeStructureCounterScene = (): CounterScene => {
79
98
  const presentation = Layer.mergeAll(
80
- provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
99
+ provide(
100
+ CounterUi,
101
+ Ui.make(CounterUi, ({ count }) => `count:${count}`),
102
+ ),
81
103
  StateGroup.live(Counters, { count: 0 }),
82
104
  )
83
105
  const app = Layer.mergeAll(
@@ -90,21 +112,45 @@ export const structureCounterScene = (): Scene => {
90
112
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
91
113
  return scene(Counter, { provide: [app] })
92
114
  }
115
+ export const structureCounterScene: typeof makeStructureCounterScene = makeStructureCounterScene
93
116
 
94
- // ── option: a flat composition whose prop is an `Option` (an Effect-native, non-JSON
95
- // value) — exercises the symmetric schema encode/decode so the client receives a REAL
96
- // `Option`, not its raw `{_tag,value}` wire shape. ──────────────────────────────────
97
- class Label extends State.make('label', S.OptionFromSelf(S.String)) {}
117
+ const LabelBase: State.StateDefinition<
118
+ 'label',
119
+ Option.Option<string>,
120
+ S.OptionFromSelf<typeof S.String>
121
+ > = State.make('label', S.OptionFromSelf(S.String))
122
+ class Label extends LabelBase {}
98
123
  class Labels extends StateGroup.make(Label) {}
99
124
  const OptionalUiBase: WiredUi<
100
- DerivedContract<{ props: S.Struct<{ label: S.Option<typeof S.String> }> }>
125
+ DerivedContract<{ props: S.Struct<{ label: S.Option<typeof S.String> }> }>,
126
+ 'Optional'
101
127
  > = ui('Optional', { props: S.Struct({ label: S.Option(S.String) }) })
102
128
  export class OptionalUi extends OptionalUiBase {}
103
- class Optional extends Composition.make('Optional', { title: 'Optional', ui: OptionalUi, states: [Label] }) {}
104
129
 
105
- export const optionScene = (initial: Option.Option<string> = Option.some('hi')): Scene => {
130
+ class Optional extends Composition.make('Optional', {
131
+ title: 'Optional',
132
+ ui: OptionalUi,
133
+ states: [Label],
134
+ })<Optional>() {}
135
+
136
+ type OptionalScene = CapturedScene<
137
+ typeof OptionalUi.Contract,
138
+ readonly [typeof Label],
139
+ | EngineServices
140
+ | State.StateStore<'label', Option.Option<string>>
141
+ | Ui.UiService<'Optional'>
142
+ | Composition.CompositionId<'Optional', Optional, never>,
143
+ unknown,
144
+ 'Optional',
145
+ Optional
146
+ >
147
+
148
+ const makeOptionScene = (initial: Option.Option<string> = Option.some('hi')): OptionalScene => {
106
149
  const presentation = Layer.mergeAll(
107
- provide(OptionalUi, Ui.make(OptionalUi, () => null)),
150
+ provide(
151
+ OptionalUi,
152
+ Ui.make(OptionalUi, () => null),
153
+ ),
108
154
  StateGroup.live(Labels, { label: initial }),
109
155
  )
110
156
  const app = Layer.mergeAll(
@@ -115,32 +161,88 @@ export const optionScene = (initial: Option.Option<string> = Option.some('hi')):
115
161
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
116
162
  return scene(Optional, { provide: [app] })
117
163
  }
164
+ export const optionScene: typeof makeOptionScene = makeOptionScene
118
165
 
119
- // ── slotted: a shell whose one slot holds a child with its OWN state + event ───
120
- class Greeting extends State.make('greeting', S.String) {}
166
+ const GreetingBase: State.StateDefinition<'greeting', string, typeof S.String> = State.make(
167
+ 'greeting',
168
+ S.String,
169
+ )
170
+ class Greeting extends GreetingBase {}
121
171
  class Greeted extends Event.make('Greeted', S.Struct({ text: S.String })) {}
122
172
  class Cleared extends Event.make('Cleared', S.Struct({})) {}
123
- class SetGreeting extends Reducer.make('SetGreeting', { states: [Greeting], events: [Greeted] }) {}
124
- class ClearGreeting extends Reducer.make('ClearGreeting', { states: [Greeting], events: [Cleared] }) {}
125
- class PanelUi extends ui('Panel', {
173
+ class SetGreeting extends Reducer.make('SetGreeting', {
174
+ states: [Greeting],
175
+ events: [Greeted],
176
+ }) {}
177
+ class ClearGreeting extends Reducer.make('ClearGreeting', {
178
+ states: [Greeting],
179
+ events: [Cleared],
180
+ }) {}
181
+ const PanelUiBase: WiredUi<
182
+ DerivedContract<{
183
+ props: S.Struct<{ greeting: typeof S.String }>
184
+ events: {
185
+ greet: S.Struct<{ text: typeof S.String }>
186
+ clear: S.Struct<{}>
187
+ }
188
+ }>,
189
+ 'Panel'
190
+ > = ui('Panel', {
126
191
  props: S.Struct({ greeting: S.String }),
127
- // Two events on one node — each registers behind its own `${id}:${name}` handle.
128
192
  events: { greet: S.Struct({ text: S.String }), clear: S.Struct({}) },
129
- }) {}
130
- class Panel extends Composition.make('Panel', { title: 'Panel', ui: PanelUi, states: [Greeting] }) {}
131
- class MainSlot extends slot('Main')<typeof Panel>() {}
132
- class ShellUi extends ui('Shell')<{ props: {}; slots: { Main: MainSlot } }>() {}
133
- class Shell extends Composition.make('Shell', { title: 'Shell', slots: { Main: MainSlot }, ui: ShellUi }) {}
193
+ })
194
+ class PanelUi extends PanelUiBase {}
195
+
196
+ class Panel extends Composition.make('Panel', {
197
+ title: 'Panel',
198
+ ui: PanelUi,
199
+ states: [Greeting],
200
+ })<Panel>() {}
134
201
 
135
- export const slottedScene = (): Scene => {
202
+ class MainSlot extends slot('Main')<MainSlot, typeof Panel>() {}
203
+ const ShellUiBase: Ui.UiClass<{ props: {}; slots: { Main: MainSlot } }, 'Shell'> = ui('Shell')<{
204
+ props: {}
205
+ slots: { Main: MainSlot }
206
+ }>()
207
+ class ShellUi extends ShellUiBase {}
208
+
209
+ class Shell extends Composition.make('Shell', {
210
+ title: 'Shell',
211
+ slots: { Main: MainSlot },
212
+ ui: ShellUi,
213
+ })<Shell>() {}
214
+
215
+ type SlottedScene = CapturedScene<
216
+ typeof ShellUi.Contract,
217
+ readonly [],
218
+ | EngineServices
219
+ | State.StateStore<'greeting', string>
220
+ | Ui.UiService<'Panel'>
221
+ | Ui.UiService<'Shell'>
222
+ | Composition.CompositionId<'Shell', Shell, MainSlot>
223
+ | Composition.CompositionId<'Panel', Panel, never>
224
+ | SlotService<'Main', MainSlot, CompositionChildSummary<Panel>>,
225
+ unknown,
226
+ 'Shell',
227
+ Shell
228
+ >
229
+
230
+ const makeSlottedScene = (): SlottedScene => {
136
231
  const presentation = Layer.mergeAll(
137
- provide(ShellUi, Ui.make(ShellUi, (_props, slots) => createElement(slots.Main, {}))),
138
- provide(PanelUi, Ui.make(PanelUi, ({ greeting }) => greeting)),
232
+ provide(
233
+ ShellUi,
234
+ Ui.make(ShellUi, (_props, slots) => createElement(slots.Main, {})),
235
+ ),
236
+ provide(
237
+ PanelUi,
238
+ Ui.make(PanelUi, ({ greeting }) => greeting),
239
+ ),
139
240
  provide(MainSlot, Panel),
140
241
  State.live(Greeting, 'hi'),
141
242
  )
142
243
  const app = Layer.mergeAll(
143
244
  Composition.live(Shell, function* () {
245
+ yield* Effect.void
144
246
  return mount({ props: {}, slots: { Main: one({}) } })
145
247
  }),
146
248
  Composition.live(Panel, function* () {
@@ -148,63 +250,132 @@ export const slottedScene = (): Scene => {
148
250
  const greet = yield* Event.trigger(Greeted)
149
251
  const clearTrigger = yield* Event.trigger(Cleared)
150
252
  const clear = (): void => clearTrigger({})
151
- return mount({ props: { greeting }, slots: {}, events: { greet, clear } })
253
+ return mount({
254
+ props: { greeting },
255
+ slots: {},
256
+ events: { greet, clear },
257
+ })
152
258
  }),
153
259
  Reducer.live(SetGreeting, (_greeting, event) => event.text),
154
260
  Reducer.live(ClearGreeting, () => ''),
155
261
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
156
262
  return scene(Shell, { provide: [app] })
157
263
  }
264
+ export const slottedScene: typeof makeSlottedScene = makeSlottedScene
158
265
 
159
- // ── list: a parent with two slots a single-child toolbar and a dynamic list ──
160
- const ListItem = S.Struct({ id: S.String, label: S.String })
161
- class Items extends State.make('items', S.Array(ListItem)) {}
266
+ const ListItem: S.Struct<{ id: typeof S.String; label: typeof S.String }> = S.Struct({
267
+ id: S.String,
268
+ label: S.String,
269
+ })
270
+ const ItemsBase: State.StateDefinition<
271
+ 'items',
272
+ ReadonlyArray<{ readonly id: string; readonly label: string }>,
273
+ S.Array$<typeof ListItem>
274
+ > = State.make('items', S.Array(ListItem))
275
+ class Items extends ItemsBase {}
162
276
  class Added extends Event.make('Added', ListItem) {}
163
277
  class Removed extends Event.make('Removed', S.Struct({ id: S.String })) {}
164
- class AddItem extends Reducer.make('AddItem', { states: [Items], events: [Added] }) {}
165
- class RemoveItem extends Reducer.make('RemoveItem', { states: [Items], events: [Removed] }) {}
278
+ class AddItem extends Reducer.make('AddItem', {
279
+ states: [Items],
280
+ events: [Added],
281
+ }) {}
282
+ class RemoveItem extends Reducer.make('RemoveItem', {
283
+ states: [Items],
284
+ events: [Removed],
285
+ }) {}
286
+
287
+ const BarUiBase: WiredUi<
288
+ DerivedContract<{
289
+ props: S.Struct<{}>
290
+ events: { add: typeof ListItem }
291
+ }>,
292
+ 'Bar'
293
+ > = ui('Bar', {
294
+ props: S.Struct({}),
295
+ events: { add: ListItem },
296
+ })
297
+ class BarUi extends BarUiBase {}
298
+
299
+ class Bar extends Composition.make('Bar', { title: 'Bar', ui: BarUi })<Bar>() {}
300
+ const ItemUiBase: WiredUi<
301
+ DerivedContract<{
302
+ props: S.Struct<{ label: typeof S.String }>
303
+ events: { remove: S.Struct<{}> }
304
+ }>,
305
+ 'Item'
306
+ > = ui('Item', {
307
+ props: S.Struct({ label: S.String }),
308
+ events: { remove: S.Struct({}) },
309
+ })
310
+ class ItemUi extends ItemUiBase {}
166
311
 
167
- class BarUi extends ui('Bar', { props: S.Struct({}), events: { add: ListItem } }) {}
168
- class Bar extends Composition.make('Bar', { title: 'Bar', ui: BarUi }) {}
169
- class ItemUi extends ui('Item', { props: S.Struct({ label: S.String }), events: { remove: S.Struct({}) } }) {}
170
312
  class ItemComp extends Composition.make('Item', {
171
313
  title: 'Item',
172
314
  ui: ItemUi,
173
315
  props: ListItem,
174
- }) {}
175
- class BarSlot extends slot('Bar')<typeof Bar>() {}
176
- class ItemSlot extends slot('Item')<typeof ItemComp>() {}
177
- class ListUi extends ui('List')<{
316
+ })<ItemComp>() {}
317
+
318
+ class BarSlot extends slot('Bar')<BarSlot, typeof Bar>() {}
319
+
320
+ class ItemSlot extends slot('Item')<ItemSlot, typeof ItemComp>() {}
321
+ const ListUiBase: Ui.UiClass<
322
+ {
323
+ props: { items: ReadonlyArray<{ id: string; label: string }> }
324
+ slots: { Bar: BarSlot; Item: ItemSlot }
325
+ },
326
+ 'List'
327
+ > = ui('List')<{
178
328
  props: { items: ReadonlyArray<{ id: string; label: string }> }
179
329
  slots: { Bar: BarSlot; Item: ItemSlot }
180
- }>() {}
330
+ }>()
331
+ class ListUi extends ListUiBase {}
332
+
181
333
  class ListComp extends Composition.make('List', {
182
334
  title: 'List',
183
335
  slots: { Bar: BarSlot, Item: ItemSlot },
184
336
  ui: ListUi,
185
337
  states: [Items],
186
- }) {}
338
+ })<ListComp>() {}
187
339
 
188
- export const listScene = (
340
+ type ListScene = CapturedScene<
341
+ typeof ListUi.Contract,
342
+ readonly [typeof Items],
343
+ | EngineServices
344
+ | Ui.UiService<'Item'>
345
+ | State.StateStore<'items', ReadonlyArray<{ readonly id: string; readonly label: string }>>
346
+ | Ui.UiService<'Bar'>
347
+ | Ui.UiService<'List'>
348
+ | Composition.CompositionId<'List', ListComp, BarSlot | ItemSlot>
349
+ | Composition.CompositionId<'Bar', Bar, never>
350
+ | Composition.CompositionId<'Item', ItemComp, never>
351
+ | SlotService<'Bar', BarSlot, CompositionChildSummary<Bar>>
352
+ | SlotService<'Item', ItemSlot, CompositionChildSummary<ItemComp>>,
353
+ unknown,
354
+ 'List',
355
+ ListComp
356
+ >
357
+
358
+ const makeListScene = (
189
359
  initial: ReadonlyArray<{ id: string; label: string }> = [
190
360
  { id: 'a', label: 'A' },
191
361
  { id: 'b', label: 'B' },
192
362
  ],
193
- ): Scene => {
363
+ ): ListScene => {
194
364
  const presentation = Layer.mergeAll(
195
365
  provide(
196
366
  ListUi,
197
367
  Ui.make(ListUi, (_props, slots) =>
198
- createElement(
199
- Fragment,
200
- null,
201
- createElement(slots.Bar, {}),
202
- createElement(slots.Item, {}),
203
- ),
368
+ createElement(Fragment, null, createElement(slots.Bar, {}), createElement(slots.Item, {})),
204
369
  ),
205
370
  ),
206
- provide(BarUi, Ui.make(BarUi, () => null)),
207
- provide(ItemUi, Ui.make(ItemUi, ({ label }) => label)),
371
+ provide(
372
+ BarUi,
373
+ Ui.make(BarUi, () => null),
374
+ ),
375
+ provide(
376
+ ItemUi,
377
+ Ui.make(ItemUi, ({ label }) => label),
378
+ ),
208
379
  provide(BarSlot, Bar),
209
380
  provide(ItemSlot, ItemComp),
210
381
  State.live(Items, initial),
@@ -228,31 +399,36 @@ export const listScene = (
228
399
  return mount({ props: {}, slots: {}, events: { add } })
229
400
  }),
230
401
  Composition.live(ItemComp, function* () {
231
- const itemProps = S.decodeUnknownSync(ListItem)(yield* Props)
402
+ const itemProps = yield* ItemComp.props
232
403
  const removeTrigger = yield* Event.trigger(Removed)
233
- // The item binds its own id, so the wire `remove` carries no payload — the
234
- // client fires it knowing only the handle.
404
+ // The item binds its id so the wire event carries no payload.
235
405
  const remove = (): void => removeTrigger({ id: itemProps.id })
236
- return mount({ props: { label: itemProps.label }, slots: {}, events: { remove } })
406
+ return mount({
407
+ props: { label: itemProps.label },
408
+ slots: {},
409
+ events: { remove },
410
+ })
237
411
  }),
238
412
  Reducer.live(AddItem, (current, event) => [...current, { id: event.id, label: event.label }]),
239
413
  Reducer.live(RemoveItem, (current, event) => current.filter((entry) => entry.id !== event.id)),
240
414
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
241
415
  return scene(ListComp, { provide: [app] })
242
416
  }
417
+ export const listScene: typeof makeListScene = makeListScene
243
418
 
244
- // ── async: a counter whose state changes AFTER the opening snapshot, with no client
245
- // invoke. A boot event kicks a procedure that sleeps, then dispatches `Bumped` — modelling
246
- // a background load (a repo list, a reconcile sweep) that resolves long after connect. This
247
- // is the ONLY fixture that exercises the server-initiated streaming push: every other scene
248
- // settles before its first render, so its full state is already in the snapshot. ──────────
249
419
  class Kick extends Event.make('Kick', S.Struct({ to: S.Number })) {}
250
420
  class Loader extends Channel.make('Loader', { policy: { _tag: 'latest' } }) {}
251
- class DelayedBump extends Procedure.make('DelayedBump', { channel: Loader, events: [Kick] }) {}
421
+ class DelayedBump extends Procedure.make('DelayedBump', {
422
+ channel: Loader,
423
+ events: [Kick],
424
+ }) {}
252
425
 
253
- export const asyncCounterScene = (delay: Duration.DurationInput = '40 millis'): Scene => {
426
+ const makeAsyncCounterScene = (delay: Duration.DurationInput = '40 millis'): CounterScene => {
254
427
  const presentation = Layer.mergeAll(
255
- provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
428
+ provide(
429
+ CounterUi,
430
+ Ui.make(CounterUi, ({ count }) => `count:${count}`),
431
+ ),
256
432
  StateGroup.live(Counters, { count: 0 }),
257
433
  )
258
434
  const app = Layer.mergeAll(
@@ -262,16 +438,16 @@ export const asyncCounterScene = (delay: Duration.DurationInput = '40 millis'):
262
438
  return mount({ props: { count }, slots: {}, events: { bump } })
263
439
  }),
264
440
  Reducer.live(Bump, (count, event) => count + event.by),
265
- // The procedure runs on the bus AFTER the scene boots: it sleeps past the opening
266
- // snapshot, then dispatches `Bumped` — so the only way the client learns the new count
267
- // is the server pushing a diff nobody asked for.
268
441
  Procedure.live(DelayedBump, function* (event) {
269
442
  yield* Effect.sleep(delay)
270
443
  yield* Event.dispatch(Bumped, { by: event.to })
271
444
  }),
272
- // The procedure runs on a named channel; without its queue+fiber (`Channel.live`)
273
- // the boot `Kick` is dropped and nothing ever bumps.
445
+ // Without Channel.live the boot Kick is dropped.
274
446
  Channel.live(Loader),
275
447
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
276
- return scene(Counter, { provide: [app], boot: [Event.construct(Kick, { to: 7 })] })
448
+ return scene(Counter, {
449
+ provide: [app],
450
+ boot: [Event.construct(Kick, { to: 7 })],
451
+ })
277
452
  }
453
+ export const asyncCounterScene: typeof makeAsyncCounterScene = makeAsyncCounterScene
package/src/index.ts CHANGED
@@ -1,13 +1,3 @@
1
- // @playfast/reform-remote — run a reform scene's logic on the server and stream
2
- // its rendered UI to a thin client over a transport. A fourth consumer of a
3
- // `Scene`, alongside @playfast/react, @playfast/react-native, and @playfast/proof.
4
- // See REMOTE_UI.md.
5
- //
6
- // Public API is exposed two ways (Effect-style): namespace barrels for discovery
7
- // (`import { Transport } from "@playfast/reform-remote"`) and per-path subpaths
8
- // for direct use (`import { connect } from "@playfast/reform-remote/transport"`).
9
- // The select re-exports below keep the common entry points nameable from the root.
10
-
11
1
  export * as Server from './server'
12
2
  export * as Client from './client'
13
3
  export * as Transport from './transport'
package/src/memory.ts CHANGED
@@ -1,14 +1,5 @@
1
1
  import type { RemoteTransport } from './transport'
2
2
 
3
- /**
4
- * The in-memory transport adapter: a duplex pair where what one end sends, the
5
- * other receives — synchronously, in process. It is the simplest concrete
6
- * `RemoteTransport`, and the one the test suite drives `serve`/`connect` over
7
- * without a socket. A WebSocket adapter is the same shape with JSON framing
8
- * across a wire; proving the loop here proves it everywhere.
9
- */
10
-
11
- /** A linked `server`/`client` transport: each receives exactly what the other sends. */
12
3
  export interface TransportPair<Server, Client> {
13
4
  readonly server: RemoteTransport<Server, Client>
14
5
  readonly client: RemoteTransport<Client, Server>
package/src/react.ts CHANGED
@@ -1,62 +1,31 @@
1
1
  import { type ReactNode, useEffect, useState, useSyncExternalStore } from 'react'
2
- import {
3
- connect,
4
- type InvokeMessage,
5
- type RemoteTransport,
6
- type ServerMessage,
7
- } from './transport'
2
+ import { connect, type InvokeMessage, type RemoteTransport, type ServerMessage } from './transport'
8
3
  import type { RemoteContract, RemoteViewSet } from './client'
9
4
 
10
- /**
11
- * The React binding for the remote client. A consumer hands it a transport and a
12
- * `views` map and gets a live React subtree — the framework owns the transport
13
- * wiring (connect, the patch subscription, re-render on each frame, teardown on
14
- * unmount). No `useSyncExternalStore`, `binding.subscribe`, or `binding.node()`
15
- * at the call site.
16
- */
17
-
18
- /**
19
- * Render the remote scene as React. Pass a stable `transport` (created once,
20
- * e.g. by `createWebSocketClientTransport`) — the binding is established on mount
21
- * and disposed on unmount.
22
- */
23
5
  export const useRemoteUI = <C extends RemoteContract>(
24
6
  transport: RemoteTransport<InvokeMessage, ServerMessage>,
25
7
  views: RemoteViewSet<C>,
26
8
  ): ReactNode => {
27
- // One binding for the life of the component (the transport is stable).
28
9
  const [binding] = useState(() => connect({ transport, views }))
29
10
  useEffect(() => () => binding.dispose(), [binding])
30
- // Re-render whenever a frame changes the tree; `snapshot` is a stable ref.
31
11
  useSyncExternalStore(binding.subscribe, binding.snapshot, binding.snapshot)
32
12
  return binding.node()
33
13
  }
34
14
 
35
15
  export interface RemoteUIProps<C extends RemoteContract> {
36
16
  readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
37
- /** A contract-checked view set from `remoteViews<C>(…)` — an unbranded `Record` is rejected,
38
- * so `<RemoteUI>` can only render views proven to implement the server's shape. */
39
17
  readonly views: RemoteViewSet<C>
40
18
  }
41
19
 
42
- /** Component form of {@link useRemoteUI}: `<RemoteUI transport={…} views={…} />`. `C` is
43
- * inferred from `views` (the `remoteViews<AppContract>(…)` result), so the whole subtree is
44
- * typed to that app's contract. */
45
20
  export const RemoteUI = <C extends RemoteContract>({
46
21
  transport,
47
22
  views,
48
23
  }: RemoteUIProps<C>): ReactNode => useRemoteUI(transport, views)
49
24
 
50
- /**
51
- * Anything that reports an observable connection status — implemented by the
52
- * WebSocket client transport. Kept structural so the React binding needs no
53
- * dependency on a specific adapter.
54
- */
55
25
  export interface StatusReporter<S> {
56
26
  readonly status: () => S
57
27
  readonly onStatusChange: (listener: () => void) => () => void
58
28
  }
59
29
 
60
- /** Subscribe to a transport's connection status (e.g. to show a reconnecting badge). */
61
30
  export const useConnectionStatus = <S>(reporter: StatusReporter<S>): S =>
62
31
  useSyncExternalStore(reporter.onStatusChange, reporter.status, reporter.status)
@@ -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,11 @@ Ui.make(FooUi, (_props, _slots, events) => {
41
31
  return null
42
32
  })
43
33
 
44
- // A local contract with a slot.
45
- class FooComp extends Composition.make('FooComp', { title: 'Foo', ui: FooUi }) {}
46
- class FooSlot extends slot('Foo')<typeof FooComp>() {}
34
+ class FooComp extends Composition.make('FooComp', { title: 'Foo', ui: FooUi })<FooComp>() {}
35
+
36
+ class FooSlot extends slot('Foo')<FooSlot, typeof FooComp>() {}
47
37
  class ShellUi extends ui('Shell', { props: S.Struct({}), slots: { Main: FooSlot } }) {}
48
38
 
49
- // ✓ slot components are keyed by the contract's slot names.
50
39
  const shellView = Ui.make(ShellUi, (_props, slots) => createElement(slots.Main, {}))
51
40
 
52
41
  Ui.make(ShellUi, (_props, slots) =>
@@ -54,12 +43,9 @@ Ui.make(ShellUi, (_props, slots) =>
54
43
  createElement(slots.Other, {}),
55
44
  )
56
45
 
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
46
  const AppContractValue = remoteContract({ Foo: FooUi, Shell: ShellUi })
60
47
  type AppContract = typeof AppContractValue
61
48
 
62
- // ✓ a view per contract, each recovering its own name + schema; implements the full shape.
63
49
  remoteViews<AppContract>({ Foo: fooView, Shell: shellView })
64
50
 
65
51
  // @ts-expect-error — a view set MISSING `Shell` does not implement the server's shape.