@playfast/reform-remote 0.0.4 → 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 +79 -63
- package/src/fixtures.ts +15 -14
- package/src/index.ts +5 -1
- package/src/server.ts +292 -191
- 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,60 +217,73 @@ 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
|
-
const props: Record<string, unknown> =
|
|
226
|
-
registered.propsSchema === undefined
|
|
227
|
-
? encoded
|
|
228
|
-
: Schema.decodeUnknownSync(registered.propsSchema)(encoded)
|
|
229
223
|
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
+
})
|
|
238
|
+
|
|
239
|
+
// A slot resolves to a COMPONENT rendering that slot's wire children — usable as
|
|
240
|
+
// `<slots.Foo/>` (Ui.make convention) or `slots.Foo()` (thunk convention). The function is
|
|
241
|
+
// cached so its identity is STABLE across renders (see `slotCache` above); it reads
|
|
242
|
+
// `latest.current` so each invocation renders against the current tree/config.
|
|
243
|
+
//
|
|
244
|
+
// KEYED SLOTS: when the caller passes `slotKey` (`<slots.Row slotKey={id} />`), render only
|
|
245
|
+
// the ONE wire child whose `key` matches — the per-item identity the server captured from
|
|
246
|
+
// the parent's React `key` (see WireNode.key). This is what lets a LIST slot be invoked once
|
|
247
|
+
// per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
|
|
248
|
+
// `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
|
|
249
|
+
// singleton slot rendered once, e.g. `<slots.Create/>`).
|
|
250
|
+
const slotFor = (slotName: string): ((slotProps?: SlotPropsExternalApi) => ReactNode) => {
|
|
245
251
|
const cached = slotCache.current[slotName]
|
|
246
|
-
|
|
247
|
-
cached
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
),
|
|
265
|
-
|
|
266
|
-
|
|
252
|
+
if (cached !== undefined) {
|
|
253
|
+
return cached
|
|
254
|
+
}
|
|
255
|
+
const stable = (slotProps?: SlotPropsExternalApi): ReactNode => {
|
|
256
|
+
const { node: currentNode, tree: currentTree, config: currentConfig } = latest.current
|
|
257
|
+
const requestedKey = slotProps?.slotKey
|
|
258
|
+
return createElement(
|
|
259
|
+
Fragment,
|
|
260
|
+
null,
|
|
261
|
+
...Wire.childrenOf(currentTree, currentNode.id)
|
|
262
|
+
.filter((child) => child.slot === slotName)
|
|
263
|
+
.filter((child) => requestedKey === undefined || child.key === requestedKey)
|
|
264
|
+
.map((child) =>
|
|
265
|
+
createElement(WireNodeView, {
|
|
266
|
+
key: child.id,
|
|
267
|
+
node: child,
|
|
268
|
+
tree: currentTree,
|
|
269
|
+
config: currentConfig,
|
|
270
|
+
}),
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
}
|
|
267
274
|
slotCache.current[slotName] = stable
|
|
268
|
-
|
|
275
|
+
return stable
|
|
269
276
|
}
|
|
270
277
|
|
|
278
|
+
// Resolve slots LAZILY by name: a view referencing `<slots.Item/>` when that slot has no
|
|
279
|
+
// wire children this frame (e.g. an empty list) gets a component that renders nothing,
|
|
280
|
+
// rather than `undefined` (which React rejects as an invalid element type). Mirrors the
|
|
281
|
+
// engine's total slot proxies — every declared slot is always callable.
|
|
282
|
+
const slots: Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode> = new Proxy(
|
|
283
|
+
Object.create(null),
|
|
284
|
+
{ get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined) },
|
|
285
|
+
)
|
|
286
|
+
|
|
271
287
|
return registered.view(props, slots, events)
|
|
272
288
|
}
|
|
273
289
|
|
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
|
@@ -14,7 +14,7 @@ export * as Transport from './transport'
|
|
|
14
14
|
export * as Memory from './memory'
|
|
15
15
|
export * as React from './react'
|
|
16
16
|
|
|
17
|
-
export { makeRemoteServer, type RemoteServer } from './server'
|
|
17
|
+
export { makeRemoteServer, type RemoteServer, renderSceneToWire } from './server'
|
|
18
18
|
export {
|
|
19
19
|
type ClientConfig,
|
|
20
20
|
type RegisteredRemoteView,
|
|
@@ -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,
|
|
@@ -34,6 +46,11 @@ import {
|
|
|
34
46
|
* evaluates a view. The render walk is breadth-first over the structure tree,
|
|
35
47
|
* assigning stable ids and encoding props via each contract's own wire schema
|
|
36
48
|
* (`WiredUiManifest`, Phase 1).
|
|
49
|
+
*
|
|
50
|
+
* The render walk (`renderSceneToWire`) is a free-standing effect that requires only
|
|
51
|
+
* `CompositionService | SlotChild` — so any runtime that supplies a scene's services
|
|
52
|
+
* can render a frame, not just `makeRemoteServer`'s own. `@playfast/reform-driver-shot`
|
|
53
|
+
* runs it against `@playfast/reform-drive`'s runtime to screenshot a driven state.
|
|
37
54
|
*/
|
|
38
55
|
|
|
39
56
|
// The runtime services a closed scene exposes (mirrors proof's erasure boundary:
|
|
@@ -50,10 +67,14 @@ const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
|
|
|
50
67
|
// is `Record<never, never>`, so each value is `never` — assignable to
|
|
51
68
|
// `Trigger<unknown>` without a cast — and an omitted `events` defaults to empty.
|
|
52
69
|
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
|
|
53
|
-
|
|
70
|
+
Option.match(Option.fromNullable(structure.events), {
|
|
71
|
+
onNone: () => ({}),
|
|
72
|
+
onSome: (events) => events,
|
|
73
|
+
})
|
|
54
74
|
|
|
55
75
|
const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
|
|
56
|
-
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<
|
|
57
78
|
RuntimeServices,
|
|
58
79
|
never,
|
|
59
80
|
never
|
|
@@ -64,11 +85,11 @@ interface Mounted {
|
|
|
64
85
|
readonly comp: CompositionClass<unknown>
|
|
65
86
|
readonly props: unknown
|
|
66
87
|
readonly id: string
|
|
67
|
-
readonly parentId: string
|
|
68
|
-
readonly slot: string
|
|
88
|
+
readonly parentId: Option.Option<string>
|
|
89
|
+
readonly slot: Option.Option<string>
|
|
69
90
|
readonly childIndex: number
|
|
70
|
-
/** The React `key` the parent gave this slot child,
|
|
71
|
-
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>
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
const childComposition = (child: SlotChild): CompositionClass<unknown> =>
|
|
@@ -77,6 +98,228 @@ const childComposition = (child: SlotChild): CompositionClass<unknown> =>
|
|
|
77
98
|
const handlesOf = (node: WireNode): ReadonlyArray<string> =>
|
|
78
99
|
node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
|
|
79
100
|
|
|
101
|
+
// A static render (a screenshot) has no client to receive trigger handles, so the
|
|
102
|
+
// default registrar is a no-op: the wire node still carries its `Event` props (with
|
|
103
|
+
// deterministic handles), they're just never registered for invocation.
|
|
104
|
+
const noRegister: TriggerRegistryApi['register'] = () => Effect.void
|
|
105
|
+
|
|
106
|
+
// Structure → wire encoding (plan 02 + 03b). Props come straight from the
|
|
107
|
+
// `Structure` value (data, never a view render). Event triggers ride ON the
|
|
108
|
+
// structure (`structure.events`) and are registered through `register` (the live
|
|
109
|
+
// registry for a streaming server, a no-op for a one-shot render). Produces the
|
|
110
|
+
// wire shape the transport and `Wire.diff` consume.
|
|
111
|
+
const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
|
|
112
|
+
mounted: Mounted,
|
|
113
|
+
structure: Structure<UiContract>,
|
|
114
|
+
register: TriggerRegistryApi['register'],
|
|
115
|
+
): Effect.fn.Return<WireNode, ParseResult.ParseError> {
|
|
116
|
+
const manifest: UiManifest = mounted.comp.manifest.ui.manifest
|
|
117
|
+
const name = manifest.name
|
|
118
|
+
|
|
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
|
+
)
|
|
128
|
+
|
|
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))
|
|
142
|
+
|
|
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
|
+
})
|
|
153
|
+
|
|
154
|
+
// Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
|
|
155
|
+
// UiContract>` erases its per-slot fill types at this boundary (the strict typing
|
|
156
|
+
// lives on the concrete contract), so the runtime fills arrive as `unknown` and
|
|
157
|
+
// are recovered structurally — no `as`.
|
|
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')
|
|
163
|
+
|
|
164
|
+
// Read a structure's fills by slot name, recovering each erased value via the
|
|
165
|
+
// discriminant guard. Returns a plain record keyed by declared slot name.
|
|
166
|
+
const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
|
|
167
|
+
Rec.fromEntries(
|
|
168
|
+
Rec.toEntries(structure.slots).flatMap(([slotName, fillValue]) =>
|
|
169
|
+
isSlotFill(fillValue) ? [[slotName, fillValue] as const] : [],
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
// Enqueue the children a single declared slot is filled with, reading the fill
|
|
174
|
+
// (Each / One / Absent) from the returned `Structure`. Multiplicity, per-item
|
|
175
|
+
// `key`, and props are carried as data — no view walk, no reconstruction.
|
|
176
|
+
/** A composition's declared slot map, defaulting to an empty record when it declares none. */
|
|
177
|
+
const slotsOf = (comp: CompositionClass<unknown>): Record<string, SlotClass> =>
|
|
178
|
+
Option.getOrElse(Option.fromNullable(comp.manifest.slots), () => ({}))
|
|
179
|
+
|
|
180
|
+
const enqueueStructureSlot = (
|
|
181
|
+
parent: Mounted,
|
|
182
|
+
slotName: string,
|
|
183
|
+
child: CompositionClass<unknown>,
|
|
184
|
+
fill: SlotFill<unknown>,
|
|
185
|
+
): ReadonlyArray<Mounted> =>
|
|
186
|
+
Match.value(fill).pipe(
|
|
187
|
+
Match.tag('Each', (each) =>
|
|
188
|
+
each.items.map(
|
|
189
|
+
(entry, index): Mounted => ({
|
|
190
|
+
comp: child,
|
|
191
|
+
props: entry.props,
|
|
192
|
+
id: `${parent.id}.${slotName}.${index}`,
|
|
193
|
+
parentId: Option.some(parent.id),
|
|
194
|
+
slot: Option.some(slotName),
|
|
195
|
+
childIndex: index,
|
|
196
|
+
key: Option.fromNullable(entry.key),
|
|
197
|
+
}),
|
|
198
|
+
),
|
|
199
|
+
),
|
|
200
|
+
Match.tag('One', (single) => [
|
|
201
|
+
{
|
|
202
|
+
comp: child,
|
|
203
|
+
props: single.props,
|
|
204
|
+
id: `${parent.id}.${slotName}.0`,
|
|
205
|
+
parentId: Option.some(parent.id),
|
|
206
|
+
slot: Option.some(slotName),
|
|
207
|
+
childIndex: 0,
|
|
208
|
+
key: Option.none(),
|
|
209
|
+
} satisfies Mounted,
|
|
210
|
+
]),
|
|
211
|
+
Match.tag('Absent', () => []),
|
|
212
|
+
Match.exhaustive,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
const collect = Effect.fn('collect')(function* (
|
|
216
|
+
root: CompositionClass<unknown>,
|
|
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) =>
|
|
224
|
+
Effect.gen(function* () {
|
|
225
|
+
if (bindings.has(slotClass)) {
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
const child = childComposition(yield* slotClass.tag)
|
|
229
|
+
bindings.set(slotClass, child)
|
|
230
|
+
yield* walk(child)
|
|
231
|
+
}),
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
yield* walk(root)
|
|
235
|
+
return bindings
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// `renderLevel` recurses level by level, so it carries an explicit type so its body can
|
|
239
|
+
// reference itself cast-free.
|
|
240
|
+
const renderLevel: (
|
|
241
|
+
frontier: ReadonlyArray<Mounted>,
|
|
242
|
+
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
243
|
+
register: TriggerRegistryApi['register'],
|
|
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* () {
|
|
256
|
+
const service = yield* mounted.comp.tag
|
|
257
|
+
const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
|
|
258
|
+
const frame = yield* Composition.render(service, env)
|
|
259
|
+
// Every composition returns a `Structure` (the Node render path is gone): its
|
|
260
|
+
// wire node's props come from `frame.props`, event triggers from `frame.events`,
|
|
261
|
+
// and children are enqueued by reading each declared slot's fill — no view eval.
|
|
262
|
+
if (!isStructure(frame)) {
|
|
263
|
+
return yield* Effect.dieMessage(
|
|
264
|
+
`reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
const node = yield* toWireNodeFromStructure(mounted, frame, register)
|
|
268
|
+
const fills = structureFills(frame)
|
|
269
|
+
const children = Rec.toEntries(slotsOf(mounted.comp)).flatMap(([slotName, slotClass]) => {
|
|
270
|
+
const child = bindings.get(slotClass)
|
|
271
|
+
if (child === undefined) {
|
|
272
|
+
return []
|
|
273
|
+
}
|
|
274
|
+
const fill = fills[slotName]
|
|
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
|
+
})
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Render the scene's CURRENT frame to a serializable `WireTree`, breadth-first from
|
|
291
|
+
* the root composition. Requires only the scene's render services
|
|
292
|
+
* (`CompositionService | SlotChild`), so it runs on any runtime built from the same
|
|
293
|
+
* closed scene — `makeRemoteServer`'s, or `@playfast/reform-drive`'s (the screenshot
|
|
294
|
+
* path). `register` defaults to a no-op: a one-shot render needs no live trigger
|
|
295
|
+
* registry, while `makeRemoteServer` passes its connection registry so the streamed
|
|
296
|
+
* client can invoke handles.
|
|
297
|
+
*/
|
|
298
|
+
/** Options for {@link renderSceneToWire}; the registrar defaults to a no-op for one-shot renders. */
|
|
299
|
+
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
|
|
304
|
+
export const renderSceneToWire = (
|
|
305
|
+
scene: Scene,
|
|
306
|
+
options?: RenderSceneToWireOptions,
|
|
307
|
+
): Effect.Effect<WireTree, ParseResult.ParseError, CompositionService | SlotChild> =>
|
|
308
|
+
Effect.gen(function* () {
|
|
309
|
+
const register = options?.register ?? noRegister
|
|
310
|
+
const bindings = yield* collect(scene.composition)
|
|
311
|
+
const root: Mounted = {
|
|
312
|
+
comp: scene.composition,
|
|
313
|
+
props: {},
|
|
314
|
+
id: '0',
|
|
315
|
+
parentId: Option.none(),
|
|
316
|
+
slot: Option.none(),
|
|
317
|
+
childIndex: 0,
|
|
318
|
+
key: Option.none(),
|
|
319
|
+
}
|
|
320
|
+
return yield* renderLevel([root], bindings, register)
|
|
321
|
+
})
|
|
322
|
+
|
|
80
323
|
export interface RemoteServer {
|
|
81
324
|
/** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
|
|
82
325
|
readonly render: () => Promise<WireTree>
|
|
@@ -97,6 +340,12 @@ export interface RemoteServer {
|
|
|
97
340
|
* no user interaction triggered. Returns an unsubscribe handle.
|
|
98
341
|
*/
|
|
99
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
|
|
100
349
|
/** Tear down the runtime and its forked fibers. */
|
|
101
350
|
readonly dispose: () => Promise<void>
|
|
102
351
|
}
|
|
@@ -105,172 +354,11 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
105
354
|
const runtime = ManagedRuntime.make(closedSceneLayer(scene))
|
|
106
355
|
const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
|
|
107
356
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
Effect.
|
|
112
|
-
|
|
113
|
-
const walk = (comp: CompositionClass<unknown>): Effect.Effect<void, never, SlotChild> =>
|
|
114
|
-
Effect.gen(function* () {
|
|
115
|
-
for (const slotClass of Object.values(comp.manifest.slots ?? {})) {
|
|
116
|
-
if (bindings.has(slotClass)) continue
|
|
117
|
-
const child = childComposition(yield* slotClass.tag)
|
|
118
|
-
bindings.set(slotClass, child)
|
|
119
|
-
yield* walk(child)
|
|
120
|
-
}
|
|
121
|
-
})
|
|
122
|
-
yield* walk(root)
|
|
123
|
-
return bindings
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
// Structure → wire encoding (plan 02 + 03b). Props come straight from the
|
|
127
|
-
// `Structure` value (data, never a view render). Event triggers ride ON the
|
|
128
|
-
// structure (`structure.events`) and are registered from there. Produces the wire
|
|
129
|
-
// shape the transport and `Wire.diff` consume.
|
|
130
|
-
const toWireNodeFromStructure = (
|
|
131
|
-
m: Mounted,
|
|
132
|
-
structure: Structure<UiContract>,
|
|
133
|
-
): Effect.Effect<WireNode, ParseResult.ParseError> =>
|
|
134
|
-
Effect.gen(function* () {
|
|
135
|
-
const manifest = m.comp.manifest.ui.manifest as UiManifest
|
|
136
|
-
const name = manifest.name
|
|
137
|
-
|
|
138
|
-
const dataProps: WireProp[] = []
|
|
139
|
-
if (manifest.props !== undefined) {
|
|
140
|
-
const encoded = yield* Schema.encodeUnknown(manifest.props)(structure.props)
|
|
141
|
-
for (const [propName, value] of Object.entries(encoded as Record<string, unknown>)) {
|
|
142
|
-
dataProps.push({ _tag: 'Data', name: propName, value })
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const eventSchemas = manifest.events ?? {}
|
|
147
|
-
const eventProps: WireProp[] = []
|
|
148
|
-
const events = eventsOf(structure)
|
|
149
|
-
for (const [eventName, trigger] of Object.entries(events)) {
|
|
150
|
-
const schema = eventSchemas[eventName]
|
|
151
|
-
if (schema === undefined) continue
|
|
152
|
-
const handle = `${m.id}:${eventName}`
|
|
153
|
-
yield* registry.register(handle, trigger, schema)
|
|
154
|
-
eventProps.push({ _tag: 'Event', name: eventName, handle })
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
id: m.id,
|
|
159
|
-
name,
|
|
160
|
-
parentId: m.parentId,
|
|
161
|
-
childIndex: m.childIndex,
|
|
162
|
-
slot: m.slot,
|
|
163
|
-
key: m.key,
|
|
164
|
-
props: [...dataProps, ...eventProps],
|
|
165
|
-
}
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
// Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
|
|
169
|
-
// UiContract>` erases its per-slot fill types at this boundary (the strict typing
|
|
170
|
-
// lives on the concrete contract), so the runtime fills arrive as `unknown` and
|
|
171
|
-
// are recovered structurally — no `as`.
|
|
172
|
-
const isSlotFill = (u: unknown): u is SlotFill<unknown> =>
|
|
173
|
-
typeof u === 'object' &&
|
|
174
|
-
u !== null &&
|
|
175
|
-
'_tag' in u &&
|
|
176
|
-
(u._tag === 'Each' || u._tag === 'One' || u._tag === 'Absent')
|
|
177
|
-
|
|
178
|
-
// Read a structure's fills by slot name, recovering each erased value via the
|
|
179
|
-
// discriminant guard. Returns a plain record keyed by declared slot name.
|
|
180
|
-
const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
|
|
181
|
-
Object.fromEntries(
|
|
182
|
-
Object.entries(structure.slots).flatMap(([name, value]) =>
|
|
183
|
-
isSlotFill(value) ? [[name, value] as const] : [],
|
|
184
|
-
),
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
// Enqueue the children a single declared slot is filled with, reading the fill
|
|
188
|
-
// (Each / One / Absent) from the returned `Structure`. Multiplicity, per-item
|
|
189
|
-
// `key`, and props are carried as data — no view walk, no reconstruction.
|
|
190
|
-
const enqueueStructureSlot = (
|
|
191
|
-
parent: Mounted,
|
|
192
|
-
slotName: string,
|
|
193
|
-
child: CompositionClass<unknown>,
|
|
194
|
-
fill: SlotFill<unknown>,
|
|
195
|
-
next: Mounted[],
|
|
196
|
-
): void =>
|
|
197
|
-
Match.value(fill).pipe(
|
|
198
|
-
Match.tag('Each', (each) => {
|
|
199
|
-
each.items.forEach((item, index) => {
|
|
200
|
-
next.push({
|
|
201
|
-
comp: child,
|
|
202
|
-
props: item.props,
|
|
203
|
-
id: `${parent.id}.${slotName}.${index}`,
|
|
204
|
-
parentId: parent.id,
|
|
205
|
-
slot: slotName,
|
|
206
|
-
childIndex: index,
|
|
207
|
-
key: item.key,
|
|
208
|
-
})
|
|
209
|
-
})
|
|
210
|
-
}),
|
|
211
|
-
Match.tag('One', (single) => {
|
|
212
|
-
next.push({
|
|
213
|
-
comp: child,
|
|
214
|
-
props: single.props,
|
|
215
|
-
id: `${parent.id}.${slotName}.0`,
|
|
216
|
-
parentId: parent.id,
|
|
217
|
-
slot: slotName,
|
|
218
|
-
childIndex: 0,
|
|
219
|
-
key: null,
|
|
220
|
-
})
|
|
221
|
-
}),
|
|
222
|
-
Match.tag('Absent', () => {}),
|
|
223
|
-
Match.exhaustive,
|
|
224
|
-
)
|
|
225
|
-
|
|
226
|
-
const renderLevel = (
|
|
227
|
-
frontier: ReadonlyArray<Mounted>,
|
|
228
|
-
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
229
|
-
): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> =>
|
|
230
|
-
Effect.gen(function* () {
|
|
231
|
-
if (frontier.length === 0) return []
|
|
232
|
-
const next: Mounted[] = []
|
|
233
|
-
const nodes: WireNode[] = []
|
|
234
|
-
for (const mounted of frontier) {
|
|
235
|
-
const service = yield* mounted.comp.tag
|
|
236
|
-
const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
|
|
237
|
-
const frame = yield* Composition.render(service, env)
|
|
238
|
-
// Every composition returns a `Structure` (the Node render path is gone): its
|
|
239
|
-
// wire node's props come from `frame.props`, event triggers from `frame.events`,
|
|
240
|
-
// and children are enqueued by reading each declared slot's fill — no view eval.
|
|
241
|
-
if (!isStructure(frame)) {
|
|
242
|
-
return yield* Effect.dieMessage(
|
|
243
|
-
`reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
|
|
244
|
-
)
|
|
245
|
-
}
|
|
246
|
-
nodes.push(yield* toWireNodeFromStructure(mounted, frame))
|
|
247
|
-
const fills = structureFills(frame)
|
|
248
|
-
for (const [slotName, slotClass] of Object.entries(mounted.comp.manifest.slots ?? {})) {
|
|
249
|
-
const child = bindings.get(slotClass)
|
|
250
|
-
if (child === undefined) continue
|
|
251
|
-
const fill = fills[slotName]
|
|
252
|
-
if (fill === undefined) continue
|
|
253
|
-
enqueueStructureSlot(mounted, slotName, child, fill, next)
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
const rest = yield* renderLevel(next, bindings)
|
|
257
|
-
return [...nodes, ...rest]
|
|
258
|
-
})
|
|
259
|
-
|
|
260
|
-
const renderEffect = Effect.gen(function* () {
|
|
261
|
-
yield* settleDrain
|
|
262
|
-
const bindings = yield* collect(scene.composition)
|
|
263
|
-
const root: Mounted = {
|
|
264
|
-
comp: scene.composition,
|
|
265
|
-
props: {},
|
|
266
|
-
id: '0',
|
|
267
|
-
parentId: null,
|
|
268
|
-
slot: null,
|
|
269
|
-
childIndex: 0,
|
|
270
|
-
key: null,
|
|
271
|
-
}
|
|
272
|
-
return yield* renderLevel([root], bindings)
|
|
273
|
-
})
|
|
357
|
+
// The shared render walk, registering triggers in this connection's registry so the
|
|
358
|
+
// client can invoke them. `settleDrain` first so an in-flight batch folds into state.
|
|
359
|
+
const renderEffect = settleDrain.pipe(
|
|
360
|
+
Effect.zipRight(renderSceneToWire(scene, { register: registry.register })),
|
|
361
|
+
)
|
|
274
362
|
|
|
275
363
|
// Server-initiated change notification: a forked daemon subscribes to the engine bus
|
|
276
364
|
// and, after each batch settles, fans out to registered listeners. This is what lets a
|
|
@@ -280,7 +368,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
280
368
|
// binding's opening snapshot captures all state up to `start`).
|
|
281
369
|
const changeListeners = new Set<() => void>()
|
|
282
370
|
const notifyChange = (): void => {
|
|
283
|
-
|
|
371
|
+
changeListeners.forEach((listener) => listener())
|
|
284
372
|
}
|
|
285
373
|
runtime.runFork(
|
|
286
374
|
Effect.scoped(
|
|
@@ -298,31 +386,43 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
298
386
|
),
|
|
299
387
|
)
|
|
300
388
|
|
|
301
|
-
runtime.runSync(
|
|
389
|
+
runtime.runSync(
|
|
390
|
+
Effect.forEach(
|
|
391
|
+
Option.getOrElse(Option.fromNullable(scene.boot), () => []),
|
|
392
|
+
(event) => publish('High', event),
|
|
393
|
+
),
|
|
394
|
+
)
|
|
302
395
|
|
|
303
396
|
// The last emitted frame, to diff against. A const holder whose field we swap
|
|
304
397
|
// (no reassigned binding — house rule), not a `let`.
|
|
305
398
|
const frame: { tree: WireTree } = { tree: [] }
|
|
306
399
|
|
|
307
400
|
const render = (): Promise<WireTree> =>
|
|
308
|
-
runtime.runPromise(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
401
|
+
runtime.runPromise(
|
|
402
|
+
renderEffect.pipe(
|
|
403
|
+
Effect.tap((tree) =>
|
|
404
|
+
Effect.sync(() => {
|
|
405
|
+
frame.tree = tree
|
|
406
|
+
}),
|
|
407
|
+
),
|
|
408
|
+
),
|
|
409
|
+
)
|
|
312
410
|
|
|
313
411
|
const renderDiff = (): Promise<ReadonlyArray<WirePatch>> =>
|
|
314
|
-
runtime.runPromise(
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
.
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
.
|
|
325
|
-
|
|
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
|
+
)
|
|
326
426
|
|
|
327
427
|
return {
|
|
328
428
|
render,
|
|
@@ -338,6 +438,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
338
438
|
changeListeners.add(listener)
|
|
339
439
|
return () => void changeListeners.delete(listener)
|
|
340
440
|
},
|
|
441
|
+
currentTree: () => frame.tree,
|
|
341
442
|
dispose: () => runtime.dispose(),
|
|
342
443
|
}
|
|
343
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),
|