@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.
@@ -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
- // The client renders views as React components (so view-body hooks work), so a test
13
- // must actually RENDER `client.node()` to run the views — `draw` forces that pass
14
- // (node, no DOM needed) and the probe captures what each view received.
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 { renderWireTree, type ClientConfig, type RemoteContract, type RemoteViewSet } from './client'
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: 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
- // The first frame is a full snapshot (replace); every frame after is the diff since the
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
- // `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.
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
- // Server-initiated changes are flushed on a short DEBOUNCE rather than synchronously: it
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
- * 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. */
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: 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
- // 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.
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
- // 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.
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
- // 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.
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)),