@playfast/reform-remote 0.1.0 → 1.0.2

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,10 @@ 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
12
  const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
16
13
 
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.
20
14
  interface Probe {
21
15
  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
16
  events?: Record<string, (payload: { by: number }) => void>
25
17
  }
26
18
  const probeView = (probe: Probe) =>
@@ -64,8 +56,6 @@ test('server-initiated state change streams to the client with NO invoke (backgr
64
56
  InvokeMessage
65
57
  >()
66
58
 
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
59
  const invokes = { count: 0 }
70
60
  const countingClientTransport = {
71
61
  ...clientTransport,
@@ -80,8 +70,6 @@ test('server-initiated state change streams to the client with NO invoke (backgr
80
70
  transport: countingClientTransport,
81
71
  views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probe) }),
82
72
  })
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
73
  const server = serve({ scene: asyncCounterScene('30 millis'), transport: serverTransport })
86
74
  try {
87
75
  await server.start()
@@ -92,7 +80,6 @@ test('server-initiated state change streams to the client with NO invoke (backgr
92
80
  draw(client.node())
93
81
  expect(probe.props).toEqual({ count: 7 })
94
82
  })
95
- // The client never sent a single Invoke — the frame was entirely server-initiated.
96
83
  expect(invokes.count).toBe(0)
97
84
  } finally {
98
85
  client.dispose()
@@ -127,12 +114,10 @@ test('a fresh Snapshot replaces the client tree — stale nodes are dropped (rec
127
114
  props: [],
128
115
  })
129
116
 
130
- // First session: two roots.
131
117
  serverTransport.send({ _tag: 'Snapshot', tree: [node('a', 'A'), node('b', 'B')] })
132
118
  draw(client.node())
133
119
  expect(rendered).toEqual(['A', 'B'])
134
120
 
135
- // Reconnect: a fresh snapshot with only one root must drop the stale 'B'.
136
121
  rendered.length = 0
137
122
  serverTransport.send({ _tag: 'Snapshot', tree: [node('a', 'A')] })
138
123
  draw(client.node())
@@ -160,7 +145,6 @@ test('two clients each get their own server runtime — state is isolated', asyn
160
145
  try {
161
146
  await serverOne.start()
162
147
  await serverTwo.start()
163
- // Render once so each probe captures its event callbacks.
164
148
  draw(clientOne.node())
165
149
  draw(clientTwo.node())
166
150
 
@@ -170,7 +154,6 @@ test('two clients each get their own server runtime — state is isolated', asyn
170
154
  expect(probeOne.props).toEqual({ count: 9 })
171
155
  })
172
156
 
173
- // The second client's runtime never saw that bump.
174
157
  draw(clientTwo.node())
175
158
  expect(probeTwo.props).toEqual({ count: 0 })
176
159
  } finally {
@@ -196,12 +179,10 @@ test('serveShared: two clients share ONE runtime — one client drives state for
196
179
  views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeTwo) }),
197
180
  })
198
181
 
199
- // One shared server; both transports attach to the same runtime.
200
182
  const shared = serveShared({ scene: counterScene() })
201
183
  const handleOne = shared.addClient(one.server)
202
184
  const handleTwo = shared.addClient(two.server)
203
185
  try {
204
- // Both clients receive the opening snapshot off the shared baseline.
205
186
  await vi.waitFor(() => {
206
187
  draw(clientOne.node())
207
188
  draw(clientTwo.node())
@@ -209,7 +190,6 @@ test('serveShared: two clients share ONE runtime — one client drives state for
209
190
  expect(probeTwo.props).toEqual({ count: 0 })
210
191
  })
211
192
 
212
- // A bump from client ONE is broadcast to BOTH — they share the same state.
213
193
  probeOne.events?.['bump']?.({ by: 5 })
214
194
  await vi.waitFor(() => {
215
195
  draw(clientOne.node())
@@ -218,7 +198,6 @@ test('serveShared: two clients share ONE runtime — one client drives state for
218
198
  expect(probeTwo.props).toEqual({ count: 5 })
219
199
  })
220
200
 
221
- // And a bump from client TWO lands on the same shared counter.
222
201
  probeTwo.events?.['bump']?.({ by: 3 })
223
202
  await vi.waitFor(() => {
224
203
  draw(clientOne.node())
@@ -258,7 +237,6 @@ test('serveShared: a client connecting mid-stream snapshots the current shared s
258
237
  expect(probeOne.props).toEqual({ count: 4 })
259
238
  })
260
239
 
261
- // A late joiner must see the CURRENT shared count (4), not a fresh 0.
262
240
  const two = inMemoryTransportPair<ServerMessage, InvokeMessage>()
263
241
  const probeTwo: Probe = {}
264
242
  const clientTwo = connect({
@@ -300,7 +278,5 @@ test('dispose detaches both ends: a post-dispose invoke no longer reaches the se
300
278
  await server.dispose()
301
279
  client.dispose()
302
280
 
303
- // After dispose the server's onMessage handler is detached; firing the stale
304
- // callback is a no-op rather than a throw.
305
281
  expect(() => probe.events?.['bump']?.({ by: 1 })).not.toThrow()
306
282
  })
package/src/transport.ts CHANGED
@@ -1,65 +1,20 @@
1
- import { Effect, Fiber, Match, Option } from 'effect'
2
- import type { ReactNode } from 'react'
3
- import { Wire, type WirePatch, type WireTree } from '@playfast/reform'
1
+ import { Effect, Fiber, Option } from 'effect'
4
2
  import type { Scene } from '@playfast/reform'
5
3
  import { makeRemoteServer } from './server'
6
- import { renderWireTree, type ClientConfig, type RemoteContract, type RemoteViewSet } from './client'
7
-
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
- export interface SnapshotMessage {
22
- readonly _tag: 'Snapshot'
23
- readonly tree: WireTree
24
- }
25
-
26
- /** Server → client: a batch of tree patches to fold with `Wire.apply`. */
27
- export interface PatchesMessage {
28
- readonly _tag: 'Patches'
29
- readonly patches: ReadonlyArray<WirePatch>
30
- }
31
-
32
- /** Everything the server sends the client: a fresh snapshot or incremental patches. */
33
- export type ServerMessage = SnapshotMessage | PatchesMessage
34
-
35
- /** Client → server: fire the trigger behind `handle` with an encoded payload. */
36
- export interface InvokeMessage {
37
- readonly _tag: 'Invoke'
38
- readonly handle: string
39
- readonly payload: unknown
40
- }
41
-
42
- /** A duplex channel: send `Out`, receive `In`. */
43
- export interface RemoteTransport<Out, In> {
44
- readonly send: (message: Out) => void
45
- readonly onMessage: (handler: (message: In) => void) => () => void
46
- }
47
-
48
- export interface ServerBinding {
49
- /** Render the first frame and push it. Call once the client is connected. */
50
- readonly start: () => Promise<void>
51
- readonly dispose: () => Promise<void>
52
- }
53
-
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
- */
4
+ export { connect, type ClientBinding, type ConnectOptions } from './connect'
5
+ import type { InvokeMessage, RemoteTransport, ServerBinding, ServerMessage } from './transport.types'
6
+ export type {
7
+ InvokeMessage,
8
+ PatchesMessage,
9
+ RemoteTransport,
10
+ ServerBinding,
11
+ ServerMessage,
12
+ SnapshotMessage,
13
+ } from './transport.types'
14
+
15
+ // Debounce for server-initiated pushes: drain settles + burst coalesces; client invokes push immediately.
60
16
  const BACKGROUND_FLUSH_MS = 16
61
17
 
62
- /** Options for {@link serve} — a single named object so every reform-remote binding shares one shape. */
63
18
  export interface ServeOptions {
64
19
  readonly scene: Scene
65
20
  readonly transport: RemoteTransport<ServerMessage, InvokeMessage>
@@ -68,22 +23,8 @@ export interface ServeOptions {
68
23
  export const serve = (options: ServeOptions): ServerBinding => {
69
24
  const { scene, transport } = options
70
25
  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.
26
+ // Snapshot first; then diffs. `started` gates races before snapshot.
27
+ // Single-flight push + dirty re-run: concurrent renderDiff would corrupt frame baseline.
87
28
  const started = { value: false }
88
29
  const flight = { promise: Option.none<Promise<void>>(), dirty: false }
89
30
  const push = (): Promise<void> => {
@@ -94,8 +35,7 @@ export const serve = (options: ServeOptions): ServerBinding => {
94
35
  flight.dirty = true
95
36
  return flight.promise.value
96
37
  }
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.
38
+ // ensuring so single-flight slot clears even on render failure.
99
39
  const run = Effect.runPromise(
100
40
  Effect.gen(function* () {
101
41
  const patches = yield* Effect.promise(() => server.renderDiff())
@@ -117,12 +57,7 @@ export const serve = (options: ServeOptions): ServerBinding => {
117
57
  flight.promise = Option.some(run)
118
58
  return run
119
59
  }
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.
60
+ // Background debounce so drain folds state before read; never races invoke's immediate push.
126
61
  const debounce = { fiber: Option.none<Fiber.RuntimeFiber<void>>() }
127
62
  const scheduleFlush = (): void => {
128
63
  if (!started.value || Option.isSome(debounce.fiber)) {
@@ -144,7 +79,6 @@ export const serve = (options: ServeOptions): ServerBinding => {
144
79
  const tree = await server.render()
145
80
  transport.send({ _tag: 'Snapshot', tree })
146
81
  started.value = true
147
- // Flush anything that changed during the (async) opening render window.
148
82
  scheduleFlush()
149
83
  }
150
84
  const off = transport.onMessage((message) => {
@@ -168,40 +102,19 @@ export const serve = (options: ServeOptions): ServerBinding => {
168
102
  }
169
103
  }
170
104
 
171
- /** A client's membership in a {@link SharedServerBinding}; `remove` detaches it. */
172
105
  export interface SharedClientHandle {
173
- /** Stop sending this client frames and drop its invoke listener. Call on disconnect. */
174
106
  readonly remove: () => void
175
107
  }
176
108
 
177
109
  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
110
  readonly addClient: (transport: RemoteTransport<ServerMessage, InvokeMessage>) => SharedClientHandle
185
- /** Tear down the shared runtime and drop every client. */
186
111
  readonly dispose: () => Promise<void>
187
112
  }
188
113
 
189
- /** Options for {@link serveShared} — one scene, run once, shared by every client. */
190
114
  export interface ServeSharedOptions {
191
115
  readonly scene: Scene
192
116
  }
193
117
 
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
118
  export const serveShared = (options: ServeSharedOptions): SharedServerBinding => {
206
119
  const { scene } = options
207
120
  const server = makeRemoteServer(scene)
@@ -210,9 +123,7 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
210
123
  clients.forEach((client) => client.send(message))
211
124
  }
212
125
 
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.
126
+ // Same single-flight + dirty as serve; patches broadcast to every client.
216
127
  const started = { value: false }
217
128
  const flight = { promise: Option.none<Promise<void>>(), dirty: false }
218
129
  const push = (): Promise<void> => {
@@ -263,9 +174,7 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
263
174
  debounce.fiber = Option.some(fiber)
264
175
  }
265
176
 
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.
177
+ // Baseline before any client snapshots; clients await ready so they never see empty tree.
269
178
  const ready = (async (): Promise<void> => {
270
179
  await server.render()
271
180
  started.value = true
@@ -284,9 +193,7 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
284
193
  ),
285
194
  )
286
195
  })
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.
196
+ // Snapshot + join broadcast in same tick so no push interleaves between them.
290
197
  void Effect.runPromise(
291
198
  Effect.promise(() => ready).pipe(
292
199
  Effect.zipRight(
@@ -317,52 +224,3 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
317
224
  },
318
225
  }
319
226
  }
320
-
321
- export interface ClientBinding {
322
- /** The current rendered node — re-read on each `subscribe` notification. */
323
- readonly node: () => ReactNode
324
- /** Notified whenever applied patches change the tree. */
325
- 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
- readonly snapshot: () => WireTree
332
- readonly dispose: () => void
333
- }
334
-
335
- /** Options for {@link connect} — a single named object, mirroring {@link ServeOptions}. */
336
- export interface ConnectOptions<C extends RemoteContract> {
337
- readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
338
- readonly views: RemoteViewSet<C>
339
- }
340
-
341
- export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): ClientBinding => {
342
- const { transport, views } = options
343
- const state: { tree: WireTree } = { tree: [] }
344
- const listeners = new Set<() => void>()
345
- const config: ClientConfig<C> = {
346
- views,
347
- invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
348
- }
349
- 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
- state.tree = Match.value(message).pipe(
353
- Match.tag('Snapshot', ({ tree }) => tree),
354
- Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
355
- Match.exhaustive,
356
- )
357
- listeners.forEach((listener) => listener())
358
- })
359
- return {
360
- node: () => renderWireTree(state.tree, config),
361
- subscribe: (listener) => {
362
- listeners.add(listener)
363
- return () => void listeners.delete(listener)
364
- },
365
- snapshot: () => state.tree,
366
- dispose: off,
367
- }
368
- }
@@ -0,0 +1,29 @@
1
+ import type { WirePatch, WireTree } from '@playfast/reform'
2
+
3
+ export interface SnapshotMessage {
4
+ readonly _tag: 'Snapshot'
5
+ readonly tree: WireTree
6
+ }
7
+
8
+ export interface PatchesMessage {
9
+ readonly _tag: 'Patches'
10
+ readonly patches: ReadonlyArray<WirePatch>
11
+ }
12
+
13
+ export type ServerMessage = SnapshotMessage | PatchesMessage
14
+
15
+ export interface InvokeMessage {
16
+ readonly _tag: 'Invoke'
17
+ readonly handle: string
18
+ readonly payload: unknown
19
+ }
20
+
21
+ export interface RemoteTransport<Out, In> {
22
+ readonly send: (message: Out) => void
23
+ readonly onMessage: (handler: (message: In) => void) => () => void
24
+ }
25
+
26
+ export interface ServerBinding {
27
+ readonly start: () => Promise<void>
28
+ readonly dispose: () => Promise<void>
29
+ }