@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 +7 -3
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/client-frames.ts +13 -1
- package/src/client.ts +10 -5
- package/src/close-codes.ts +21 -0
- package/src/socket.ts +28 -13
- package/src/sync-frames.ts +3 -0
- package/src/sync-upgrade.ts +7 -0
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
|
|
769
|
-
|
|
770
|
-
|
|
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
|
|
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.
|
|
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.
|
|
40
|
-
"@ultimat3/query": "19.3.
|
|
39
|
+
"@ultimat3/core": "19.3.3",
|
|
40
|
+
"@ultimat3/query": "19.3.3",
|
|
41
41
|
"nats": "2.29.3"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/client-frames.ts
CHANGED
|
@@ -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(
|
|
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
|
-
/**
|
|
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
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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. */
|
package/src/sync-frames.ts
CHANGED
|
@@ -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,
|
package/src/sync-upgrade.ts
CHANGED
|
@@ -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
|