@playfast/reform-remote 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -16
- package/package.json +18 -18
- package/src/client-keyed-slot.test.ts +19 -28
- package/src/client-remount.test.ts +14 -28
- package/src/client.ts +73 -182
- package/src/fixtures.ts +268 -92
- package/src/index.ts +0 -10
- package/src/memory.ts +0 -9
- package/src/react.ts +1 -32
- package/src/remote-view.typecheck.ts +3 -17
- package/src/server.test.ts +28 -32
- package/src/server.ts +269 -195
- package/src/transport.test.ts +3 -26
- package/src/transport.ts +18 -96
package/src/transport.test.ts
CHANGED
|
@@ -9,18 +9,11 @@ import { inMemoryTransportPair } from './memory'
|
|
|
9
9
|
import { remoteViews } from './client'
|
|
10
10
|
import { asyncCounterScene, CounterUi, counterScene } from './fixtures'
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
|
|
16
|
-
|
|
17
|
-
// A presentation that records the props/events handed to it, so a test can read
|
|
18
|
-
// what the client reconstructed and fire the callbacks back at the server. It is a
|
|
19
|
-
// regular `Ui.make` view (the only client authoring form), bound to the contract.
|
|
12
|
+
const draw = (node: ReactNode): void =>
|
|
13
|
+
void renderToStaticMarkup(createElement(Fragment, null, node))
|
|
14
|
+
|
|
20
15
|
interface Probe {
|
|
21
16
|
props?: Record<string, unknown>
|
|
22
|
-
// The counter contract's only event, `bump`, takes `{ by: number }`; typing the
|
|
23
|
-
// record to that payload lets the strongly-typed `Ui.make` events assign in.
|
|
24
17
|
events?: Record<string, (payload: { by: number }) => void>
|
|
25
18
|
}
|
|
26
19
|
const probeView = (probe: Probe) =>
|
|
@@ -64,8 +57,6 @@ test('server-initiated state change streams to the client with NO invoke (backgr
|
|
|
64
57
|
InvokeMessage
|
|
65
58
|
>()
|
|
66
59
|
|
|
67
|
-
// Count outbound client→server messages: the whole point is that the new frame arrives
|
|
68
|
-
// without the client invoking anything. A non-zero count would mean the test cheated.
|
|
69
60
|
const invokes = { count: 0 }
|
|
70
61
|
const countingClientTransport = {
|
|
71
62
|
...clientTransport,
|
|
@@ -80,8 +71,6 @@ test('server-initiated state change streams to the client with NO invoke (backgr
|
|
|
80
71
|
transport: countingClientTransport,
|
|
81
72
|
views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probe) }),
|
|
82
73
|
})
|
|
83
|
-
// The scene's procedure sleeps, THEN bumps to 7 — so the snapshot is count:0 and the
|
|
84
|
-
// change can only reach the client via a server-pushed diff.
|
|
85
74
|
const server = serve({ scene: asyncCounterScene('30 millis'), transport: serverTransport })
|
|
86
75
|
try {
|
|
87
76
|
await server.start()
|
|
@@ -92,7 +81,6 @@ test('server-initiated state change streams to the client with NO invoke (backgr
|
|
|
92
81
|
draw(client.node())
|
|
93
82
|
expect(probe.props).toEqual({ count: 7 })
|
|
94
83
|
})
|
|
95
|
-
// The client never sent a single Invoke — the frame was entirely server-initiated.
|
|
96
84
|
expect(invokes.count).toBe(0)
|
|
97
85
|
} finally {
|
|
98
86
|
client.dispose()
|
|
@@ -127,12 +115,10 @@ test('a fresh Snapshot replaces the client tree — stale nodes are dropped (rec
|
|
|
127
115
|
props: [],
|
|
128
116
|
})
|
|
129
117
|
|
|
130
|
-
// First session: two roots.
|
|
131
118
|
serverTransport.send({ _tag: 'Snapshot', tree: [node('a', 'A'), node('b', 'B')] })
|
|
132
119
|
draw(client.node())
|
|
133
120
|
expect(rendered).toEqual(['A', 'B'])
|
|
134
121
|
|
|
135
|
-
// Reconnect: a fresh snapshot with only one root must drop the stale 'B'.
|
|
136
122
|
rendered.length = 0
|
|
137
123
|
serverTransport.send({ _tag: 'Snapshot', tree: [node('a', 'A')] })
|
|
138
124
|
draw(client.node())
|
|
@@ -160,7 +146,6 @@ test('two clients each get their own server runtime — state is isolated', asyn
|
|
|
160
146
|
try {
|
|
161
147
|
await serverOne.start()
|
|
162
148
|
await serverTwo.start()
|
|
163
|
-
// Render once so each probe captures its event callbacks.
|
|
164
149
|
draw(clientOne.node())
|
|
165
150
|
draw(clientTwo.node())
|
|
166
151
|
|
|
@@ -170,7 +155,6 @@ test('two clients each get their own server runtime — state is isolated', asyn
|
|
|
170
155
|
expect(probeOne.props).toEqual({ count: 9 })
|
|
171
156
|
})
|
|
172
157
|
|
|
173
|
-
// The second client's runtime never saw that bump.
|
|
174
158
|
draw(clientTwo.node())
|
|
175
159
|
expect(probeTwo.props).toEqual({ count: 0 })
|
|
176
160
|
} finally {
|
|
@@ -196,12 +180,10 @@ test('serveShared: two clients share ONE runtime — one client drives state for
|
|
|
196
180
|
views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeTwo) }),
|
|
197
181
|
})
|
|
198
182
|
|
|
199
|
-
// One shared server; both transports attach to the same runtime.
|
|
200
183
|
const shared = serveShared({ scene: counterScene() })
|
|
201
184
|
const handleOne = shared.addClient(one.server)
|
|
202
185
|
const handleTwo = shared.addClient(two.server)
|
|
203
186
|
try {
|
|
204
|
-
// Both clients receive the opening snapshot off the shared baseline.
|
|
205
187
|
await vi.waitFor(() => {
|
|
206
188
|
draw(clientOne.node())
|
|
207
189
|
draw(clientTwo.node())
|
|
@@ -209,7 +191,6 @@ test('serveShared: two clients share ONE runtime — one client drives state for
|
|
|
209
191
|
expect(probeTwo.props).toEqual({ count: 0 })
|
|
210
192
|
})
|
|
211
193
|
|
|
212
|
-
// A bump from client ONE is broadcast to BOTH — they share the same state.
|
|
213
194
|
probeOne.events?.['bump']?.({ by: 5 })
|
|
214
195
|
await vi.waitFor(() => {
|
|
215
196
|
draw(clientOne.node())
|
|
@@ -218,7 +199,6 @@ test('serveShared: two clients share ONE runtime — one client drives state for
|
|
|
218
199
|
expect(probeTwo.props).toEqual({ count: 5 })
|
|
219
200
|
})
|
|
220
201
|
|
|
221
|
-
// And a bump from client TWO lands on the same shared counter.
|
|
222
202
|
probeTwo.events?.['bump']?.({ by: 3 })
|
|
223
203
|
await vi.waitFor(() => {
|
|
224
204
|
draw(clientOne.node())
|
|
@@ -258,7 +238,6 @@ test('serveShared: a client connecting mid-stream snapshots the current shared s
|
|
|
258
238
|
expect(probeOne.props).toEqual({ count: 4 })
|
|
259
239
|
})
|
|
260
240
|
|
|
261
|
-
// A late joiner must see the CURRENT shared count (4), not a fresh 0.
|
|
262
241
|
const two = inMemoryTransportPair<ServerMessage, InvokeMessage>()
|
|
263
242
|
const probeTwo: Probe = {}
|
|
264
243
|
const clientTwo = connect({
|
|
@@ -300,7 +279,5 @@ test('dispose detaches both ends: a post-dispose invoke no longer reaches the se
|
|
|
300
279
|
await server.dispose()
|
|
301
280
|
client.dispose()
|
|
302
281
|
|
|
303
|
-
// After dispose the server's onMessage handler is detached; firing the stale
|
|
304
|
-
// callback is a no-op rather than a throw.
|
|
305
282
|
expect(() => probe.events?.['bump']?.({ by: 1 })).not.toThrow()
|
|
306
283
|
})
|
package/src/transport.ts
CHANGED
|
@@ -1,89 +1,53 @@
|
|
|
1
1
|
import { Effect, Fiber, Match, Option } from 'effect'
|
|
2
2
|
import type { ReactNode } from 'react'
|
|
3
|
-
import { Wire, type WirePatch, type WireTree } from '@playfast/reform'
|
|
4
|
-
import type { Scene } from '@playfast/reform'
|
|
3
|
+
import { type AnyScene, Wire, type WirePatch, type WireTree } from '@playfast/reform/internal'
|
|
5
4
|
import { makeRemoteServer } from './server'
|
|
6
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
renderWireTree,
|
|
7
|
+
type ClientConfig,
|
|
8
|
+
type RemoteContract,
|
|
9
|
+
type RemoteViewSet,
|
|
10
|
+
} from './client'
|
|
7
11
|
|
|
8
|
-
/**
|
|
9
|
-
* Binds the server driver and the client renderer to a transport. The wire
|
|
10
|
-
* carries only serializable data — patches one way, trigger invocations the
|
|
11
|
-
* other — so any duplex channel (WebSocket, postMessage, in-memory) works; a
|
|
12
|
-
* concrete adapter just implements `RemoteTransport`. See REMOTE_UI.md §5.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Server → client: the full current tree, replacing whatever the client holds.
|
|
17
|
-
* The first frame of every (re)connection — a fresh server runtime diffs from
|
|
18
|
-
* empty, so it can only *add* nodes; a snapshot is what lets a reconnecting
|
|
19
|
-
* client drop the stale tree it accumulated on the previous socket.
|
|
20
|
-
*/
|
|
21
12
|
export interface SnapshotMessage {
|
|
22
13
|
readonly _tag: 'Snapshot'
|
|
23
14
|
readonly tree: WireTree
|
|
24
15
|
}
|
|
25
16
|
|
|
26
|
-
/** Server → client: a batch of tree patches to fold with `Wire.apply`. */
|
|
27
17
|
export interface PatchesMessage {
|
|
28
18
|
readonly _tag: 'Patches'
|
|
29
19
|
readonly patches: ReadonlyArray<WirePatch>
|
|
30
20
|
}
|
|
31
21
|
|
|
32
|
-
/** Everything the server sends the client: a fresh snapshot or incremental patches. */
|
|
33
22
|
export type ServerMessage = SnapshotMessage | PatchesMessage
|
|
34
23
|
|
|
35
|
-
/** Client → server: fire the trigger behind `handle` with an encoded payload. */
|
|
36
24
|
export interface InvokeMessage {
|
|
37
25
|
readonly _tag: 'Invoke'
|
|
38
26
|
readonly handle: string
|
|
39
27
|
readonly payload: unknown
|
|
40
28
|
}
|
|
41
29
|
|
|
42
|
-
/** A duplex channel: send `Out`, receive `In`. */
|
|
43
30
|
export interface RemoteTransport<Out, In> {
|
|
44
31
|
readonly send: (message: Out) => void
|
|
45
32
|
readonly onMessage: (handler: (message: In) => void) => () => void
|
|
46
33
|
}
|
|
47
34
|
|
|
48
35
|
export interface ServerBinding {
|
|
49
|
-
/** Render the first frame and push it. Call once the client is connected. */
|
|
50
36
|
readonly start: () => Promise<void>
|
|
51
37
|
readonly dispose: () => Promise<void>
|
|
52
38
|
}
|
|
53
39
|
|
|
54
|
-
/**
|
|
55
|
-
* Debounce window for SERVER-initiated frame pushes (background loads, scheduler ticks).
|
|
56
|
-
* Long enough that the engine drain has folded the change into state and a burst of facts
|
|
57
|
-
* coalesces into one render; short enough to feel live. Client invokes don't wait on this —
|
|
58
|
-
* they push immediately.
|
|
59
|
-
*/
|
|
60
40
|
const BACKGROUND_FLUSH_MS = 16
|
|
61
41
|
|
|
62
|
-
/** Options for {@link serve} — a single named object so every reform-remote binding shares one shape. */
|
|
63
42
|
export interface ServeOptions {
|
|
64
|
-
readonly scene:
|
|
43
|
+
readonly scene: AnyScene
|
|
65
44
|
readonly transport: RemoteTransport<ServerMessage, InvokeMessage>
|
|
66
45
|
}
|
|
67
46
|
|
|
68
47
|
export const serve = (options: ServeOptions): ServerBinding => {
|
|
69
48
|
const { scene, transport } = options
|
|
70
49
|
const server = makeRemoteServer(scene)
|
|
71
|
-
//
|
|
72
|
-
// last (apply). Both keep the server's `frame` baseline in step, so the diffs always
|
|
73
|
-
// reference what the client actually holds. Diffs are emitted both after a client invoke
|
|
74
|
-
// AND whenever the server's OWN state changes (a background load resolving, a scheduler
|
|
75
|
-
// tick) — without the latter, anything the user didn't directly trigger would never reach
|
|
76
|
-
// the client (it would sit on the snapshot's loading state forever).
|
|
77
|
-
//
|
|
78
|
-
// `started` gates pushes until the opening snapshot is sent: a diff that raced ahead of
|
|
79
|
-
// the snapshot would reference a tree the client has not received.
|
|
80
|
-
//
|
|
81
|
-
// `push` is a COALESCING single-flight render: at most one `renderDiff` runs at a time
|
|
82
|
-
// (concurrent ones would interleave the sink captures and corrupt the frame baseline). If
|
|
83
|
-
// a change arrives WHILE a render is in flight — e.g. an async procedure folds its result
|
|
84
|
-
// mid-render — `dirty` is set and a follow-up render runs when the current one settles, so
|
|
85
|
-
// a late state change is never dropped. Both a client invoke and a server-side change
|
|
86
|
-
// (the bus subscriber) funnel through here, so there is exactly one push path.
|
|
50
|
+
// Snapshot gates diffs; single-flight rendering protects the shared frame baseline.
|
|
87
51
|
const started = { value: false }
|
|
88
52
|
const flight = { promise: Option.none<Promise<void>>(), dirty: false }
|
|
89
53
|
const push = (): Promise<void> => {
|
|
@@ -94,8 +58,7 @@ export const serve = (options: ServeOptions): ServerBinding => {
|
|
|
94
58
|
flight.dirty = true
|
|
95
59
|
return flight.promise.value
|
|
96
60
|
}
|
|
97
|
-
//
|
|
98
|
-
// (the old `.finally`), so the single-flight slot is never left stuck.
|
|
61
|
+
// ensuring clears the single-flight slot even when rendering fails.
|
|
99
62
|
const run = Effect.runPromise(
|
|
100
63
|
Effect.gen(function* () {
|
|
101
64
|
const patches = yield* Effect.promise(() => server.renderDiff())
|
|
@@ -117,12 +80,7 @@ export const serve = (options: ServeOptions): ServerBinding => {
|
|
|
117
80
|
flight.promise = Option.some(run)
|
|
118
81
|
return run
|
|
119
82
|
}
|
|
120
|
-
//
|
|
121
|
-
// coalesces a burst (a procedure that dispatches several facts) into one render, and the
|
|
122
|
-
// delay lets the drain fold the change into state before we read it — so the diff reflects
|
|
123
|
-
// the settled result, and the background flush never races the synchronous invoke push
|
|
124
|
-
// above. A client invoke still pushes immediately (its own settle path), so user actions
|
|
125
|
-
// stay snappy; this path only carries updates no interaction triggered.
|
|
83
|
+
// Background debounce lets the drain settle and coalesces bursts without delaying invokes.
|
|
126
84
|
const debounce = { fiber: Option.none<Fiber.RuntimeFiber<void>>() }
|
|
127
85
|
const scheduleFlush = (): void => {
|
|
128
86
|
if (!started.value || Option.isSome(debounce.fiber)) {
|
|
@@ -144,7 +102,6 @@ export const serve = (options: ServeOptions): ServerBinding => {
|
|
|
144
102
|
const tree = await server.render()
|
|
145
103
|
transport.send({ _tag: 'Snapshot', tree })
|
|
146
104
|
started.value = true
|
|
147
|
-
// Flush anything that changed during the (async) opening render window.
|
|
148
105
|
scheduleFlush()
|
|
149
106
|
}
|
|
150
107
|
const off = transport.onMessage((message) => {
|
|
@@ -168,40 +125,21 @@ export const serve = (options: ServeOptions): ServerBinding => {
|
|
|
168
125
|
}
|
|
169
126
|
}
|
|
170
127
|
|
|
171
|
-
/** A client's membership in a {@link SharedServerBinding}; `remove` detaches it. */
|
|
172
128
|
export interface SharedClientHandle {
|
|
173
|
-
/** Stop sending this client frames and drop its invoke listener. Call on disconnect. */
|
|
174
129
|
readonly remove: () => void
|
|
175
130
|
}
|
|
176
131
|
|
|
177
132
|
export interface SharedServerBinding {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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. */
|
|
133
|
+
readonly addClient: (
|
|
134
|
+
transport: RemoteTransport<ServerMessage, InvokeMessage>,
|
|
135
|
+
) => SharedClientHandle
|
|
186
136
|
readonly dispose: () => Promise<void>
|
|
187
137
|
}
|
|
188
138
|
|
|
189
|
-
/** Options for {@link serveShared} — one scene, run once, shared by every client. */
|
|
190
139
|
export interface ServeSharedOptions {
|
|
191
|
-
readonly scene:
|
|
140
|
+
readonly scene: AnyScene
|
|
192
141
|
}
|
|
193
142
|
|
|
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
143
|
export const serveShared = (options: ServeSharedOptions): SharedServerBinding => {
|
|
206
144
|
const { scene } = options
|
|
207
145
|
const server = makeRemoteServer(scene)
|
|
@@ -210,9 +148,7 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
|
|
|
210
148
|
clients.forEach((client) => client.send(message))
|
|
211
149
|
}
|
|
212
150
|
|
|
213
|
-
//
|
|
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.
|
|
151
|
+
// One shared render baseline requires the same single-flight discipline as serve.
|
|
216
152
|
const started = { value: false }
|
|
217
153
|
const flight = { promise: Option.none<Promise<void>>(), dirty: false }
|
|
218
154
|
const push = (): Promise<void> => {
|
|
@@ -263,9 +199,7 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
|
|
|
263
199
|
debounce.fiber = Option.some(fiber)
|
|
264
200
|
}
|
|
265
201
|
|
|
266
|
-
//
|
|
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.
|
|
202
|
+
// Clients await the initial baseline so none snapshot an empty tree.
|
|
269
203
|
const ready = (async (): Promise<void> => {
|
|
270
204
|
await server.render()
|
|
271
205
|
started.value = true
|
|
@@ -284,9 +218,7 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
|
|
|
284
218
|
),
|
|
285
219
|
)
|
|
286
220
|
})
|
|
287
|
-
//
|
|
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.
|
|
221
|
+
// Snapshot and broadcast membership must change in one tick so no diff interleaves.
|
|
290
222
|
void Effect.runPromise(
|
|
291
223
|
Effect.promise(() => ready).pipe(
|
|
292
224
|
Effect.zipRight(
|
|
@@ -319,20 +251,12 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
|
|
|
319
251
|
}
|
|
320
252
|
|
|
321
253
|
export interface ClientBinding {
|
|
322
|
-
/** The current rendered node — re-read on each `subscribe` notification. */
|
|
323
254
|
readonly node: () => ReactNode
|
|
324
|
-
/** Notified whenever applied patches change the tree. */
|
|
325
255
|
readonly subscribe: (listener: () => void) => () => void
|
|
326
|
-
/**
|
|
327
|
-
* The current wire tree — a stable reference that changes only when a frame is
|
|
328
|
-
* applied, so it is a valid `useSyncExternalStore` snapshot (the React binding
|
|
329
|
-
* builds on this).
|
|
330
|
-
*/
|
|
331
256
|
readonly snapshot: () => WireTree
|
|
332
257
|
readonly dispose: () => void
|
|
333
258
|
}
|
|
334
259
|
|
|
335
|
-
/** Options for {@link connect} — a single named object, mirroring {@link ServeOptions}. */
|
|
336
260
|
export interface ConnectOptions<C extends RemoteContract> {
|
|
337
261
|
readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
|
|
338
262
|
readonly views: RemoteViewSet<C>
|
|
@@ -347,8 +271,6 @@ export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): C
|
|
|
347
271
|
invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
|
|
348
272
|
}
|
|
349
273
|
const off = transport.onMessage((message) => {
|
|
350
|
-
// A snapshot replaces the tree wholesale (a fresh or reconnected session);
|
|
351
|
-
// patches fold into it. So a reconnect re-syncs without leaking stale nodes.
|
|
352
274
|
state.tree = Match.value(message).pipe(
|
|
353
275
|
Match.tag('Snapshot', ({ tree }) => tree),
|
|
354
276
|
Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
|