@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/README.md +16 -16
- package/package.json +18 -18
- package/src/client-keyed-slot.test.ts +19 -28
- package/src/client-remount.test.ts +14 -28
- package/src/client.ts +73 -182
- package/src/fixtures.ts +268 -92
- package/src/index.ts +0 -10
- package/src/memory.ts +0 -9
- package/src/react.ts +1 -32
- package/src/remote-view.typecheck.ts +3 -17
- package/src/server.test.ts +28 -32
- package/src/server.ts +269 -195
- package/src/transport.test.ts +3 -26
- package/src/transport.ts +18 -96
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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', {
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+
const makeCounterScene = (
|
|
76
|
+
boot?: ReadonlyArray<EventOf<'Bumped', { by: number }>>,
|
|
77
|
+
): CounterScene => {
|
|
59
78
|
const presentation = Layer.mergeAll(
|
|
60
|
-
provide(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
120
|
-
|
|
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', {
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
class
|
|
133
|
-
|
|
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
|
-
|
|
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(
|
|
138
|
-
|
|
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({
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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', {
|
|
165
|
-
|
|
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
|
-
|
|
176
|
-
class
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
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(
|
|
207
|
-
|
|
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 =
|
|
402
|
+
const itemProps = yield* ItemComp.props
|
|
232
403
|
const removeTrigger = yield* Event.trigger(Removed)
|
|
233
|
-
// The item binds its
|
|
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({
|
|
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', {
|
|
421
|
+
class DelayedBump extends Procedure.make('DelayedBump', {
|
|
422
|
+
channel: Loader,
|
|
423
|
+
events: [Kick],
|
|
424
|
+
}) {}
|
|
252
425
|
|
|
253
|
-
|
|
426
|
+
const makeAsyncCounterScene = (delay: Duration.DurationInput = '40 millis'): CounterScene => {
|
|
254
427
|
const presentation = Layer.mergeAll(
|
|
255
|
-
provide(
|
|
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
|
-
//
|
|
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, {
|
|
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
|
-
|
|
45
|
-
|
|
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.
|