@playfast/reform-remote 0.0.5 → 0.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 +3 -10
- package/package.json +1 -1
- package/src/client.ts +41 -30
- package/src/fixtures.ts +15 -14
- package/src/index.ts +4 -0
- package/src/server.ts +189 -120
- package/src/transport.test.ts +102 -1
- package/src/transport.ts +205 -29
package/README.md
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
A fourth consumer of a reform [`Scene`](https://www.npmjs.com/package/@playfast/reform), alongside
|
|
12
|
-
[`@playfast/react`](https://www.npmjs.com/package/@playfast/react),
|
|
13
|
-
[`@playfast/react-native`](https://www.npmjs.com/package/@playfast/react-native), and
|
|
14
|
-
[`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof). State, events, reducers,
|
|
12
|
+
[`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react),
|
|
13
|
+
[`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native), and
|
|
14
|
+
[`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof). State, events, reducers,
|
|
15
15
|
async/remote data, and compositions all run **server-side**; the client receives a serialized
|
|
16
16
|
tree of rendered UI contracts and renders them with local presentations. The wire carries only
|
|
17
17
|
data — UI-tree patches one way, trigger invocations the other — so there is no API layer to write.
|
|
@@ -113,13 +113,6 @@ sockets pair with [`@playfast/reform-remote-node`](https://www.npmjs.com/package
|
|
|
113
113
|
| [`@playfast/reform-remote-bun`](https://www.npmjs.com/package/@playfast/reform-remote-bun) | WebSocket server | `Bun.serve` |
|
|
114
114
|
| [`@playfast/reform-remote-web`](https://www.npmjs.com/package/@playfast/reform-remote-web) | WebSocket client (factory, auto-reconnect) | global `WebSocket` |
|
|
115
115
|
|
|
116
|
-
## Status
|
|
117
|
-
|
|
118
|
-
The full loop — scene → wire tree → diff → client render → trigger → server dispatch → re-render
|
|
119
|
-
— is implemented and proven over the in-memory transport (flat, nested-slot, and dynamic-list
|
|
120
|
-
scenes) and over real WebSockets via the adapter packages above. Remaining: `provideRemote` sugar
|
|
121
|
-
that gates wiring on the `WiredUi` brand per remote.
|
|
122
|
-
|
|
123
116
|
## License
|
|
124
117
|
|
|
125
118
|
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-remote",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.0
|
|
4
|
+
"version": "0.1.0",
|
|
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/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createElement, Fragment, type ReactNode, useRef } from 'react'
|
|
2
|
-
import { Schema } from 'effect'
|
|
2
|
+
import { Option, Record as Rec, Schema } from 'effect'
|
|
3
3
|
import {
|
|
4
4
|
type MadeView,
|
|
5
5
|
Ui,
|
|
@@ -42,11 +42,17 @@ type AnyMadeView = MadeView<any>
|
|
|
42
42
|
* their runtime form. Views are authored with `Ui.make` (contract-typed); this is
|
|
43
43
|
* only the internal shape `renderWireTree` invokes them through.
|
|
44
44
|
*/
|
|
45
|
+
/** Props a slot thunk/component accepts — a React element-props boundary
|
|
46
|
+
* (`<slots.Row slotKey={id}/>`), so it stays a plain optional-field shape. */
|
|
47
|
+
export interface SlotPropsExternalApi {
|
|
48
|
+
readonly slotKey?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
export type RemoteView = (
|
|
46
52
|
props: Record<string, unknown>,
|
|
47
53
|
// A slot thunk optionally takes `{ slotKey }` to select a single keyed child (see the keyed-
|
|
48
54
|
// slot handling in `WireNodeView`); omitting it renders all of the slot's children.
|
|
49
|
-
slots: Record<string, (slotProps?:
|
|
55
|
+
slots: Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode>,
|
|
50
56
|
events: Record<string, (payload: unknown) => void>,
|
|
51
57
|
) => ReactNode
|
|
52
58
|
|
|
@@ -58,7 +64,7 @@ export type RemoteView = (
|
|
|
58
64
|
export interface RegisteredRemoteView {
|
|
59
65
|
readonly name: string
|
|
60
66
|
readonly view: RemoteView
|
|
61
|
-
readonly propsSchema
|
|
67
|
+
readonly propsSchema: Option.Option<Schema.Schema<Record<string, unknown>, unknown>>
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
/** The by-name presentation record the renderer walks — the unbranded runtime shape behind
|
|
@@ -144,17 +150,16 @@ export type RemoteViews<C extends RemoteContract> = {
|
|
|
144
150
|
* accept); an unbranded `Record` can never be substituted.
|
|
145
151
|
*/
|
|
146
152
|
export const remoteViews = <C extends RemoteContract>(views: RemoteViews<C>): RemoteViewSet<C> => {
|
|
147
|
-
const
|
|
148
|
-
const byName: ViewRegistry =
|
|
149
|
-
|
|
153
|
+
const madeViews: ReadonlyArray<AnyMadeView> = Object.values(views)
|
|
154
|
+
const byName: ViewRegistry = Rec.fromEntries(
|
|
155
|
+
madeViews.map((view): readonly [string, RegisteredRemoteView] => {
|
|
150
156
|
const viewContract = view[UiViewContract]
|
|
151
|
-
const propsSchema = viewContract.manifest.props
|
|
152
157
|
// `Node` is `ReactNode` and `ViewImpl`'s params widen to the dynamic shape the
|
|
153
158
|
// renderer calls, so the contract-typed view IS a `RemoteView` — no cast.
|
|
154
159
|
const entry: RegisteredRemoteView = {
|
|
155
160
|
name: viewContract.manifest.name,
|
|
156
161
|
view,
|
|
157
|
-
|
|
162
|
+
propsSchema: Option.fromNullable(viewContract.manifest.props),
|
|
158
163
|
}
|
|
159
164
|
return [entry.name, entry]
|
|
160
165
|
}),
|
|
@@ -163,7 +168,7 @@ export const remoteViews = <C extends RemoteContract>(views: RemoteViews<C>): Re
|
|
|
163
168
|
// function OF `C` that is never called, so it needs NO runtime value of `C` — the set is
|
|
164
169
|
// built cast-free even though the client only has the contract as a type. The renderer
|
|
165
170
|
// reads the set by string key; the brand is never read at runtime.
|
|
166
|
-
return
|
|
171
|
+
return { ...byName, [ViewSetContract]: (_contract: C): void => {} }
|
|
167
172
|
}
|
|
168
173
|
|
|
169
174
|
export interface ClientConfig<C extends RemoteContract> {
|
|
@@ -188,15 +193,13 @@ interface RenderConfig {
|
|
|
188
193
|
* slot components, and runs the registered view INSIDE this component so the view's
|
|
189
194
|
* own hooks get a fiber. A child slot renders its wire children as nested `WireNodeView`s.
|
|
190
195
|
*/
|
|
191
|
-
|
|
192
|
-
node,
|
|
193
|
-
tree,
|
|
194
|
-
config,
|
|
195
|
-
}: {
|
|
196
|
+
interface WireNodeViewProps {
|
|
196
197
|
readonly node: WireNode
|
|
197
198
|
readonly tree: WireTree
|
|
198
199
|
readonly config: RenderConfig
|
|
199
|
-
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
|
|
200
203
|
// The latest render inputs, for the stable slot closures below to read. Mutated every
|
|
201
204
|
// render so a slot always renders against the current tree, even though its function
|
|
202
205
|
// identity never changes.
|
|
@@ -214,18 +217,24 @@ const WireNodeView = ({
|
|
|
214
217
|
const slotCache = useRef<Record<string, () => ReactNode>>({})
|
|
215
218
|
|
|
216
219
|
const registered = config.views[node.name]
|
|
217
|
-
if (registered === undefined)
|
|
218
|
-
|
|
219
|
-
const encoded: Record<string, unknown> = {}
|
|
220
|
-
const events: Record<string, (payload: unknown) => void> = {}
|
|
221
|
-
for (const prop of node.props) {
|
|
222
|
-
if (prop._tag === 'Data') encoded[prop.name] = prop.value
|
|
223
|
-
else events[prop.name] = (payload) => config.invoke(prop.handle, payload)
|
|
220
|
+
if (registered === undefined) {
|
|
221
|
+
return null
|
|
224
222
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
223
|
+
|
|
224
|
+
const encoded: Record<string, unknown> = Rec.fromEntries(
|
|
225
|
+
node.props.flatMap((prop) => (prop._tag === 'Data' ? [[prop.name, prop.value] as const] : [])),
|
|
226
|
+
)
|
|
227
|
+
const events: Record<string, (payload: unknown) => void> = Rec.fromEntries(
|
|
228
|
+
node.props.flatMap((prop) =>
|
|
229
|
+
prop._tag === 'Event'
|
|
230
|
+
? [[prop.name, (payload: unknown) => config.invoke(prop.handle, payload)] as const]
|
|
231
|
+
: [],
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
const props: Record<string, unknown> = Option.match(registered.propsSchema, {
|
|
235
|
+
onNone: () => encoded,
|
|
236
|
+
onSome: (schema) => Schema.decodeUnknownSync(schema)(encoded),
|
|
237
|
+
})
|
|
229
238
|
|
|
230
239
|
// A slot resolves to a COMPONENT rendering that slot's wire children — usable as
|
|
231
240
|
// `<slots.Foo/>` (Ui.make convention) or `slots.Foo()` (thunk convention). The function is
|
|
@@ -238,10 +247,12 @@ const WireNodeView = ({
|
|
|
238
247
|
// per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
|
|
239
248
|
// `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
|
|
240
249
|
// singleton slot rendered once, e.g. `<slots.Create/>`).
|
|
241
|
-
const slotFor = (slotName: string): ((slotProps?:
|
|
250
|
+
const slotFor = (slotName: string): ((slotProps?: SlotPropsExternalApi) => ReactNode) => {
|
|
242
251
|
const cached = slotCache.current[slotName]
|
|
243
|
-
if (cached !== undefined)
|
|
244
|
-
|
|
252
|
+
if (cached !== undefined) {
|
|
253
|
+
return cached
|
|
254
|
+
}
|
|
255
|
+
const stable = (slotProps?: SlotPropsExternalApi): ReactNode => {
|
|
245
256
|
const { node: currentNode, tree: currentTree, config: currentConfig } = latest.current
|
|
246
257
|
const requestedKey = slotProps?.slotKey
|
|
247
258
|
return createElement(
|
|
@@ -268,7 +279,7 @@ const WireNodeView = ({
|
|
|
268
279
|
// wire children this frame (e.g. an empty list) gets a component that renders nothing,
|
|
269
280
|
// rather than `undefined` (which React rejects as an invalid element type). Mirrors the
|
|
270
281
|
// engine's total slot proxies — every declared slot is always callable.
|
|
271
|
-
const slots: Record<string, (slotProps?:
|
|
282
|
+
const slots: Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode> = new Proxy(
|
|
272
283
|
Object.create(null),
|
|
273
284
|
{ get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined) },
|
|
274
285
|
)
|
package/src/fixtures.ts
CHANGED
|
@@ -52,7 +52,8 @@ export class CounterUi extends CounterUiBase {}
|
|
|
52
52
|
class Counter extends Composition.make('Counter', { title: 'Counter', ui: CounterUi, states: [Count] }) {}
|
|
53
53
|
|
|
54
54
|
/** A boot event the counter scene dispatches before its first render. */
|
|
55
|
-
export const bumpedBy = (
|
|
55
|
+
export const bumpedBy = (amount: number): EventOf<'Bumped', { by: number }> =>
|
|
56
|
+
Event.construct(Bumped, { by: amount })
|
|
56
57
|
|
|
57
58
|
export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number }>>): Scene => {
|
|
58
59
|
const presentation = Layer.mergeAll(
|
|
@@ -65,7 +66,7 @@ export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number
|
|
|
65
66
|
const bump = yield* Event.trigger(Bumped)
|
|
66
67
|
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
67
68
|
}),
|
|
68
|
-
Reducer.live(Bump, (
|
|
69
|
+
Reducer.live(Bump, (count, event) => count + event.by),
|
|
69
70
|
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
70
71
|
return scene(Counter, boot === undefined ? { provide: [app] } : { provide: [app], boot })
|
|
71
72
|
}
|
|
@@ -85,7 +86,7 @@ export const structureCounterScene = (): Scene => {
|
|
|
85
86
|
const bump = yield* Event.trigger(Bumped)
|
|
86
87
|
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
87
88
|
}),
|
|
88
|
-
Reducer.live(Bump, (
|
|
89
|
+
Reducer.live(Bump, (count, event) => count + event.by),
|
|
89
90
|
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
90
91
|
return scene(Counter, { provide: [app] })
|
|
91
92
|
}
|
|
@@ -210,14 +211,14 @@ export const listScene = (
|
|
|
210
211
|
)
|
|
211
212
|
const app = Layer.mergeAll(
|
|
212
213
|
Composition.live(ListComp, function* () {
|
|
213
|
-
const
|
|
214
|
+
const listItems = yield* Items
|
|
214
215
|
return mount({
|
|
215
|
-
props: { items },
|
|
216
|
+
props: { items: listItems },
|
|
216
217
|
slots: {
|
|
217
218
|
Bar: one({}),
|
|
218
|
-
Item: each(
|
|
219
|
-
key: (
|
|
220
|
-
props: (
|
|
219
|
+
Item: each(listItems, {
|
|
220
|
+
key: (entry) => entry.id,
|
|
221
|
+
props: (entry) => ({ id: entry.id, label: entry.label }),
|
|
221
222
|
}),
|
|
222
223
|
},
|
|
223
224
|
})
|
|
@@ -227,15 +228,15 @@ export const listScene = (
|
|
|
227
228
|
return mount({ props: {}, slots: {}, events: { add } })
|
|
228
229
|
}),
|
|
229
230
|
Composition.live(ItemComp, function* () {
|
|
230
|
-
const
|
|
231
|
+
const itemProps = S.decodeUnknownSync(ListItem)(yield* Props)
|
|
231
232
|
const removeTrigger = yield* Event.trigger(Removed)
|
|
232
233
|
// The item binds its own id, so the wire `remove` carries no payload — the
|
|
233
234
|
// client fires it knowing only the handle.
|
|
234
|
-
const remove = (): void => removeTrigger({ id:
|
|
235
|
-
return mount({ props: { label:
|
|
235
|
+
const remove = (): void => removeTrigger({ id: itemProps.id })
|
|
236
|
+
return mount({ props: { label: itemProps.label }, slots: {}, events: { remove } })
|
|
236
237
|
}),
|
|
237
|
-
Reducer.live(AddItem, (
|
|
238
|
-
Reducer.live(RemoveItem, (
|
|
238
|
+
Reducer.live(AddItem, (current, event) => [...current, { id: event.id, label: event.label }]),
|
|
239
|
+
Reducer.live(RemoveItem, (current, event) => current.filter((entry) => entry.id !== event.id)),
|
|
239
240
|
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
240
241
|
return scene(ListComp, { provide: [app] })
|
|
241
242
|
}
|
|
@@ -260,7 +261,7 @@ export const asyncCounterScene = (delay: Duration.DurationInput = '40 millis'):
|
|
|
260
261
|
const bump = yield* Event.trigger(Bumped)
|
|
261
262
|
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
262
263
|
}),
|
|
263
|
-
Reducer.live(Bump, (
|
|
264
|
+
Reducer.live(Bump, (count, event) => count + event.by),
|
|
264
265
|
// The procedure runs on the bus AFTER the scene boots: it sleeps past the opening
|
|
265
266
|
// snapshot, then dispatches `Bumped` — so the only way the client learns the new count
|
|
266
267
|
// is the server pushing a diff nobody asked for.
|
package/src/index.ts
CHANGED
|
@@ -35,8 +35,12 @@ export {
|
|
|
35
35
|
type RemoteTransport,
|
|
36
36
|
serve,
|
|
37
37
|
type ServeOptions,
|
|
38
|
+
serveShared,
|
|
39
|
+
type ServeSharedOptions,
|
|
38
40
|
type ServerBinding,
|
|
39
41
|
type ServerMessage,
|
|
42
|
+
type SharedClientHandle,
|
|
43
|
+
type SharedServerBinding,
|
|
40
44
|
type SnapshotMessage,
|
|
41
45
|
} from './transport'
|
|
42
46
|
export { inMemoryTransportPair, type TransportPair } from './memory'
|
package/src/server.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Array as Arr,
|
|
3
|
+
Effect,
|
|
4
|
+
Layer,
|
|
5
|
+
Match,
|
|
6
|
+
ManagedRuntime,
|
|
7
|
+
Option,
|
|
8
|
+
type ParseResult,
|
|
9
|
+
PubSub,
|
|
10
|
+
Queue,
|
|
11
|
+
Record as Rec,
|
|
12
|
+
Schema,
|
|
13
|
+
} from 'effect'
|
|
2
14
|
import {
|
|
3
15
|
Bus,
|
|
4
16
|
Composition,
|
|
@@ -55,10 +67,14 @@ const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
|
|
|
55
67
|
// is `Record<never, never>`, so each value is `never` — assignable to
|
|
56
68
|
// `Trigger<unknown>` without a cast — and an omitted `events` defaults to empty.
|
|
57
69
|
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
|
|
58
|
-
|
|
70
|
+
Option.match(Option.fromNullable(structure.events), {
|
|
71
|
+
onNone: () => ({}),
|
|
72
|
+
onSome: (events) => events,
|
|
73
|
+
})
|
|
59
74
|
|
|
60
75
|
const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
|
|
61
|
-
scene.provide
|
|
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<
|
|
62
78
|
RuntimeServices,
|
|
63
79
|
never,
|
|
64
80
|
never
|
|
@@ -69,11 +85,11 @@ interface Mounted {
|
|
|
69
85
|
readonly comp: CompositionClass<unknown>
|
|
70
86
|
readonly props: unknown
|
|
71
87
|
readonly id: string
|
|
72
|
-
readonly parentId: string
|
|
73
|
-
readonly slot: string
|
|
88
|
+
readonly parentId: Option.Option<string>
|
|
89
|
+
readonly slot: Option.Option<string>
|
|
74
90
|
readonly childIndex: number
|
|
75
|
-
/** The React `key` the parent gave this slot child,
|
|
76
|
-
readonly key: string
|
|
91
|
+
/** The React `key` the parent gave this slot child, if any — the keyed-slot selector. */
|
|
92
|
+
readonly key: Option.Option<string>
|
|
77
93
|
}
|
|
78
94
|
|
|
79
95
|
const childComposition = (child: SlotChild): CompositionClass<unknown> =>
|
|
@@ -92,131 +108,151 @@ const noRegister: TriggerRegistryApi['register'] = () => Effect.void
|
|
|
92
108
|
// structure (`structure.events`) and are registered through `register` (the live
|
|
93
109
|
// registry for a streaming server, a no-op for a one-shot render). Produces the
|
|
94
110
|
// wire shape the transport and `Wire.diff` consume.
|
|
95
|
-
const toWireNodeFromStructure = (
|
|
96
|
-
|
|
111
|
+
const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
|
|
112
|
+
mounted: Mounted,
|
|
97
113
|
structure: Structure<UiContract>,
|
|
98
114
|
register: TriggerRegistryApi['register'],
|
|
99
|
-
): Effect.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const name = manifest.name
|
|
115
|
+
): Effect.fn.Return<WireNode, ParseResult.ParseError> {
|
|
116
|
+
const manifest: UiManifest = mounted.comp.manifest.ui.manifest
|
|
117
|
+
const name = manifest.name
|
|
103
118
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
|
|
122
|
+
onNone: () => Effect.succeed({}),
|
|
123
|
+
onSome: (schema) => Schema.encodeUnknown(schema)(structure.props),
|
|
124
|
+
})
|
|
125
|
+
const dataProps: ReadonlyArray<WireProp> = Rec.toEntries(encodedProps).map(
|
|
126
|
+
([propName, propValue]): WireProp => ({ _tag: 'Data', name: propName, value: propValue }),
|
|
127
|
+
)
|
|
111
128
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const schema = eventSchemas
|
|
117
|
-
if (schema
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
const eventSchemas = Option.fromNullable(manifest.events)
|
|
130
|
+
const events = eventsOf(structure)
|
|
131
|
+
const eventProps: ReadonlyArray<WireProp> = yield* Effect.forEach(Rec.toEntries(events), ([eventName, trigger]) =>
|
|
132
|
+
Effect.gen(function* () {
|
|
133
|
+
const schema = Option.flatMap(eventSchemas, (schemas) => Rec.get(schemas, eventName))
|
|
134
|
+
if (Option.isNone(schema)) {
|
|
135
|
+
return Option.none<WireProp>()
|
|
136
|
+
}
|
|
137
|
+
const handle = `${mounted.id}:${eventName}`
|
|
138
|
+
yield* register(handle, trigger, schema.value)
|
|
139
|
+
return Option.some<WireProp>({ _tag: 'Event', name: eventName, handle })
|
|
140
|
+
}),
|
|
141
|
+
).pipe(Effect.map(Arr.getSomes))
|
|
122
142
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
143
|
+
return {
|
|
144
|
+
id: mounted.id,
|
|
145
|
+
name,
|
|
146
|
+
parentId: Option.getOrNull(mounted.parentId),
|
|
147
|
+
childIndex: mounted.childIndex,
|
|
148
|
+
slot: Option.getOrNull(mounted.slot),
|
|
149
|
+
key: Option.getOrNull(mounted.key),
|
|
150
|
+
props: [...dataProps, ...eventProps],
|
|
151
|
+
}
|
|
152
|
+
})
|
|
133
153
|
|
|
134
154
|
// Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
|
|
135
155
|
// UiContract>` erases its per-slot fill types at this boundary (the strict typing
|
|
136
156
|
// lives on the concrete contract), so the runtime fills arrive as `unknown` and
|
|
137
157
|
// are recovered structurally — no `as`.
|
|
138
|
-
const isSlotFill = (
|
|
139
|
-
typeof
|
|
140
|
-
|
|
141
|
-
'_tag' in
|
|
142
|
-
(
|
|
158
|
+
const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
|
|
159
|
+
typeof candidate === 'object' &&
|
|
160
|
+
candidate !== null &&
|
|
161
|
+
'_tag' in candidate &&
|
|
162
|
+
(candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
|
|
143
163
|
|
|
144
164
|
// Read a structure's fills by slot name, recovering each erased value via the
|
|
145
165
|
// discriminant guard. Returns a plain record keyed by declared slot name.
|
|
146
166
|
const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
isSlotFill(
|
|
167
|
+
Rec.fromEntries(
|
|
168
|
+
Rec.toEntries(structure.slots).flatMap(([slotName, fillValue]) =>
|
|
169
|
+
isSlotFill(fillValue) ? [[slotName, fillValue] as const] : [],
|
|
150
170
|
),
|
|
151
171
|
)
|
|
152
172
|
|
|
153
173
|
// Enqueue the children a single declared slot is filled with, reading the fill
|
|
154
174
|
// (Each / One / Absent) from the returned `Structure`. Multiplicity, per-item
|
|
155
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
|
+
const slotsOf = (comp: CompositionClass<unknown>): Record<string, SlotClass> =>
|
|
178
|
+
Option.getOrElse(Option.fromNullable(comp.manifest.slots), () => ({}))
|
|
179
|
+
|
|
156
180
|
const enqueueStructureSlot = (
|
|
157
181
|
parent: Mounted,
|
|
158
182
|
slotName: string,
|
|
159
183
|
child: CompositionClass<unknown>,
|
|
160
184
|
fill: SlotFill<unknown>,
|
|
161
|
-
|
|
162
|
-
): void =>
|
|
185
|
+
): ReadonlyArray<Mounted> =>
|
|
163
186
|
Match.value(fill).pipe(
|
|
164
|
-
Match.tag('Each', (each) =>
|
|
165
|
-
each.items.
|
|
166
|
-
|
|
187
|
+
Match.tag('Each', (each) =>
|
|
188
|
+
each.items.map(
|
|
189
|
+
(entry, index): Mounted => ({
|
|
167
190
|
comp: child,
|
|
168
|
-
props:
|
|
191
|
+
props: entry.props,
|
|
169
192
|
id: `${parent.id}.${slotName}.${index}`,
|
|
170
|
-
parentId: parent.id,
|
|
171
|
-
slot: slotName,
|
|
193
|
+
parentId: Option.some(parent.id),
|
|
194
|
+
slot: Option.some(slotName),
|
|
172
195
|
childIndex: index,
|
|
173
|
-
key:
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
Match.tag('One', (single) =>
|
|
178
|
-
|
|
196
|
+
key: Option.fromNullable(entry.key),
|
|
197
|
+
}),
|
|
198
|
+
),
|
|
199
|
+
),
|
|
200
|
+
Match.tag('One', (single) => [
|
|
201
|
+
{
|
|
179
202
|
comp: child,
|
|
180
203
|
props: single.props,
|
|
181
204
|
id: `${parent.id}.${slotName}.0`,
|
|
182
|
-
parentId: parent.id,
|
|
183
|
-
slot: slotName,
|
|
205
|
+
parentId: Option.some(parent.id),
|
|
206
|
+
slot: Option.some(slotName),
|
|
184
207
|
childIndex: 0,
|
|
185
|
-
key:
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
Match.tag('Absent', () =>
|
|
208
|
+
key: Option.none(),
|
|
209
|
+
} satisfies Mounted,
|
|
210
|
+
]),
|
|
211
|
+
Match.tag('Absent', () => []),
|
|
189
212
|
Match.exhaustive,
|
|
190
213
|
)
|
|
191
214
|
|
|
192
|
-
const collect = (
|
|
215
|
+
const collect = Effect.fn('collect')(function* (
|
|
193
216
|
root: CompositionClass<unknown>,
|
|
194
|
-
): Effect.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
217
|
+
): Effect.fn.Return<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> {
|
|
218
|
+
const bindings = new Map<SlotClass, CompositionClass<unknown>>()
|
|
219
|
+
// `walk` recurses, so it carries an explicit type so its body can reference itself cast-free.
|
|
220
|
+
const walk: (comp: CompositionClass<unknown>) => Effect.Effect<void, never, SlotChild> = Effect.fn(
|
|
221
|
+
'walk',
|
|
222
|
+
)(function* (comp: CompositionClass<unknown>): Effect.fn.Return<void, never, SlotChild> {
|
|
223
|
+
yield* Effect.forEach(Rec.values(slotsOf(comp)), (slotClass) =>
|
|
198
224
|
Effect.gen(function* () {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const child = childComposition(yield* slotClass.tag)
|
|
202
|
-
bindings.set(slotClass, child)
|
|
203
|
-
yield* walk(child)
|
|
225
|
+
if (bindings.has(slotClass)) {
|
|
226
|
+
return
|
|
204
227
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
228
|
+
const child = childComposition(yield* slotClass.tag)
|
|
229
|
+
bindings.set(slotClass, child)
|
|
230
|
+
yield* walk(child)
|
|
231
|
+
}),
|
|
232
|
+
)
|
|
208
233
|
})
|
|
234
|
+
yield* walk(root)
|
|
235
|
+
return bindings
|
|
236
|
+
})
|
|
209
237
|
|
|
210
|
-
|
|
238
|
+
// `renderLevel` recurses level by level, so it carries an explicit type so its body can
|
|
239
|
+
// reference itself cast-free.
|
|
240
|
+
const renderLevel: (
|
|
211
241
|
frontier: ReadonlyArray<Mounted>,
|
|
212
242
|
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
213
243
|
register: TriggerRegistryApi['register'],
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
244
|
+
) => Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> = Effect.fn(
|
|
245
|
+
'renderLevel',
|
|
246
|
+
)(function* (
|
|
247
|
+
frontier: ReadonlyArray<Mounted>,
|
|
248
|
+
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
249
|
+
register: TriggerRegistryApi['register'],
|
|
250
|
+
): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> {
|
|
251
|
+
if (frontier.length === 0) {
|
|
252
|
+
return []
|
|
253
|
+
}
|
|
254
|
+
const rendered = yield* Effect.forEach(frontier, (mounted) =>
|
|
255
|
+
Effect.gen(function* () {
|
|
220
256
|
const service = yield* mounted.comp.tag
|
|
221
257
|
const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
|
|
222
258
|
const frame = yield* Composition.render(service, env)
|
|
@@ -228,19 +264,27 @@ const renderLevel = (
|
|
|
228
264
|
`reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
|
|
229
265
|
)
|
|
230
266
|
}
|
|
231
|
-
|
|
267
|
+
const node = yield* toWireNodeFromStructure(mounted, frame, register)
|
|
232
268
|
const fills = structureFills(frame)
|
|
233
|
-
|
|
269
|
+
const children = Rec.toEntries(slotsOf(mounted.comp)).flatMap(([slotName, slotClass]) => {
|
|
234
270
|
const child = bindings.get(slotClass)
|
|
235
|
-
if (child === undefined)
|
|
271
|
+
if (child === undefined) {
|
|
272
|
+
return []
|
|
273
|
+
}
|
|
236
274
|
const fill = fills[slotName]
|
|
237
|
-
if (fill === undefined)
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
275
|
+
if (fill === undefined) {
|
|
276
|
+
return []
|
|
277
|
+
}
|
|
278
|
+
return enqueueStructureSlot(mounted, slotName, child, fill)
|
|
279
|
+
})
|
|
280
|
+
return { node, children }
|
|
281
|
+
}),
|
|
282
|
+
)
|
|
283
|
+
const nodes = rendered.map((entry) => entry.node)
|
|
284
|
+
const next = rendered.flatMap((entry) => entry.children)
|
|
285
|
+
const rest = yield* renderLevel(next, bindings, register)
|
|
286
|
+
return [...nodes, ...rest]
|
|
287
|
+
})
|
|
244
288
|
|
|
245
289
|
/**
|
|
246
290
|
* Render the scene's CURRENT frame to a serializable `WireTree`, breadth-first from
|
|
@@ -251,9 +295,15 @@ const renderLevel = (
|
|
|
251
295
|
* registry, while `makeRemoteServer` passes its connection registry so the streamed
|
|
252
296
|
* client can invoke handles.
|
|
253
297
|
*/
|
|
298
|
+
/** Options for {@link renderSceneToWire}; the registrar defaults to a no-op for one-shot renders. */
|
|
299
|
+
export interface RenderSceneToWireOptions {
|
|
300
|
+
readonly register: TriggerRegistryApi['register']
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// oxlint-disable-next-line reform-rules/prefer-effect-fn -- exported binding: Effect.fn's inferred type isn't portable under isolatedDeclarations
|
|
254
304
|
export const renderSceneToWire = (
|
|
255
305
|
scene: Scene,
|
|
256
|
-
options?:
|
|
306
|
+
options?: RenderSceneToWireOptions,
|
|
257
307
|
): Effect.Effect<WireTree, ParseResult.ParseError, CompositionService | SlotChild> =>
|
|
258
308
|
Effect.gen(function* () {
|
|
259
309
|
const register = options?.register ?? noRegister
|
|
@@ -262,10 +312,10 @@ export const renderSceneToWire = (
|
|
|
262
312
|
comp: scene.composition,
|
|
263
313
|
props: {},
|
|
264
314
|
id: '0',
|
|
265
|
-
parentId:
|
|
266
|
-
slot:
|
|
315
|
+
parentId: Option.none(),
|
|
316
|
+
slot: Option.none(),
|
|
267
317
|
childIndex: 0,
|
|
268
|
-
key:
|
|
318
|
+
key: Option.none(),
|
|
269
319
|
}
|
|
270
320
|
return yield* renderLevel([root], bindings, register)
|
|
271
321
|
})
|
|
@@ -290,6 +340,12 @@ export interface RemoteServer {
|
|
|
290
340
|
* no user interaction triggered. Returns an unsubscribe handle.
|
|
291
341
|
*/
|
|
292
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
|
|
293
349
|
/** Tear down the runtime and its forked fibers. */
|
|
294
350
|
readonly dispose: () => Promise<void>
|
|
295
351
|
}
|
|
@@ -312,7 +368,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
312
368
|
// binding's opening snapshot captures all state up to `start`).
|
|
313
369
|
const changeListeners = new Set<() => void>()
|
|
314
370
|
const notifyChange = (): void => {
|
|
315
|
-
|
|
371
|
+
changeListeners.forEach((listener) => listener())
|
|
316
372
|
}
|
|
317
373
|
runtime.runFork(
|
|
318
374
|
Effect.scoped(
|
|
@@ -330,31 +386,43 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
330
386
|
),
|
|
331
387
|
)
|
|
332
388
|
|
|
333
|
-
runtime.runSync(
|
|
389
|
+
runtime.runSync(
|
|
390
|
+
Effect.forEach(
|
|
391
|
+
Option.getOrElse(Option.fromNullable(scene.boot), () => []),
|
|
392
|
+
(event) => publish('High', event),
|
|
393
|
+
),
|
|
394
|
+
)
|
|
334
395
|
|
|
335
396
|
// The last emitted frame, to diff against. A const holder whose field we swap
|
|
336
397
|
// (no reassigned binding — house rule), not a `let`.
|
|
337
398
|
const frame: { tree: WireTree } = { tree: [] }
|
|
338
399
|
|
|
339
400
|
const render = (): Promise<WireTree> =>
|
|
340
|
-
runtime.runPromise(
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
401
|
+
runtime.runPromise(
|
|
402
|
+
renderEffect.pipe(
|
|
403
|
+
Effect.tap((tree) =>
|
|
404
|
+
Effect.sync(() => {
|
|
405
|
+
frame.tree = tree
|
|
406
|
+
}),
|
|
407
|
+
),
|
|
408
|
+
),
|
|
409
|
+
)
|
|
344
410
|
|
|
345
411
|
const renderDiff = (): Promise<ReadonlyArray<WirePatch>> =>
|
|
346
|
-
runtime.runPromise(
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
.
|
|
357
|
-
|
|
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
|
+
)
|
|
358
426
|
|
|
359
427
|
return {
|
|
360
428
|
render,
|
|
@@ -370,6 +438,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
370
438
|
changeListeners.add(listener)
|
|
371
439
|
return () => void changeListeners.delete(listener)
|
|
372
440
|
},
|
|
441
|
+
currentTree: () => frame.tree,
|
|
373
442
|
dispose: () => runtime.dispose(),
|
|
374
443
|
}
|
|
375
444
|
}
|
package/src/transport.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { expect, test, vi } from 'vitest'
|
|
|
4
4
|
import { Schema as S } from 'effect'
|
|
5
5
|
import { Ui, ui } from '@playfast/reform'
|
|
6
6
|
import type { InvokeMessage, ServerMessage } from './transport'
|
|
7
|
-
import { connect, serve } from './transport'
|
|
7
|
+
import { connect, serve, serveShared } from './transport'
|
|
8
8
|
import { inMemoryTransportPair } from './memory'
|
|
9
9
|
import { remoteViews } from './client'
|
|
10
10
|
import { asyncCounterScene, CounterUi, counterScene } from './fixtures'
|
|
@@ -181,6 +181,107 @@ test('two clients each get their own server runtime — state is isolated', asyn
|
|
|
181
181
|
}
|
|
182
182
|
})
|
|
183
183
|
|
|
184
|
+
test('serveShared: two clients share ONE runtime — one client drives state for both', async () => {
|
|
185
|
+
const one = inMemoryTransportPair<ServerMessage, InvokeMessage>()
|
|
186
|
+
const two = inMemoryTransportPair<ServerMessage, InvokeMessage>()
|
|
187
|
+
|
|
188
|
+
const probeOne: Probe = {}
|
|
189
|
+
const probeTwo: Probe = {}
|
|
190
|
+
const clientOne = connect({
|
|
191
|
+
transport: one.client,
|
|
192
|
+
views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeOne) }),
|
|
193
|
+
})
|
|
194
|
+
const clientTwo = connect({
|
|
195
|
+
transport: two.client,
|
|
196
|
+
views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeTwo) }),
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// One shared server; both transports attach to the same runtime.
|
|
200
|
+
const shared = serveShared({ scene: counterScene() })
|
|
201
|
+
const handleOne = shared.addClient(one.server)
|
|
202
|
+
const handleTwo = shared.addClient(two.server)
|
|
203
|
+
try {
|
|
204
|
+
// Both clients receive the opening snapshot off the shared baseline.
|
|
205
|
+
await vi.waitFor(() => {
|
|
206
|
+
draw(clientOne.node())
|
|
207
|
+
draw(clientTwo.node())
|
|
208
|
+
expect(probeOne.props).toEqual({ count: 0 })
|
|
209
|
+
expect(probeTwo.props).toEqual({ count: 0 })
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// A bump from client ONE is broadcast to BOTH — they share the same state.
|
|
213
|
+
probeOne.events?.['bump']?.({ by: 5 })
|
|
214
|
+
await vi.waitFor(() => {
|
|
215
|
+
draw(clientOne.node())
|
|
216
|
+
draw(clientTwo.node())
|
|
217
|
+
expect(probeOne.props).toEqual({ count: 5 })
|
|
218
|
+
expect(probeTwo.props).toEqual({ count: 5 })
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// And a bump from client TWO lands on the same shared counter.
|
|
222
|
+
probeTwo.events?.['bump']?.({ by: 3 })
|
|
223
|
+
await vi.waitFor(() => {
|
|
224
|
+
draw(clientOne.node())
|
|
225
|
+
draw(clientTwo.node())
|
|
226
|
+
expect(probeOne.props).toEqual({ count: 8 })
|
|
227
|
+
expect(probeTwo.props).toEqual({ count: 8 })
|
|
228
|
+
})
|
|
229
|
+
} finally {
|
|
230
|
+
handleOne.remove()
|
|
231
|
+
handleTwo.remove()
|
|
232
|
+
clientOne.dispose()
|
|
233
|
+
clientTwo.dispose()
|
|
234
|
+
await shared.dispose()
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('serveShared: a client connecting mid-stream snapshots the current shared state', async () => {
|
|
239
|
+
const one = inMemoryTransportPair<ServerMessage, InvokeMessage>()
|
|
240
|
+
|
|
241
|
+
const probeOne: Probe = {}
|
|
242
|
+
const clientOne = connect({
|
|
243
|
+
transport: one.client,
|
|
244
|
+
views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeOne) }),
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const shared = serveShared({ scene: counterScene() })
|
|
248
|
+
const handleOne = shared.addClient(one.server)
|
|
249
|
+
try {
|
|
250
|
+
await vi.waitFor(() => {
|
|
251
|
+
draw(clientOne.node())
|
|
252
|
+
expect(probeOne.props).toEqual({ count: 0 })
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
probeOne.events?.['bump']?.({ by: 4 })
|
|
256
|
+
await vi.waitFor(() => {
|
|
257
|
+
draw(clientOne.node())
|
|
258
|
+
expect(probeOne.props).toEqual({ count: 4 })
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// A late joiner must see the CURRENT shared count (4), not a fresh 0.
|
|
262
|
+
const two = inMemoryTransportPair<ServerMessage, InvokeMessage>()
|
|
263
|
+
const probeTwo: Probe = {}
|
|
264
|
+
const clientTwo = connect({
|
|
265
|
+
transport: two.client,
|
|
266
|
+
views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeTwo) }),
|
|
267
|
+
})
|
|
268
|
+
const handleTwo = shared.addClient(two.server)
|
|
269
|
+
try {
|
|
270
|
+
await vi.waitFor(() => {
|
|
271
|
+
draw(clientTwo.node())
|
|
272
|
+
expect(probeTwo.props).toEqual({ count: 4 })
|
|
273
|
+
})
|
|
274
|
+
} finally {
|
|
275
|
+
handleTwo.remove()
|
|
276
|
+
clientTwo.dispose()
|
|
277
|
+
}
|
|
278
|
+
} finally {
|
|
279
|
+
handleOne.remove()
|
|
280
|
+
clientOne.dispose()
|
|
281
|
+
await shared.dispose()
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
184
285
|
test('dispose detaches both ends: a post-dispose invoke no longer reaches the server', async () => {
|
|
185
286
|
const { server: serverTransport, client: clientTransport } = inMemoryTransportPair<
|
|
186
287
|
ServerMessage,
|
package/src/transport.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { Match } from 'effect'
|
|
1
|
+
import { Effect, Fiber, Match, Option } from 'effect'
|
|
2
2
|
import type { ReactNode } from 'react'
|
|
3
3
|
import { Wire, type WirePatch, type WireTree } from '@playfast/reform'
|
|
4
4
|
import type { Scene } from '@playfast/reform'
|
|
5
5
|
import { makeRemoteServer } from './server'
|
|
6
|
-
import { renderWireTree, type RemoteContract, type RemoteViewSet } from './client'
|
|
6
|
+
import { renderWireTree, type ClientConfig, type RemoteContract, type RemoteViewSet } from './client'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Binds the server driver and the client renderer to a transport. The wire
|
|
@@ -85,24 +85,36 @@ export const serve = (options: ServeOptions): ServerBinding => {
|
|
|
85
85
|
// a late state change is never dropped. Both a client invoke and a server-side change
|
|
86
86
|
// (the bus subscriber) funnel through here, so there is exactly one push path.
|
|
87
87
|
const started = { value: false }
|
|
88
|
-
const flight = { promise:
|
|
88
|
+
const flight = { promise: Option.none<Promise<void>>(), dirty: false }
|
|
89
89
|
const push = (): Promise<void> => {
|
|
90
|
-
if (!started.value)
|
|
91
|
-
|
|
90
|
+
if (!started.value) {
|
|
91
|
+
return Effect.runPromise(Effect.void)
|
|
92
|
+
}
|
|
93
|
+
if (Option.isSome(flight.promise)) {
|
|
92
94
|
flight.dirty = true
|
|
93
|
-
return flight.promise
|
|
95
|
+
return flight.promise.value
|
|
94
96
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
97
|
+
// `Effect.ensuring` runs the settle/re-render cleanup whether the render succeeds or fails
|
|
98
|
+
// (the old `.finally`), so the single-flight slot is never left stuck.
|
|
99
|
+
const run = Effect.runPromise(
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
const patches = yield* Effect.promise(() => server.renderDiff())
|
|
102
|
+
if (patches.length > 0) {
|
|
103
|
+
transport.send({ _tag: 'Patches', patches })
|
|
104
|
+
}
|
|
105
|
+
}).pipe(
|
|
106
|
+
Effect.ensuring(
|
|
107
|
+
Effect.sync(() => {
|
|
108
|
+
flight.promise = Option.none()
|
|
109
|
+
if (flight.dirty) {
|
|
110
|
+
flight.dirty = false
|
|
111
|
+
void push()
|
|
112
|
+
}
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
flight.promise = Option.some(run)
|
|
106
118
|
return run
|
|
107
119
|
}
|
|
108
120
|
// Server-initiated changes are flushed on a short DEBOUNCE rather than synchronously: it
|
|
@@ -111,13 +123,22 @@ export const serve = (options: ServeOptions): ServerBinding => {
|
|
|
111
123
|
// the settled result, and the background flush never races the synchronous invoke push
|
|
112
124
|
// above. A client invoke still pushes immediately (its own settle path), so user actions
|
|
113
125
|
// stay snappy; this path only carries updates no interaction triggered.
|
|
114
|
-
const debounce = {
|
|
126
|
+
const debounce = { fiber: Option.none<Fiber.RuntimeFiber<void>>() }
|
|
115
127
|
const scheduleFlush = (): void => {
|
|
116
|
-
if (!started.value || debounce.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
128
|
+
if (!started.value || Option.isSome(debounce.fiber)) {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
const fiber = Effect.runFork(
|
|
132
|
+
Effect.sleep(BACKGROUND_FLUSH_MS).pipe(
|
|
133
|
+
Effect.zipRight(
|
|
134
|
+
Effect.sync(() => {
|
|
135
|
+
debounce.fiber = Option.none()
|
|
136
|
+
void push()
|
|
137
|
+
}),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
debounce.fiber = Option.some(fiber)
|
|
121
142
|
}
|
|
122
143
|
const start = async (): Promise<void> => {
|
|
123
144
|
const tree = await server.render()
|
|
@@ -127,7 +148,11 @@ export const serve = (options: ServeOptions): ServerBinding => {
|
|
|
127
148
|
scheduleFlush()
|
|
128
149
|
}
|
|
129
150
|
const off = transport.onMessage((message) => {
|
|
130
|
-
void
|
|
151
|
+
void Effect.runPromise(
|
|
152
|
+
Effect.promise(() => server.invoke(message.handle, message.payload)).pipe(
|
|
153
|
+
Effect.zipRight(Effect.promise(push)),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
131
156
|
})
|
|
132
157
|
const offChange = server.subscribe(scheduleFlush)
|
|
133
158
|
return {
|
|
@@ -135,7 +160,159 @@ export const serve = (options: ServeOptions): ServerBinding => {
|
|
|
135
160
|
dispose: async (): Promise<void> => {
|
|
136
161
|
off()
|
|
137
162
|
offChange()
|
|
138
|
-
if (
|
|
163
|
+
if (Option.isSome(debounce.fiber)) {
|
|
164
|
+
Effect.runFork(Fiber.interrupt(debounce.fiber.value))
|
|
165
|
+
}
|
|
166
|
+
await server.dispose()
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A client's membership in a {@link SharedServerBinding}; `remove` detaches it. */
|
|
172
|
+
export interface SharedClientHandle {
|
|
173
|
+
/** Stop sending this client frames and drop its invoke listener. Call on disconnect. */
|
|
174
|
+
readonly remove: () => void
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface SharedServerBinding {
|
|
178
|
+
/**
|
|
179
|
+
* Attach a freshly-connected transport to the ONE shared runtime: send it a
|
|
180
|
+
* Snapshot of the current shared tree, then fold it into the broadcast set so
|
|
181
|
+
* every later frame — and every other client's invoke result — reaches it too.
|
|
182
|
+
* Returns a handle to detach the client when its socket closes.
|
|
183
|
+
*/
|
|
184
|
+
readonly addClient: (transport: RemoteTransport<ServerMessage, InvokeMessage>) => SharedClientHandle
|
|
185
|
+
/** Tear down the shared runtime and drop every client. */
|
|
186
|
+
readonly dispose: () => Promise<void>
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Options for {@link serveShared} — one scene, run once, shared by every client. */
|
|
190
|
+
export interface ServeSharedOptions {
|
|
191
|
+
readonly scene: Scene
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The single-instance counterpart to {@link serve}: ONE `makeRemoteServer(scene)` —
|
|
196
|
+
* one runtime, one trigger registry, one frame baseline — shared by every connected
|
|
197
|
+
* client. A client's invoke fires on the shared runtime and the resulting diff is
|
|
198
|
+
* broadcast to ALL clients, so every connection sees and drives the same state.
|
|
199
|
+
*
|
|
200
|
+
* Sync is structural: all clients hold the same baseline, so one render's diff applies
|
|
201
|
+
* to all. A client connecting mid-stream snapshots off the live `currentTree()` (after
|
|
202
|
+
* the opening render settles) and joins the broadcast set in the same tick — it never
|
|
203
|
+
* misses or double-applies a frame.
|
|
204
|
+
*/
|
|
205
|
+
export const serveShared = (options: ServeSharedOptions): SharedServerBinding => {
|
|
206
|
+
const { scene } = options
|
|
207
|
+
const server = makeRemoteServer(scene)
|
|
208
|
+
const clients = new Set<RemoteTransport<ServerMessage, InvokeMessage>>()
|
|
209
|
+
const broadcast = (message: ServerMessage): void => {
|
|
210
|
+
clients.forEach((client) => client.send(message))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The shared push loop: same coalescing single-flight as `serve`, but one render's
|
|
214
|
+
// patches fan out to EVERY client. `started` gates pushes until the opening render has
|
|
215
|
+
// set the baseline; `dirty` re-runs a render that a late change raced into mid-flight.
|
|
216
|
+
const started = { value: false }
|
|
217
|
+
const flight = { promise: Option.none<Promise<void>>(), dirty: false }
|
|
218
|
+
const push = (): Promise<void> => {
|
|
219
|
+
if (!started.value) {
|
|
220
|
+
return Effect.runPromise(Effect.void)
|
|
221
|
+
}
|
|
222
|
+
if (Option.isSome(flight.promise)) {
|
|
223
|
+
flight.dirty = true
|
|
224
|
+
return flight.promise.value
|
|
225
|
+
}
|
|
226
|
+
const run = Effect.runPromise(
|
|
227
|
+
Effect.gen(function* () {
|
|
228
|
+
const patches = yield* Effect.promise(() => server.renderDiff())
|
|
229
|
+
if (patches.length > 0) {
|
|
230
|
+
broadcast({ _tag: 'Patches', patches })
|
|
231
|
+
}
|
|
232
|
+
}).pipe(
|
|
233
|
+
Effect.ensuring(
|
|
234
|
+
Effect.sync(() => {
|
|
235
|
+
flight.promise = Option.none()
|
|
236
|
+
if (flight.dirty) {
|
|
237
|
+
flight.dirty = false
|
|
238
|
+
void push()
|
|
239
|
+
}
|
|
240
|
+
}),
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
flight.promise = Option.some(run)
|
|
245
|
+
return run
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const debounce = { fiber: Option.none<Fiber.RuntimeFiber<void>>() }
|
|
249
|
+
const scheduleFlush = (): void => {
|
|
250
|
+
if (!started.value || Option.isSome(debounce.fiber)) {
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
const fiber = Effect.runFork(
|
|
254
|
+
Effect.sleep(BACKGROUND_FLUSH_MS).pipe(
|
|
255
|
+
Effect.zipRight(
|
|
256
|
+
Effect.sync(() => {
|
|
257
|
+
debounce.fiber = Option.none()
|
|
258
|
+
void push()
|
|
259
|
+
}),
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
debounce.fiber = Option.some(fiber)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Render once up front so the shared baseline exists before any client snapshots off
|
|
267
|
+
// it. Clients `await ready`, so they never capture the empty pre-render tree. Anything
|
|
268
|
+
// that changed during the (async) opening render is flushed by the trailing scheduleFlush.
|
|
269
|
+
const ready = (async (): Promise<void> => {
|
|
270
|
+
await server.render()
|
|
271
|
+
started.value = true
|
|
272
|
+
scheduleFlush()
|
|
273
|
+
})()
|
|
274
|
+
|
|
275
|
+
const offChange = server.subscribe(scheduleFlush)
|
|
276
|
+
|
|
277
|
+
const addClient = (
|
|
278
|
+
transport: RemoteTransport<ServerMessage, InvokeMessage>,
|
|
279
|
+
): SharedClientHandle => {
|
|
280
|
+
const off = transport.onMessage((message) => {
|
|
281
|
+
void Effect.runPromise(
|
|
282
|
+
Effect.promise(() => server.invoke(message.handle, message.payload)).pipe(
|
|
283
|
+
Effect.zipRight(Effect.promise(push)),
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
})
|
|
287
|
+
// Once the baseline exists, send this client its opening Snapshot and join the
|
|
288
|
+
// broadcast set in the SAME tick — no async push can interleave between the two, so
|
|
289
|
+
// the client's first frame and every subsequent diff stay in lockstep.
|
|
290
|
+
void Effect.runPromise(
|
|
291
|
+
Effect.promise(() => ready).pipe(
|
|
292
|
+
Effect.zipRight(
|
|
293
|
+
Effect.sync(() => {
|
|
294
|
+
transport.send({ _tag: 'Snapshot', tree: server.currentTree() })
|
|
295
|
+
clients.add(transport)
|
|
296
|
+
}),
|
|
297
|
+
),
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
return {
|
|
301
|
+
remove: (): void => {
|
|
302
|
+
off()
|
|
303
|
+
clients.delete(transport)
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
addClient,
|
|
310
|
+
dispose: async (): Promise<void> => {
|
|
311
|
+
offChange()
|
|
312
|
+
if (Option.isSome(debounce.fiber)) {
|
|
313
|
+
Effect.runFork(Fiber.interrupt(debounce.fiber.value))
|
|
314
|
+
}
|
|
315
|
+
clients.clear()
|
|
139
316
|
await server.dispose()
|
|
140
317
|
},
|
|
141
318
|
}
|
|
@@ -165,10 +342,9 @@ export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): C
|
|
|
165
342
|
const { transport, views } = options
|
|
166
343
|
const state: { tree: WireTree } = { tree: [] }
|
|
167
344
|
const listeners = new Set<() => void>()
|
|
168
|
-
const config = {
|
|
345
|
+
const config: ClientConfig<C> = {
|
|
169
346
|
views,
|
|
170
|
-
invoke: (handle:
|
|
171
|
-
transport.send({ _tag: 'Invoke', handle, payload }),
|
|
347
|
+
invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
|
|
172
348
|
}
|
|
173
349
|
const off = transport.onMessage((message) => {
|
|
174
350
|
// A snapshot replaces the tree wholesale (a fresh or reconnected session);
|
|
@@ -178,7 +354,7 @@ export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): C
|
|
|
178
354
|
Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
|
|
179
355
|
Match.exhaustive,
|
|
180
356
|
)
|
|
181
|
-
|
|
357
|
+
listeners.forEach((listener) => listener())
|
|
182
358
|
})
|
|
183
359
|
return {
|
|
184
360
|
node: () => renderWireTree(state.tree, config),
|