@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 +13 -16
- package/README.md +45 -5
- package/package.json +15 -5
- package/src/client-contract.ts +12 -0
- package/src/client.ts +18 -8
- package/src/device-agent.ts +174 -0
- package/src/device-bus.ts +258 -0
- package/src/device-declarer.ts +32 -0
- package/src/device-errors.ts +89 -0
- package/src/device-host.ts +288 -0
- package/src/device-socket.ts +386 -0
- package/src/device-upgrade.ts +218 -0
- package/src/device-wire.ts +155 -0
- package/src/error-titles.ts +8 -0
- package/src/index.ts +8 -1
- package/src/live-contract.ts +15 -1
- package/src/live-definition.ts +15 -3
- package/src/live-fanout.ts +40 -0
- package/src/live-query.ts +42 -2
- package/src/live-spend.ts +2 -0
- package/src/offline-queue.ts +5 -0
- package/src/outbox-slot.ts +7 -1
- package/src/page-errors.ts +15 -0
- package/src/page-outbox.ts +16 -9
- package/src/pinned-wire.ts +64 -0
- package/src/query-poll.ts +19 -0
- package/src/query-window.ts +39 -7
- package/src/server.ts +43 -0
- package/src/socket.ts +1 -1
- package/src/sync-frames.ts +8 -0
- package/src/sync-node-bounds.ts +20 -0
- package/src/sync-node.ts +10 -2
- package/src/sync-origin.ts +5 -3
- package/src/sync-protocol.ts +18 -3
- package/src/use-mutation.ts +22 -5
- package/src/use-query.ts +82 -23
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)
|
|
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
|
|
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
|
|
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
|
|
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 })` |
|
|
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` ·
|
|
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": "
|
|
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": "
|
|
44
|
-
"@ultimat3/entity": "
|
|
45
|
-
"@ultimat3/policy": "
|
|
46
|
-
"@ultimat3/query": "
|
|
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
|
}
|
package/src/client-contract.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
}
|