@ultimat3/realtime 19.3.1 → 19.3.3

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
@@ -765,9 +765,13 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
765
765
  `DEFAULT_HEARTBEAT_MS`, 15s; `0` disables) sends a `hello` — byte-identical to the opening one,
766
766
  since the frame has no resume list to leave out — plus one subscribe frame per topic, which is the
767
767
  node's presence heartbeat. It is **not** how a deploy is noticed: `socket.skewed` compares the
768
- build id recorded at the upgrade against this node's, both fixed for the socket's life, so every
769
- `hello` on one socket answers the same forever and `update-available` reaches a client on the
770
- socket it opens against the *new* node. Two silent windows and the client closes with `4000` and
768
+ build the client claims (the `hello`'s `buildId`, which `sawHello` records on every one — the
769
+ latest is the record — or `?build=` on the dial) against this node's; a client says the same
770
+ build on every beat and the node's never moves while the socket is open, so every `hello` on one
771
+ socket answers the same forever and `update-available` reaches a client on the
772
+ socket it opens against the *new* node. The hello IS read — until 2026-09-07 only the dial was,
773
+ and a dial without `?build=` was recorded as this node's own id, so a client naming its build only
774
+ in the frame was current forever. Two silent windows and the client closes with `4000` and
771
775
  arms the reconnect. It is one
772
776
  self-re-arming tick on the injected `Scheduler`, not an interval: a client is either beating on a
773
777
  live socket or backing off toward a new one, never both. The 15s is the client's OWN number:
package/README.md CHANGED
@@ -306,7 +306,7 @@ new LiveClient({ signal, connect, buildId, heartbeatMs: 15_000 }); // 0 disables
306
306
  | Default | `DEFAULT_HEARTBEAT_MS`, 15s. The client's own number and the only one: `realtime.heartbeatMs` in `app.config.ts` was deleted 2026-08-19 because nothing read it |
307
307
  | One beat | a `hello` — which carries no cursors at all; `HelloFrame` has no resume list, so a beat and an opening frame are byte-identical — plus one subscribe frame per topic held |
308
308
  | Why the topics | on the node, repeating the subscribe frame **is** the presence heartbeat; presence has no frame of its own in either direction |
309
- | Not a deploy check | `update-available` answers a skew between the build id recorded at the upgrade and the node's own, and neither can change on an open socket — so every `hello` on one socket answers the same forever. A client hears about a deploy on the socket it opens against the **new** node |
309
+ | Not a deploy check | `update-available` answers a skew between the build the client claims — the `hello`'s `buildId`, or `?build=` on the dial; every hello is read and the latest one is the record — and the node's own. A client says the same build on every beat and the node's never moves while the socket is open, so every `hello` on one socket answers the same forever. A client hears about a deploy on the socket it opens against the **new** node |
310
310
  | Silence | nothing received for **two** intervals ⇒ close `4000` (a private-use code, so it is distinguishable in a log) and arm the reconnect. Judged from the last frame of any kind, since the point is that bytes still cross |
311
311
  | Not an interval | one armed tick, re-armed by itself, on the same injected `Scheduler` the reconnect uses — a client is either beating on a live socket or backing off toward a new one, never both |
312
312
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "19.3.1",
3
+ "version": "19.3.3",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,8 +36,8 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/core": "19.3.1",
40
- "@ultimat3/query": "19.3.1",
39
+ "@ultimat3/core": "19.3.3",
40
+ "@ultimat3/query": "19.3.3",
41
41
  "nats": "2.29.3"
42
42
  }
43
43
  }
@@ -3,6 +3,7 @@
3
3
  // every piece of the client a frame may touch, so the blast radius of a new frame kind is a
4
4
  // reviewable list rather than "whatever the router could reach through `this`".
5
5
 
6
+ import { CLOSE } from './close-codes';
6
7
  import { advance } from './cursor';
7
8
  import type { JsonObject, JsonValue } from './json';
8
9
  import type { Registration, RowWindows } from './live-rows';
@@ -14,6 +15,17 @@ import type { Frame, PresenceMember } from './sync-protocol';
14
15
  /** Declared with the window it projects; re-exported here because the router is what writes it. */
15
16
  export type { LiveState, Registration } from './live-rows';
16
17
 
18
+ /**
19
+ * The code a `reconnect` frame closes with: `CLOSE.drain`, the same number the node uses for a
20
+ * drain it closes itself, so a log reads one code for one event whichever side closed first. It
21
+ * was 1001, and a browser refuses that from script: `WebSocket.close()` throws
22
+ * `InvalidAccessError: The close code must be either 1000, or between 3000 and 4999` — measured
23
+ * in Chrome, an uncaught exception in every tab on every node drain. The reconnect still happened,
24
+ * because the node closed the socket a moment later; the exception was the only trace.
25
+ * `HEARTBEAT_TIMEOUT_CODE` in `client.ts` is the sibling, for the other close the client makes.
26
+ */
27
+ export const RECONNECT_CODE = CLOSE.drain;
28
+
17
29
  /**
18
30
  * Everything an inbound frame is allowed to reach. Narrow on purpose — a router that took the
19
31
  * client itself could touch the reconnect timer, the socket and the outbound path, none of which
@@ -151,7 +163,7 @@ export function applyFrame<T extends TableMap>(frame: Frame, target: ClientFrame
151
163
  // Order is load-bearing: arming first is what makes the close this triggers keep the delay
152
164
  // the node assigned to *this* socket instead of falling back to a local backoff.
153
165
  target.scheduleReconnect(frame.afterMs);
154
- target.closeSocket(1001, frame.reason);
166
+ target.closeSocket(RECONNECT_CODE, frame.reason);
155
167
  return;
156
168
  }
157
169
  case 'update-available': {
package/src/client.ts CHANGED
@@ -45,7 +45,11 @@ export type {
45
45
  /** The four states a live subscription renders. Declared with the window that holds them. */
46
46
  export type { LiveState } from './live-rows';
47
47
 
48
- /** Private-use close code (4000–4999), so a heartbeat timeout is distinguishable in a log. */
48
+ /**
49
+ * Private-use close code (4000–4999), so a heartbeat timeout is distinguishable in a log. A browser
50
+ * accepts only 1000 and 3000–4999 from script; `RECONNECT_CODE` (`client-frames.ts`) is the
51
+ * sibling, for the close a `reconnect` frame makes.
52
+ */
49
53
  const HEARTBEAT_TIMEOUT_CODE = 4000;
50
54
 
51
55
  /** The default reporter: `console.error`, never core's `logger` — that writes `process.stderr`. */
@@ -395,10 +399,11 @@ export class LiveClient<T extends TableMap = TableMap> {
395
399
  * heartbeat: subscribing IS being in the room, so a client that stopped repeating it is swept
396
400
  * out of every room it is still receiving from.
397
401
  *
398
- * It is NOT how a deploy is noticed. `socket.skewed` compares the build id the upgrade recorded
399
- * against this node's, both fixed for the socket's whole life, so every `hello` on one socket
400
- * gets the same answer forever; `update-available` reaches a client on the socket it opens
401
- * against the *new* node, which is a reconnect and never a beat.
402
+ * It is NOT how a deploy is noticed. `socket.skewed` compares the build id this client says —
403
+ * the `hello`'s own `buildId`, the same on every beat, or `?build=` on the dial — against the
404
+ * node's, and neither moves while the socket is open, so every `hello` on one socket gets the
405
+ * same answer forever; `update-available` reaches a client on the socket it opens against the
406
+ * *new* node, which is a reconnect and never a beat.
402
407
  */
403
408
  #beat(): void {
404
409
  this.#send(this.#hello());
@@ -0,0 +1,21 @@
1
+ // The close codes this protocol speaks, defined once below both halves. `socket.ts` (the node's
2
+ // registry) and `client-frames.ts` (browser code) each need one of these and neither may import
3
+ // the other — the client must not pull the node's registry into the tab — so the table lives here,
4
+ // a leaf with no import at all, and `socket.ts` re-exports it for the node-side files that already
5
+ // read `CLOSE` from there.
6
+ //
7
+ // 1000–1015 are the RFC 6455 codes; 4000–4999 are private use. A browser accepts only 1000 and
8
+ // 3000–4999 from script (`WebSocket.close()` throws `InvalidAccessError` on anything else), which
9
+ // is why every code the CLIENT sends is in the private range and why `goingAway` (1001) is a code
10
+ // only the node may close with.
11
+
12
+ export const CLOSE = {
13
+ normal: 1000,
14
+ goingAway: 1001,
15
+ policy: 1008,
16
+ overloaded: 1013,
17
+ versionSkew: 4000,
18
+ idle: 4001,
19
+ /** The node drains, or the client obeys a `reconnect` frame: one event, one code, either side. */
20
+ drain: 4002,
21
+ } as const;
package/src/socket.ts CHANGED
@@ -16,18 +16,12 @@ import {
16
16
  systemClock,
17
17
  uuid,
18
18
  } from '@ultimat3/core';
19
+ import { CLOSE } from './close-codes';
19
20
  import { encode, type Frame } from './sync-protocol';
20
21
  import { AcceptBudget } from './thundering-herd';
21
22
 
22
- export const CLOSE = {
23
- normal: 1000,
24
- goingAway: 1001,
25
- policy: 1008,
26
- overloaded: 1013,
27
- versionSkew: 4000,
28
- idle: 4001,
29
- drain: 4002,
30
- } as const;
23
+ /** Defined in `close-codes.ts`, below both halves; re-exported for the node-side files. */
24
+ export { CLOSE } from './close-codes';
31
25
 
32
26
  /**
33
27
  * The slice of Bun's `ServerWebSocket` this package uses. Structural, so tests need no server.
@@ -49,7 +43,11 @@ export interface WsLike {
49
43
 
50
44
  export interface SyncSocketOptions {
51
45
  readonly ws: WsLike;
52
- /** Build id the *client* reported in `hello`. Version skew is a first-class connection state. */
46
+ /**
47
+ * Build id the *client* reported on the dial (`?build=`), or this node's own when it sent none;
48
+ * `sawHello` overwrites it with what the `hello` frame says. Version skew is a first-class
49
+ * connection state.
50
+ */
53
51
  readonly clientBuildId: string;
54
52
  readonly serverBuildId: string;
55
53
  readonly actor?: Actor | null;
@@ -113,7 +111,6 @@ export function actorIdOf(actor: Actor | null): string | null {
113
111
  */
114
112
  export class SyncSocket {
115
113
  readonly id: string;
116
- readonly clientBuildId: string;
117
114
  readonly serverBuildId: string;
118
115
  readonly openedAt: number;
119
116
  /** Channel topics (tier 1). Live-query subscriptions are keyed separately, by sid. */
@@ -149,13 +146,14 @@ export class SyncSocket {
149
146
  readonly #clock: Clock;
150
147
  readonly #maxBufferedBytes: number;
151
148
  readonly #maxDroppedFrames: number;
149
+ #clientBuildId: string;
152
150
  #closed = false;
153
151
 
154
152
  constructor(options: SyncSocketOptions) {
155
153
  this.#ws = options.ws;
156
154
  this.#clock = options.clock ?? systemClock;
157
155
  this.id = options.id ?? uuid();
158
- this.clientBuildId = options.clientBuildId;
156
+ this.#clientBuildId = options.clientBuildId;
159
157
  this.serverBuildId = options.serverBuildId;
160
158
  this.actor = options.actor ?? null;
161
159
  this.#maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
@@ -185,9 +183,26 @@ export class SyncSocket {
185
183
  return this.#closed;
186
184
  }
187
185
 
186
+ /** The build the client last claimed: the dial's `?build=`, then whatever its `hello` said. */
187
+ get clientBuildId(): string {
188
+ return this.#clientBuildId;
189
+ }
190
+
191
+ /**
192
+ * The `hello` frame is the documented place a client names its build, and until 2026-09-07 the
193
+ * node never read it: `clientBuildId` came from the dial's `?build=` alone and defaulted to this
194
+ * node's OWN id when the query was absent — so a client that said `hello` from any build at all
195
+ * was deemed current forever, and `update-available` never came. Measured on ai-maxxing: a page
196
+ * sending `buildId: "dev"` in every hello to a node on `46db23f57d6ef969`. The last word wins,
197
+ * and the hello is the later one; `?build=` still works for a dial that carries it.
198
+ */
199
+ sawHello(buildId: string): void {
200
+ this.#clientBuildId = buildId;
201
+ }
202
+
188
203
  /** A skewed client gets an `update-available` frame; it is never silently served a new shape. */
189
204
  get skewed(): boolean {
190
- return this.clientBuildId !== this.serverBuildId;
205
+ return this.#clientBuildId !== this.serverBuildId;
191
206
  }
192
207
 
193
208
  /** `false` means the frame was dropped by backpressure — the caller must mark state stale. */
@@ -76,6 +76,9 @@ export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
76
76
  async function apply(socket: SyncSocket, frame: Frame): Promise<void> {
77
77
  switch (frame.type) {
78
78
  case 'hello': {
79
+ // Before `skewed` is asked: the frame is the client's word on its build, and the upgrade
80
+ // may have recorded none (a dial without `?build=` defaults to this node's own id).
81
+ socket.sawHello(frame.buildId);
79
82
  socket.send({
80
83
  type: 'hello',
81
84
  v: PROTOCOL_VERSION,
@@ -14,6 +14,12 @@ import type { AcceptBudget, Rng } from './thundering-herd';
14
14
  */
15
15
  export interface WsData {
16
16
  readonly socketId: string;
17
+ /**
18
+ * `?build=` off the dial, or this node's own id when the dial carried none. A starting value,
19
+ * not the verdict: the `hello` frame's `buildId` overwrites it (`SyncSocket.sawHello`), so a
20
+ * client that names its build only in the frame — the documented place — is not deemed current
21
+ * forever for having sent no query.
22
+ */
17
23
  readonly clientBuildId: string;
18
24
  }
19
25
 
@@ -118,6 +124,7 @@ export async function handleUpgrade(
118
124
  if (!deps.ready() || deps.socketCount() >= deps.maxConnections) return shed(deps);
119
125
  const data: WsData = {
120
126
  socketId: deps.newSocketId(),
127
+ // The node's own id is "not skewed until the hello says so", never "current forever".
121
128
  clientBuildId: url.searchParams.get('build') ?? deps.buildId,
122
129
  };
123
130
  // Before the upgrade, never after: `server.upgrade` runs `websocket.open` synchronously and does