@ultimat3/realtime 29.4.0 → 30.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/CLAUDE.md CHANGED
@@ -98,7 +98,10 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
98
98
  outside the lane, against the live window.
99
99
  - **`desynced` is a mark with a reader**: the next delivery serves a fresh snapshot out of the shared
100
100
  window and only then clears it. **`result.refill` is checked BEFORE the mark** — a guessed window
101
- never clears a mark (`live-fanout.test.ts`).
101
+ never clears a mark (`live-fanout.test.ts`). **A quiet query has three more readers**
102
+ (`sync-drain.test.ts`): Bun's `drain` (`registry.repair`, which waits out a read in flight), a read
103
+ that lands, and the node's sweep (`registry.repairMarked`, on the idle interval), which also re-reads
104
+ a stale window that owes one, once a pass. Never out of a window stale or with a read not settled.
102
105
  - **A change the window already holds is refused** (`change.lsn <= entry.lsn`, counted as
103
106
  `staleChanges`). **A gap is detected**: the replicator stamps `producer` + `seq`; a skipped
104
107
  sequence — or a NEW producer after a known one — stales every window; `refillWindowInLane` replaces it.
@@ -131,6 +134,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
131
134
  `Transport.onReconnect(listener)` (required) announces each recovery.
132
135
  - **A bucket name is checked before interpolation** (`assertBucket`, `X_TRANSPORT_PROTOCOL`); a
133
136
  presence key or member id is base64url-encoded (`encodeToken`), never validated.
137
+ - **A socket closed during its presence write takes the write back** (`joinPresence`, asked after the
138
+ awaits; `sync-frames.test.ts`).
134
139
  - Presence lives in `transport.shared`. The sync node is `PresenceRegistry`'s only caller:
135
140
  subscribing to a topic IS joining, repeating the frame is the heartbeat, dropping or closing is the
136
141
  leave. Expiry is silent, so the node sweeps on an interval, and **one node per topic sweeps** (lowest
@@ -149,7 +154,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
149
154
  late subscription and RAISES `X_TRANSPORT_UNAVAILABLE`. `#release` takes the reserved bridge, never
150
155
  a name.
151
156
  - **An upgrade from another origin is `403 X_SOCKET_ORIGIN_REFUSED`** before `authenticate`
152
- (`sync-origin.ts`): `allowedOrigins`, when declared, is the WHOLE list; undeclared, the
157
+ (`sync-origin.ts`): `allowedOrigins`, when declared, is the WHOLE list (`sec-fetch-site` unread); undeclared, the
153
158
  `Host`-derived origin (and its `https` spelling when reached over plain http). The refusal names
154
159
  the asker and the list. `x dev` keeps the reached-on origin (`admitReachedOrigin`) and adds
155
160
  its web role's beside a declared list; a container adds nothing. `AcceptBudget` is reserved before
@@ -183,6 +188,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
183
188
  per table per node (`ProducerKinds`, `X_REALTIME_PRODUCER_CONFLICT`); the x dev bridge skips a
184
189
  claimed table (`isPublishedTable`). `ChangeEvent.table` is the relation on every producer.
185
190
  - **An error never renders a credential**: `parsePgUrl`/`parseNatsUrl` name the variable.
191
+ - **`deviceSocket()`**: `wiki/Realtime.md#device-sockets`.
186
192
 
187
193
  ## Replication
188
194
 
@@ -214,6 +220,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
214
220
 
215
221
  ## The page
216
222
 
223
+ - **A timed re-read is `useQuery`'s `poll: pollEvery(ms)`** (`query-poll.ts`), non-live by type, through core's `visibleInterval`. Full rule: `docs/history/realtime.md`.
217
224
  - **One record store per PAGE (`record-store.ts`)**, keyed `type:key` with the SERVER's key
218
225
  (`live-record-type.ts`); the browser never derives one. SYNCED truth plus an OVERLAY of pending
219
226
  optimistic writes, REPLAYED over synced truth whenever it moves. Installed as core's `RecordSink` on
@@ -232,8 +239,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
232
239
  routed; a port silent for `REAP_AFTER_BEATS` is reaped; each tab resubscribes from its OWN cursors.
233
240
  No `SharedWorker` → the in-page host runs the same engine over a `MessageChannel`.
234
241
  - **The socket is READ-ONLY (protocol 3)**. A write is `useMutation`: the twin into the overlay, then
235
- `POST actionPath(name)` through core's `clientTransport` with an idempotency key; the answer's
236
- records are adopted inside the transport call, before `settle`. An `ack` is only a refusal.
242
+ the stamped pin (`renderedActionPin`) or `POST actionPath(name)`, via `clientTransport`, keyed;
243
+ the answer's records are adopted inside the transport call, before `settle`. An `ack` is only a refusal.
237
244
  KNOWN offline (`knownOffline()`), it is queued with no POST and painted once on disk (#777).
238
245
  - **`LiveClient` holds no signal** — plain reads plus `onStatus` / `onChange`; hooks wrap them.
239
246
  - **An answer that did not carry a touched row keeps that row's overlay** until the server's next row
@@ -241,12 +248,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
241
248
  - **A catch-up read waits for the page's writes in flight over its types**
242
249
  (`RecordStore.afterWrites`): it lands once each is answered or echoed, within that bound; a row
243
250
  moved meanwhile is kept (#778).
244
- - **A frame names the write that produced it**: `records.write`, `patch.writes` (a live patch
245
- one, a resume each it replays, off `RowPatch.write` in the ring) — `writeDigest(key)`, from a
246
- REQUIRED `ChangeEvent.write: string | null`. `RecordStore.push` digests each overlay key first
247
- (`record-names.ts`); `client-channels.ts` / `live-rows.ts` settle in the frame's own batch. A
248
- non-digest is a protocol error, dropped on the bus. Partly confirmed rows go to
249
- `OverlayEntry.confirmed`. `write-echo.test.ts`, `live-write-echo.test.ts`.
251
+ - **A frame names the write that produced it** (`writeDigest(key)`, from a REQUIRED `ChangeEvent.write`; `write-echo.test.ts`). Full rule: `docs/history/realtime.md`.
250
252
  - `local(tx, input)` is pure and CONVERGENT: no I/O, no `Date.now()`, no `Math.random()`.
251
253
  - **One conflict vocabulary**: `ConflictPolicy` from `@ultimat3/core`, resolved by core's
252
254
  `resolveConflict(overlayRow, serverRow)` in `RecordStore.settle`. A server delete is not a conflict.
@@ -259,12 +261,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
259
261
  reader hears only keys it shows or keeps (history, 2026-10-29).
260
262
  - Every subscription handle is `Disposable`; `[Symbol.dispose]` IS the release. Type claims go in
261
263
  `type-pins.ts`.
262
- - **The outbox**: only `pending` is sendable; `drain()` is one chained pass at a time; `#epoch` is
263
- bumped before the requeue scan, read every pass. `#persist` hands SNAPSHOTS by KEY; one tab drains
264
- (`navigator.locks`), resetting a stored `inflight`. A `rescope` abandons the queue and bumps the
265
- epoch synchronously; `enqueue` decides WHOSE queue before its first await
266
- (`X_OFFLINE_QUEUE_ABANDONED`); `useMutation` fences its POST on its scope epoch. A refused disk is
267
- warned `X_LOCAL_STORE_UNAVAILABLE`, never rejecting `ready`. An entry leaves disk at its ack.
264
+ - **The outbox**: only `pending` is sendable, one chained `drain()` at a time, one tab drains (`navigator.locks`); a `rescope` abandons the queue (`X_OFFLINE_QUEUE_ABANDONED`). Full rule: `docs/history/realtime.md`.
268
265
 
269
266
  ## The client connection
270
267
 
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Three tiers, one ladder, one protocol. Climbing a rung is a config change, never a rewrite.
4
4
 
5
+ `bun add @ultimat3/realtime` · guide: [Realtime](https://github.com/developerz-ai/ultimate/wiki/Realtime)
6
+
5
7
  ## The ladder
6
8
 
7
9
  | Tier | What it gives you | What it costs you |
@@ -84,10 +86,10 @@ class — stay on `.`.
84
86
  | wire | `.` | `PROTOCOL_VERSION` (3), `encode`, `decode`, `Frame` |
85
87
  | the node | `./server` | `syncNode` / `listenSyncNode` (`sync` role) |
86
88
  | a socket's identity | `./server` | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
87
- | hooks | `.` | `useQuery`, `useRecord`, `useRecords` (by key, or a whole type: `RecordSelection`), `useRecordChanges`, `useMutation`, `useMutationQueue`, `useOutbox`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` |
89
+ | hooks | `.` | `useQuery` (and `pollEvery` for its `poll`), `useRecord`, `useRecords` (by key, or a whole type: `RecordSelection`), `useRecordChanges`, `useMutation`, `useMutationQueue`, `useOutbox`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` |
88
90
  | channel records, no replicator | `./server` | `recordPublisher` — an app's committed rows as channel `records`, sequenced per producer ([below](#channel-records-with-no-replicator)) |
89
91
  | channels | `.` | `channel`, `channelRef`, `ChannelHandle`, `topic`, `readPresence`, the channel frame types |
90
- | first paint | `.` | `holdFirstPaint`, `FIRST_PAINT_HOLD_MS` (1 s) — what the island bootstrap awaits before `mount`, for an island that reads the store or the outbox: the boot's restore and the open outbox, capped (#506). An island taking only `useChannel` / `usePresence` / `useConnection` never awaits it and is never hidden |
92
+ | first paint | `.` | `holdFirstPaint`, `FIRST_PAINT_HOLD_MS` (1 s) — what the island bootstrap awaits before `mount`, for an island that reads the store or the outbox: the boot's restore and the open outbox, capped (#506). An island taking only `useChannel` / `usePresence` / `useConnection` never awaits it and is never hidden, nor is one taking `useQuery` / `useRecord` in an app that declares no `persist: true` entity |
91
93
  | offline | `.` | `pageOutbox`, `recordPersister`, `persistedTypes`, `openLocalStore`, `pageLocalStore`, `memoryLocalStore()` |
92
94
  | the socket's worker | `./sync-worker` | the SharedWorker entry — no exports |
93
95
 
@@ -254,6 +256,10 @@ const runs = useRecords<Run>('runs', { // EVERY `ru
254
256
  throttleMs: 1_000, // optional: at most once a second
255
257
  });
256
258
  const like = useMutation(LIKE_POST); // await like(input); like.pending
259
+ // A ref names the primitive, never its URL: an action or query declared `http: { path, method }`
260
+ // is reached where the server's `<meta name="ultimate-pins">` says (core's `renderedActionPin` /
261
+ // `renderedQueryPin`), and a queued write keeps the pin it was sent under. A `:param` the input
262
+ // cannot fill is `X_MUTATION_PATH_UNFILLED`, before anything is painted or sent.
257
263
  const connection = useConnection(); // .offline .online .reconnectAt .updateAvailable
258
264
  const writes = useMutationQueue(); // .pending .failed
259
265
  const outbox = useOutbox(); // .size — queued, not yet sent
@@ -279,6 +285,8 @@ same type `@ultimat3/ui` renders.
279
285
  |---|---|
280
286
  | A query ref is `{ name, live?, entity? }`, never the query VALUE | importing a `query()` drags its read path into the island (698,801 B measured) |
281
287
  | Lists hold keys; rows are the store's | a record updated by any answer or frame re-renders every list showing it, with no refetch |
288
+ | A `useQuery` list is in ANSWER order | the envelope's `order[type]` where the server sent one — an object enumerates integer-like keys ascending, so an `integer()` keyed newest-first feed rendered oldest-first from `records[type]` alone — else `records[type]`'s key order |
289
+ | A live `refetch` re-asks a REFUSED subscription | a refusal with no `retryAfterSeconds` stays `failed` on an open socket until it reconnects; `refetch` calls `LiveHandle.retry()` and settles on the node's next answer, so a pull-to-refresh does something in exactly that state. Any other live state settles at once: the socket is its refresh |
282
290
  | A list that must GROW with the store is `useRecords(type, { where?, order? })` | a `useQuery` list holds its own answer's keys and `useRecords(type, keys)` the keys it was given, so a record a channel's `records` frame adopts reaches neither. The whole-type form is the channel-records counterpart of a live window: `ready` from its first read, every record of the type the store holds (optimistic ones included), default order by key, and none evicted while it is held — a row it alone kept goes when the last reader of the type lets go (#680) |
283
291
  | An answer is the SAME object until what it shows moved | `useQuery`, `useRecord` and both `useRecords` forms hand back the previous answer — and its `data` array — while the status and every row object match: a refetch or poll that found the same rows, or a re-read after an unrelated change, re-runs nothing derived from it (Solid's `===`). A whole-type reader with `where` is not even TOLD of a record it neither shows nor would show: on a run page the viewer's alerts list stopped hearing the fleet's runs (180 of 200 frames, 0 its own) |
284
292
  | A busy feed is throttled by the reader, `useRecords(type, { throttleMs })` | the first change is told at once, a burst as ONE notification at the end of the period, the last always lands; `release()` drops what is owed. A live board on a phone took ten records a second, each a whole-board redraw: `throttleMs: 1000` tells it 21 times in 20 s instead of 200. Absent or `0`: every change, as before. A non-finite or fractional value is `X_INVARIANT`; `schedule` injects the timer in a test |
@@ -288,6 +296,7 @@ same type `@ultimat3/ui` renders.
288
296
  | **No `solid-js` import.** Each bundle installs its own `SignalFactory` | every island carries its own solid-js; a signal from another bundle is invisible to its effects |
289
297
  | Every member is a **getter**, every result an **accessor** | a value snapshotted at hook time never re-renders |
290
298
  | `input` is read **once** | nothing here re-runs it; a changed input is a new `useQuery` |
299
+ | A timed re-read is `useQuery(ref, input, { poll: pollEvery(ms) })` | non-live refs only (a live ref refuses it by type: `LiveQueryOptions`); `refetch` through core's `visibleInterval`, so a hidden tab, a lost session and `release()` all stop it. A value, not a number: `useQuery` never references the timer, so only an island importing `pollEvery` carries it (the reference like control rose 39,210 → 41,477 B across wave 5a, nearly all of it this, 2026-11-05) |
291
300
  | The caller owns `release()` / `using` | this layer does not know what a mount is |
292
301
 
293
302
  **A server render** (no install, no DOM) answers `pending`, reports online and creates no page
@@ -382,7 +391,7 @@ wire.
382
391
  | Ceiling | Default | Option | Refused with |
383
392
  |---|---|---|---|
384
393
  | concurrent sockets on this node | 250,000 | `syncNode({ maxConnections })` | `503` + `retry-after-ms`, the same shed as the accept budget |
385
- | inbound bytes per frame | 256 KiB | `syncNode({ maxFrameBytes })` | the socket, by `Bun.serve`'s `maxPayloadLength` |
394
+ | inbound bytes per frame | 256 KiB | `syncNode({ maxFrameBytes })` | a `1009` close, by `Bun.serve`'s `maxPayloadLength` and by the node itself — a listener shared with a `deviceSocket()` takes the widest mount's limit |
386
395
  | inbound frames per socket | 64/s, burst 256 | `syncNode({ maxFramesPerSecond, frameBurst })` | `X_FRAME_RATE_LIMIT` |
387
396
  | live subscriptions per socket | 128 | `new LiveQueryRegistry({ maxPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
388
397
  | live subscriptions per actor (anonymous: per resolved client address), across its sockets | 1,000 (`DEFAULT_MAX_PER_ACTOR`) | `new LiveQueryRegistry({ maxPerActor })`; the boot passes `realtime.maxSubscriptionsPerActor` | `X_SUBSCRIPTION_LIMIT` |
@@ -480,7 +489,7 @@ is admitted is decided in `sync-origin.ts`:
480
489
  | Admitted | When |
481
490
  |---|---|
482
491
  | no `Origin` header | always — RFC 6455 has every browser send one, so its absence is not a browser |
483
- | `syncNode({ allowedOrigins })`, compared exactly (scheme, host, port) | when any is declared. The declaration is the WHOLE list (24.0.0): the origin the node was reached on is no longer admitted beside it, because that origin is read off the `Host` header and a sibling subdomain pointed at the node would be "its own" |
492
+ | `syncNode({ allowedOrigins })`, compared exactly (scheme, host, port) | when any is declared. The declaration is the WHOLE list (24.0.0): the origin the node was reached on is no longer admitted beside it, because that origin is read off the `Host` header and a sibling subdomain pointed at the node would be "its own". `sec-fetch-site` is not read beside it, for the same reason |
484
493
  | the origin the node was reached on | only when nothing is declared. A node reached over plain `http` also admits the `https` spelling of that host and port: TLS ended at a proxy, the scheme is not knowable there, and a browser sends no `sec-fetch-site` on a websocket handshake |
485
494
 
486
495
  The CLI passes `APP_URL`'s origin, which the Compose rung (`:3000` / `:3001`) requires on `sync`
@@ -514,6 +523,16 @@ engine (one per origin, in the `SharedWorker`) keeps none of it across pages: a
514
523
  the node is down dials at once on a fresh curve, and the last page leaving forgets the target, so
515
524
  the next build's page never dials with the old build id.
516
525
 
526
+ ### A live read joins its caller's trace
527
+
528
+ `As of 2026-11`. A `subscribe` frame may carry `traceparent` (additive, no protocol bump), from
529
+ `LiveClient({ traceparent: () => … })`, asked per frame. The node keeps the newest subscriber's on
530
+ the window: the snapshot a subscribe causes runs in `live.snapshot <query>`, a child of it; a
531
+ re-read a change forces runs in `live.rerun <query>`, linked to it and parented on the change
532
+ (`@ultimat3/query`'s `traceLiveRead`). A non-string or oversized value is dropped at decode and an
533
+ unparsable one ignored — telemetry never fails a subscribe. The page socket sends none: a browser
534
+ holds no ambient trace.
535
+
517
536
  ### Liveness: `heartbeatMs`
518
537
 
519
538
  A half-open socket — the TCP connection is dead and no `close` ever fires — is invisible to the
@@ -874,6 +893,26 @@ principal.
874
893
  (`maxRowsPerType` / `maxRowsByType`, least recently written first). An eviction is a change like any other: the
875
894
  persister deletes the row from disk with it. Persisting it (IndexedDB) is plan 101 slice 12.
876
895
 
896
+ ## Device sockets
897
+
898
+ `deviceSocket({ path, resolveToken, onFrame, maxSocketsPerActor?, heartbeatMs?, allowedOrigins? })`
899
+ — a declared socket for a non-browser client, from `./server`. A factory over `route`: the value is
900
+ a websocket mount plus `request(ctx, deviceId, frame, { deadlineMs })`, which reaches one connected
901
+ device from any process over the bus, scoped to `ctx.actor.orgId`. `deviceAgent({ url, token,
902
+ onRequest })` is the Bun client. Every rule (credential, caps, frame limit, beat, drain, codes):
903
+ [wiki/Realtime.md#device-sockets](../../wiki/Realtime.md#device-sockets).
904
+
905
+ | File | Job |
906
+ |---|---|
907
+ | `device-socket.ts` | the declaration, its screen, the registry, `request()` and `attach()` |
908
+ | `device-upgrade.ts` | the upgrade decision: origin, bearer token under a deadline, expiry, cap |
909
+ | `device-host.ts` | the sockets one node holds: frames, the lane, the sweep, the drain |
910
+ | `device-bus.ts` | where a device is and how a request reaches it and comes back |
911
+ | `device-wire.ts` | both hops' frames and the frame limit |
912
+ | `device-agent.ts` | the device's half, on Bun |
913
+ | `device-errors.ts` | its refusals |
914
+ | `device-declarer.ts` | the file that called `deviceSocket()`, so `x dev` refuses another module taking a path |
915
+
877
916
  ## Errors
878
917
 
879
918
  `X_TOPIC_FORBIDDEN` · `X_SUBSCRIPTION_LIMIT` · `X_SUBSCRIPTION_ID_TAKEN` ·
@@ -883,7 +922,8 @@ principal.
883
922
  `X_REALTIME_UNINSTALLED` · `X_SYNC_UNCONFIGURED` · `X_RECORD_REJECTED` ·
884
923
  `X_LIVE_SERVER_RENDER` · `X_LIVE_QUERY_UNKNOWN` ·
885
924
  `X_LIVE_REPLICA_IDENTITY` ·
886
- `X_SOCKET_UNAUTHENTICATED` · `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED` ·
925
+ `X_SOCKET_UNAUTHENTICATED` · `X_SOCKET_AUTH_UNAVAILABLE` ·
926
+ `X_DEVICE_OFFLINE` · `X_DEVICE_REQUEST_TIMEOUT` · `X_DEVICE_FRAME_TOO_LARGE` · `X_NOT_IMPLEMENTED` ·
887
927
  `X_TIMEOUT`
888
928
 
889
929
  `X_NOT_IMPLEMENTED` and `X_TIMEOUT` are **borrowed** from `@ultimat3/core`, which owns and titles
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "29.4.0",
3
+ "version": "30.1.0",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
+ "keywords": [
6
+ "ultimate",
7
+ "bun",
8
+ "framework",
9
+ "realtime",
10
+ "websocket",
11
+ "local-first",
12
+ "sync"
13
+ ],
14
+ "homepage": "https://github.com/developerz-ai/ultimate/wiki/Realtime",
5
15
  "license": "MIT",
6
16
  "type": "module",
7
17
  "sideEffects": [
@@ -40,10 +50,10 @@
40
50
  "test": "bun test"
41
51
  },
42
52
  "dependencies": {
43
- "@ultimat3/core": "29.4.0",
44
- "@ultimat3/entity": "29.4.0",
45
- "@ultimat3/policy": "29.4.0",
46
- "@ultimat3/query": "29.4.0",
53
+ "@ultimat3/core": "30.1.0",
54
+ "@ultimat3/entity": "30.1.0",
55
+ "@ultimat3/policy": "30.1.0",
56
+ "@ultimat3/query": "30.1.0",
47
57
  "nats": "2.29.3"
48
58
  }
49
59
  }
@@ -37,6 +37,12 @@ export interface LiveHandle<R extends object = Row> extends Disposable {
37
37
  cursor(): LiveCursor | null;
38
38
  /** What the node answered when it refused the subscription; `undefined` unless `failed`. */
39
39
  error(): unknown;
40
+ /**
41
+ * Ask the node again, now, for a `failed` subscription on an open socket — a refusal with no
42
+ * stated delay stays refused until the socket itself reconnects. `true` = a subscribe went out
43
+ * and the state is `loading`; `false` = nothing to retry, or no socket (the reopen replays it).
44
+ */
45
+ retry(): boolean;
40
46
  /** Called after anything the reads above answer has moved. Returns the unsubscribe. */
41
47
  onChange(listener: () => void): () => void;
42
48
  unsubscribe(): void;
@@ -71,6 +77,12 @@ export interface LiveClientOptions {
71
77
  readonly heartbeatMs?: number;
72
78
  /** Where a failure nobody awaits is reported. Defaults to `console.error`. */
73
79
  readonly onError?: (error: unknown) => void;
80
+ /**
81
+ * The W3C `traceparent` a live subscribe carries, asked at each `subscribe` frame, so the read
82
+ * it causes joins the caller's trace. Absent, or answering `undefined`, sends none: a browser
83
+ * has no ambient trace, and the node then starts the read's own.
84
+ */
85
+ readonly traceparent?: () => string | undefined;
74
86
  /**
75
87
  * A channel's catch-up read, re-run on `replay-gap` or a new epoch: the named query, answering its
76
88
  * records UNADOPTED for the channel book to adopt with the frames held behind the read.
package/src/client.ts CHANGED
@@ -300,6 +300,7 @@ export class LiveClient {
300
300
  state: (): LiveState => registration.state,
301
301
  cursor: (): LiveCursor | null => registration.cursor,
302
302
  error: (): unknown => registration.error,
303
+ retry: () => this.#retryNow(registration),
303
304
  onChange: (listener) => {
304
305
  listeners.add(listener);
305
306
  return () => {
@@ -324,6 +325,7 @@ export class LiveClient {
324
325
  }
325
326
 
326
327
  #sendSubscribe(registration: Registration): void {
328
+ const traceparent = this.#options.traceparent?.();
327
329
  this.#send({
328
330
  type: 'subscribe',
329
331
  v: PROTOCOL_VERSION,
@@ -335,6 +337,7 @@ export class LiveClient {
335
337
  input: registration.input,
336
338
  cursor: registration.cursor,
337
339
  },
340
+ ...(traceparent === undefined ? {} : { traceparent }),
338
341
  });
339
342
  }
340
343
 
@@ -361,23 +364,30 @@ export class LiveClient {
361
364
 
362
365
  /**
363
366
  * Ask again once the node's stated delay has passed — a rate-limited subscribe, whose bucket
364
- * refills. Only while the registration is still held, still refused and the socket still up:
365
- * anything else and the reopen's replay, or the unsubscribe, already decided its fate.
367
+ * refills. `#retryNow` holds it to a registration still held and refused on an open socket.
366
368
  */
367
369
  #retryLater(registration: Registration, afterMs: number): void {
368
370
  this.#retries.get(registration.sid)?.();
369
371
  const cancel = (this.#options.scheduler ?? timeoutScheduler)(() => {
370
372
  this.#retries.delete(registration.sid);
371
- if (this.#registrations.get(registration.sid) !== registration) return;
372
- if (!this.#connected || registration.state !== 'failed') return;
373
- registration.state = 'loading';
374
- registration.error = undefined;
375
- registration.notify();
376
- this.#sendSubscribe(registration);
373
+ this.#retryNow(registration);
377
374
  }, afterMs);
378
375
  this.#retries.set(registration.sid, cancel);
379
376
  }
380
377
 
378
+ /** The re-subscribe itself, timed or asked for (`LiveHandle.retry`). Cancels a pending timer. */
379
+ #retryNow(registration: Registration): boolean {
380
+ const held = this.#registrations.get(registration.sid) === registration;
381
+ if (!held || !this.#connected || registration.state !== 'failed') return false;
382
+ this.#retries.get(registration.sid)?.();
383
+ this.#retries.delete(registration.sid);
384
+ registration.state = 'loading';
385
+ registration.error = undefined;
386
+ registration.notify();
387
+ this.#sendSubscribe(registration);
388
+ return true;
389
+ }
390
+
381
391
  /** The opening frame, and the heartbeat's. One shape, because it makes one claim: I am here. */
382
392
  #hello(): Frame {
383
393
  return {
@@ -0,0 +1,174 @@
1
+ // The device's half of a `deviceSocket()`, for an agent written on Bun: dial with the bearer token,
2
+ // beat at the interval the node names, answer `request` frames, and redial on realtime's one
3
+ // jittered backoff — or at the slot a draining node's `reconnect` frame assigns.
4
+
5
+ import { reportError } from '@ultimat3/core';
6
+ import { bytesOverLimit, type DeviceInbound, decodeNodeFrame } from './device-wire';
7
+ import type { JsonValue } from './json';
8
+ import {
9
+ type BackoffPolicy,
10
+ defaultBackoff,
11
+ policyDelay,
12
+ type Rng,
13
+ type Scheduler,
14
+ timeoutScheduler,
15
+ } from './thundering-herd';
16
+
17
+ /** As much of a websocket as the agent drives — Bun's `WebSocket` fits. */
18
+ export interface AgentSocket {
19
+ send(data: string): void;
20
+ close(code?: number, reason?: string): void;
21
+ onopen: (() => void) | null;
22
+ onmessage: ((event: { readonly data: unknown }) => void) | null;
23
+ onclose: (() => void) | null;
24
+ }
25
+
26
+ export type AgentDial = (url: string, headers: Readonly<Record<string, string>>) => AgentSocket;
27
+
28
+ /**
29
+ * Bun's `WebSocket` takes `{ headers }` and sends them on the upgrade — the thing a browser's
30
+ * cannot do, and why a device token rides `Authorization` rather than the URL. The DOM lib this
31
+ * repo also loads types the constructor without that overload (`Bun.WebSocketOptions` exists only
32
+ * when it is absent), so the one call is typed by the shape Bun really accepts.
33
+ */
34
+ type HeaderedWebSocket = new (
35
+ url: string,
36
+ options: { readonly headers: Readonly<Record<string, string>> },
37
+ ) => AgentSocket;
38
+
39
+ const bunDial: AgentDial = (url, headers) =>
40
+ new (WebSocket as unknown as HeaderedWebSocket)(url, { headers });
41
+
42
+ export interface DeviceAgentOptions {
43
+ /** `wss://app.example.com/link` — the declaration's path on the app's origin. */
44
+ readonly url: string;
45
+ /** Asked on every dial, so a refreshed token is the next connection's. */
46
+ readonly token: () => string | Promise<string>;
47
+ /** The answer to a node's `request(ctx, deviceId, frame)`. */
48
+ readonly onRequest: (frame: JsonValue) => Promise<JsonValue> | JsonValue;
49
+ /** What `onFrame` on the node answered, and anything else it pushes. */
50
+ readonly onFrame?: ((frame: JsonValue) => void) | undefined;
51
+ readonly backoff?: BackoffPolicy | undefined;
52
+ readonly rng?: Rng | undefined;
53
+ readonly schedule?: Scheduler | undefined;
54
+ readonly dial?: AgentDial | undefined;
55
+ }
56
+
57
+ export interface DeviceAgent {
58
+ readonly connected: boolean;
59
+ /** One frame for the node's `onFrame`. `false` when no socket is open to carry it. */
60
+ send(frame: JsonValue): boolean;
61
+ /** Closes for good: no redial. */
62
+ close(): void;
63
+ }
64
+
65
+ export function deviceAgent(options: DeviceAgentOptions): DeviceAgent {
66
+ const schedule = options.schedule ?? timeoutScheduler;
67
+ const backoff = options.backoff ?? defaultBackoff;
68
+ const dial = options.dial ?? bunDial;
69
+ let socket: AgentSocket | null = null;
70
+ let open = false;
71
+ let closed = false;
72
+ let attempt = 0;
73
+ let cancelBeat: () => void = () => undefined;
74
+ let cancelRedial: (() => void) | null = null;
75
+
76
+ const write = (frame: DeviceInbound): boolean => {
77
+ const text = JSON.stringify(frame);
78
+ if (!open || socket === null || bytesOverLimit(text) !== null) return false;
79
+ socket.send(text);
80
+ return true;
81
+ };
82
+
83
+ const beat = (everyMs: number): void => {
84
+ cancelBeat();
85
+ cancelBeat = schedule(() => {
86
+ write({ type: 'beat' });
87
+ beat(everyMs);
88
+ }, everyMs);
89
+ };
90
+
91
+ const redialIn = (ms: number): void => {
92
+ if (closed || cancelRedial !== null) return;
93
+ cancelRedial = schedule(() => {
94
+ cancelRedial = null;
95
+ void connect();
96
+ }, ms);
97
+ };
98
+
99
+ const answer = async (id: string, frame: JsonValue): Promise<void> => {
100
+ try {
101
+ write({ type: 'response', id, frame: await options.onRequest(frame) });
102
+ } catch (error) {
103
+ // No answer is the answer: the node's deadline tells its caller, with the code that says so.
104
+ reportError(error, { source: 'realtime', scope: { operation: 'device.agent_request' } });
105
+ }
106
+ };
107
+
108
+ const receive = (current: AgentSocket, data: unknown): void => {
109
+ if (current !== socket || typeof data !== 'string') return;
110
+ const frame = decodeNodeFrame(data);
111
+ if (frame === undefined) return;
112
+ if (frame.type === 'hello') beat(frame.heartbeatMs);
113
+ else if (frame.type === 'request') void answer(frame.id, frame.frame);
114
+ else if (frame.type === 'frame') options.onFrame?.(frame.frame);
115
+ else if (frame.type === 'reconnect') {
116
+ // The node's slot, not the backoff: the spread is what keeps a fleet restart off one node.
117
+ attempt = 0;
118
+ cancelRedial?.();
119
+ cancelRedial = null;
120
+ redialIn(frame.afterMs);
121
+ current.close(4002, 'reconnect');
122
+ }
123
+ };
124
+
125
+ async function connect(): Promise<void> {
126
+ if (closed) return;
127
+ let current: AgentSocket;
128
+ try {
129
+ const token = await options.token();
130
+ // `close()` may have landed while the token was fetched: a dial now is a socket, and a beat,
131
+ // that nothing would ever close.
132
+ if (closed) return;
133
+ current = dial(options.url, { authorization: `Bearer ${token}` });
134
+ } catch (error) {
135
+ reportError(error, { source: 'realtime', scope: { operation: 'device.agent_dial' } });
136
+ attempt += 1;
137
+ redialIn(policyDelay(backoff, attempt, options.rng));
138
+ return;
139
+ }
140
+ socket = current;
141
+ current.onopen = () => {
142
+ if (current !== socket) return;
143
+ open = true;
144
+ attempt = 0;
145
+ };
146
+ current.onmessage = (event) => receive(current, event.data);
147
+ current.onclose = () => {
148
+ if (current !== socket) return;
149
+ socket = null;
150
+ open = false;
151
+ cancelBeat();
152
+ attempt += 1;
153
+ redialIn(policyDelay(backoff, attempt, options.rng));
154
+ };
155
+ }
156
+
157
+ void connect();
158
+ return {
159
+ get connected() {
160
+ return open;
161
+ },
162
+ send: (frame) => write({ type: 'frame', frame }),
163
+ close: () => {
164
+ closed = true;
165
+ cancelBeat();
166
+ cancelRedial?.();
167
+ cancelRedial = null;
168
+ const current = socket;
169
+ socket = null;
170
+ open = false;
171
+ current?.close(1000, 'closed');
172
+ },
173
+ };
174
+ }