@ultimat3/realtime 1.2.0 → 3.0.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.
Files changed (61) hide show
  1. package/CLAUDE.md +641 -0
  2. package/README.md +336 -19
  3. package/package.json +6 -3
  4. package/src/apply-patches.ts +60 -0
  5. package/src/change-buffer.ts +77 -11
  6. package/src/channel.ts +202 -20
  7. package/src/client-contract.ts +81 -0
  8. package/src/client-frames.ts +175 -0
  9. package/src/client-heartbeat.ts +77 -0
  10. package/src/client-mutations.ts +114 -0
  11. package/src/client-topics.ts +54 -0
  12. package/src/client.ts +307 -273
  13. package/src/cursor.ts +7 -1
  14. package/src/errors.ts +193 -4
  15. package/src/frame-lanes.ts +58 -0
  16. package/src/hooks.ts +19 -5
  17. package/src/identity-map.ts +141 -0
  18. package/src/index.ts +99 -28
  19. package/src/json.ts +38 -1
  20. package/src/live-contract.ts +67 -0
  21. package/src/live-definition.ts +16 -11
  22. package/src/live-fanout.ts +150 -0
  23. package/src/live-query.ts +215 -268
  24. package/src/live-rows.ts +143 -0
  25. package/src/local-store.ts +86 -43
  26. package/src/nats-client.ts +132 -0
  27. package/src/nats-fake.ts +389 -344
  28. package/src/nats-jetstream.ts +21 -20
  29. package/src/nats-kv.ts +7 -7
  30. package/src/nats-lib-client.ts +210 -0
  31. package/src/nats-transport.ts +109 -138
  32. package/src/offline-queue.ts +146 -30
  33. package/src/pg-entity-row.ts +99 -31
  34. package/src/pg-replication.ts +84 -27
  35. package/src/pg-socket.ts +4 -1
  36. package/src/policy-gate.ts +13 -5
  37. package/src/presence.ts +76 -6
  38. package/src/query-hook.ts +56 -0
  39. package/src/query-window.ts +187 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +225 -34
  43. package/src/subscriber-gate.ts +209 -0
  44. package/src/subscription-book.ts +237 -0
  45. package/src/sync-auth.ts +124 -0
  46. package/src/sync-frames.ts +185 -0
  47. package/src/sync-listen.ts +73 -0
  48. package/src/sync-node.ts +324 -248
  49. package/src/sync-protocol.ts +115 -24
  50. package/src/sync-upgrade.ts +124 -0
  51. package/src/thundering-herd.ts +21 -0
  52. package/src/transport-env.ts +3 -3
  53. package/src/type-pins.ts +72 -0
  54. package/src/window-lock.ts +21 -0
  55. package/src/nats-commands.ts +0 -97
  56. package/src/nats-connection-fixture.ts +0 -105
  57. package/src/nats-connection.ts +0 -464
  58. package/src/nats-protocol.ts +0 -222
  59. package/src/nats-socket.ts +0 -236
  60. package/src/pg-connection-fixture.ts +0 -215
  61. package/src/pg-replication-fixture.ts +0 -261
package/src/sync-node.ts CHANGED
@@ -4,44 +4,55 @@
4
4
  // client may reconnect to any node and resume from its cursor, which is why drain is allowed to
5
5
  // redistribute connections at all.
6
6
 
7
- import {
8
- type Clock,
9
- healthzPayload,
10
- logger,
11
- markListening,
12
- markReady,
13
- onShutdown,
14
- readyzPayload,
15
- systemClock,
16
- uuid,
17
- } from '@ultimat3/core';
7
+ import { type Clock, logger, markReady, reportError, systemClock, uuid } from '@ultimat3/core';
18
8
  import type { ChannelHub, Topic } from './channel';
19
- import { topic as makeTopic } from './channel';
9
+ import { isClientFault } from './errors';
20
10
  import type { Transport, TransportSubscription } from './fanout';
21
- import type { JsonValue, Row } from './json';
22
11
  import type { LiveQueryRegistry } from './live-query';
23
- import { type PresenceRegistry, presenceFrame } from './presence';
24
- import { CHANGE_SUBJECT_PREFIX, parseChange } from './replicator';
25
- import { CLOSE, SocketRegistry, SyncSocket, type WsLike } from './socket';
12
+ import type { PresenceRegistry } from './presence';
13
+ import { CHANGE_SUBJECT_PREFIX, parseEnvelope, SeqGapDetector } from './replicator';
14
+ import {
15
+ CLOSE,
16
+ DEFAULT_MAX_BUFFERED_BYTES,
17
+ idleSweepPeriodMs,
18
+ SocketRegistry,
19
+ SyncSocket,
20
+ type WsLike,
21
+ } from './socket';
22
+ import { GrantBook, type SyncAuthenticator, sweepGrants } from './sync-auth';
23
+ import { ackRefOf, createFrameRouter, type MutationHandler } from './sync-frames';
26
24
  import { decode, type Frame, PROTOCOL_VERSION, toWireError } from './sync-protocol';
25
+ import { handleUpgrade, type UpgradeTarget, type WsData } from './sync-upgrade';
27
26
  import { AcceptBudget, drainPlan, type Rng, reconnectFrame } from './thundering-herd';
28
27
 
29
- export interface WsData {
30
- readonly socketId: string;
31
- readonly clientBuildId: string;
32
- readonly actorId: string | null;
33
- }
28
+ /** Declared with the upgrade that builds it — this file only ever reads one. */
29
+ export type { UpgradeTarget, WsData } from './sync-upgrade';
34
30
 
35
31
  export type SyncWs = WsLike & { readonly data: WsData };
36
32
 
37
- /** Server-authoritative mutation execution. Injected: `sync` never owns business logic. */
38
- export type MutationHandler = (args: {
39
- socket: SyncSocket;
40
- name: string;
41
- key: string;
42
- seq: number;
43
- input: JsonValue;
44
- }) => Promise<{ lsn?: string | null; entity?: string; row?: Row | null }>;
33
+ /**
34
+ * How often an expired grant is re-decided. A third of the shortest TTL worth issuing: a grant is
35
+ * re-checked on the pass after it expires, so the window a revoked actor keeps its socket is this
36
+ * interval and not its token's lifetime.
37
+ */
38
+ export const DEFAULT_REAUTH_INTERVAL_MS = 30_000;
39
+
40
+ /**
41
+ * Concurrent sockets one node will hold. The accept budget bounds the accept RATE and nothing
42
+ * bounded the COUNT: at the 500/s that budget permits, an attacker holding each socket open with
43
+ * one keepalive frame a minute reaches 1.8M sockets an hour, each carrying a `GrantBook` entry.
44
+ *
45
+ * The number clears the 50,000 real clients this repo has measured on one node
46
+ * (`scripts/bench/restart-bench.ts`) with room to spare, because a ceiling that refuses a proven
47
+ * workload is an outage the framework caused.
48
+ */
49
+ export const DEFAULT_MAX_CONNECTIONS = 250_000;
50
+
51
+ /**
52
+ * Inbound bytes one frame may carry. Bun's own default is 16 MiB, which one authenticated socket
53
+ * can push continuously; a `subscribe` frame carrying a full 512-id cursor is under 32 KiB.
54
+ */
55
+ export const DEFAULT_MAX_FRAME_BYTES = 256 * 1024;
45
56
 
46
57
  export interface SyncNodeOptions {
47
58
  readonly hub: ChannelHub;
@@ -51,7 +62,40 @@ export interface SyncNodeOptions {
51
62
  readonly presence?: PresenceRegistry;
52
63
  readonly sockets?: SocketRegistry;
53
64
  readonly accept?: AcceptBudget;
65
+ /** Concurrent sockets this node will hold. The count the accept budget does not bound. */
66
+ readonly maxConnections?: number;
67
+ /** Inbound bytes one frame may carry, handed to whatever server mounts `websocket`. */
68
+ readonly maxFrameBytes?: number;
69
+ /** Sustained inbound frames one socket may have routed per second. */
70
+ readonly maxFramesPerSecond?: number;
71
+ /** Burst allowance on that rate, per socket. */
72
+ readonly frameBurst?: number;
73
+ /**
74
+ * When a socket starts dropping frames, and how many drops close it. On `SyncSocket` too, but
75
+ * this node builds every socket it holds — so unforwarded they were reachable only by abandoning
76
+ * `createSyncNode`, and a dropped channel frame is the one loss nothing replays.
77
+ */
78
+ readonly maxBufferedBytes?: number;
79
+ readonly maxDroppedFrames?: number;
80
+ /**
81
+ * How long a socket may route no frame before this node evicts it. Every ceiling on a socket
82
+ * `sync` builds has to be reachable from here, and this one was not: `SocketRegistry`'s default
83
+ * was only settable by constructing the registry yourself, and nothing swept it either way.
84
+ */
85
+ readonly idleTimeoutMs?: number;
54
86
  readonly onMutate?: MutationHandler;
87
+ /**
88
+ * Who is dialling. Injected for the same reason `onMutate` is: `sync` owns no business logic and
89
+ * imports no authenticator, so an app supplies the one function that turns an upgrade request
90
+ * into an actor — from `@ultimat3/auth` or from anywhere else.
91
+ *
92
+ * **Omitted, every socket on this node is anonymous** and every policy downstream — the topic
93
+ * guard, `authorize`, `visible`, the per-tenant subscription cap — decides against `null`. That
94
+ * is a single-tenant node, and `start()` says so in the log.
95
+ */
96
+ readonly authenticate?: SyncAuthenticator;
97
+ /** How often an expired grant is re-decided. The clock a socket's authority runs on. */
98
+ readonly reauthenticateIntervalMs?: number;
55
99
  readonly clock?: Clock;
56
100
  readonly rng?: Rng;
57
101
  /** WS endpoint. One path, no negotiation — the protocol version lives in the frames. */
@@ -59,21 +103,30 @@ export interface SyncNodeOptions {
59
103
  readonly drainSpreadMs?: number;
60
104
  }
61
105
 
62
- /** Structural view of `Bun.serve`'s server object; keeps this module free of a Bun import. */
63
- export interface UpgradeTarget {
64
- upgrade(request: Request, options: { data: WsData }): boolean;
65
- }
66
-
67
106
  export interface SyncNode {
68
107
  readonly sockets: SocketRegistry;
69
108
  readonly ready: boolean;
70
109
  start(): Promise<void>;
110
+ /**
111
+ * Refuse new connections, keep every one this node holds. The SIGTERM `accept` phase calls it —
112
+ * `/readyz` answers 503 so the load balancer stops routing here, and an upgrade arriving in the
113
+ * meantime is shed with a retry delay instead of landing on a process that is going away. It is
114
+ * NOT `stop()`: a draining node still owes its clients their patches, and `stop()` releases the
115
+ * change subscription that carries them.
116
+ */
117
+ stopAccepting(): void;
71
118
  stop(): Promise<void>;
72
- fetch(request: Request, server: UpgradeTarget): Response | undefined;
119
+ /**
120
+ * Async because `authenticate` is: the credential is decided *before* `server.upgrade`, so a
121
+ * refused one never costs a websocket. Bun's `fetch` may return a promise, and an upgrade that
122
+ * awaits first is still an upgrade.
123
+ */
124
+ fetch(request: Request, server: UpgradeTarget): Promise<Response | undefined>;
73
125
  readonly websocket: {
74
126
  idleTimeout: number;
75
127
  backpressureLimit: number;
76
- publishToSelf: boolean;
128
+ /** Inbound ceiling. Declared here so every host that mounts this handler inherits it. */
129
+ maxPayloadLength: number;
77
130
  sendPings: boolean;
78
131
  open(ws: SyncWs): void;
79
132
  message(ws: SyncWs, message: string | Uint8Array): void;
@@ -85,132 +138,137 @@ export interface SyncNode {
85
138
 
86
139
  export function createSyncNode(options: SyncNodeOptions): SyncNode {
87
140
  const sockets =
88
- options.sockets ?? new SocketRegistry({ ...(options.clock ? { clock: options.clock } : {}) });
141
+ options.sockets ??
142
+ new SocketRegistry({
143
+ ...(options.clock ? { clock: options.clock } : {}),
144
+ ...(options.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: options.idleTimeoutMs }),
145
+ });
89
146
  const clock = options.clock ?? systemClock;
90
147
  const accept = options.accept ?? new AcceptBudget({ perSecond: 500, burst: 2000, clock });
148
+ const maxConnections = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
91
149
  const path = options.path ?? '/_x/sync';
92
150
  const presence = options.presence;
151
+ const grants = new GrantBook();
152
+ const gaps = new SeqGapDetector();
93
153
  let ready = false;
94
154
  let changes: TransportSubscription | null = null;
95
155
  let sweeping: ReturnType<typeof setInterval> | null = null;
156
+ let reauthing: ReturnType<typeof setInterval> | null = null;
157
+ let idling: ReturnType<typeof setInterval> | null = null;
96
158
 
97
159
  /**
98
- * Presence work nobody is waiting on — a leave from a synchronous close, a sweep on a timer.
99
- * It reaches the bus, so it can fail; failing must not take a socket or the process with it,
100
- * and must not be silent either, or "the room still shows someone who left" has nothing to read.
160
+ * Work nobody is waiting on — a presence leave from a synchronous close, a sweep on a timer, a
161
+ * fanout off the change bus. It reaches the bus or a policy, so it can fail; failing must not take
162
+ * a socket or the process with it, and must not be silent either, or "the room still shows someone
163
+ * who left" and "that change reached nobody" have nothing to read. `operation` stays low
164
+ * cardinality so the monitor can group on it; the topic or entity goes in `at`.
101
165
  */
102
- const detach = (work: Promise<unknown>, at: string): void => {
166
+ const detach = (work: Promise<unknown>, operation: string, at?: string): void => {
103
167
  void work.catch((error: unknown) => {
104
- logger.error('presence failed', {
105
- at,
168
+ logger.error(`${operation} failed`, {
169
+ ...(at === undefined ? {} : { at }),
106
170
  error: error instanceof Error ? error.message : String(error),
107
171
  });
172
+ // Nobody is awaiting this, so the log is the only trace it leaves — and a log is not a
173
+ // signal anyone is paged on. The bus is this node's dependency, never the client's.
174
+ reportError(error, { source: 'realtime', scope: { operation } });
108
175
  });
109
176
  };
110
177
 
111
- const routeFrame = async (socket: SyncSocket, frame: Frame): Promise<void> => {
112
- socket.touch();
113
- switch (frame.type) {
114
- case 'hello': {
115
- socket.send({
116
- type: 'hello',
117
- v: PROTOCOL_VERSION,
118
- buildId: options.buildId,
119
- sessionId: socket.id,
120
- actorId: socket.actorId,
121
- resume: [],
122
- });
123
- if (socket.skewed) {
124
- socket.send({ type: 'update-available', v: PROTOCOL_VERSION, buildId: options.buildId });
125
- }
126
- return;
127
- }
128
- case 'subscribe': {
129
- if (frame.target.kind === 'topic') {
130
- const name = makeTopic(...frame.target.topic.split('.'));
131
- if (frame.op === 'drop') {
132
- options.hub.unsubscribe(socket, name);
133
- if (presence) await presence.leave(name, socket.id);
134
- return;
135
- }
136
- await options.hub.subscribe(socket, name);
137
- // Subscribing to a topic IS joining its presence set: presence has no frame of its own,
138
- // so a second round trip saying "and I am here" would be a second way to do one thing,
139
- // and a client that skipped it would be invisible in a room it is receiving from.
140
- // Repeating the frame is therefore also the heartbeat — `join` re-`put`s the member.
141
- if (presence) {
142
- const members = await presence.join(name, { id: socket.id, actorId: socket.actorId });
143
- socket.send(presenceFrame(name, 'sync', members));
144
- }
145
- return;
146
- }
147
- if (frame.op === 'drop') {
148
- options.registry.unsubscribe(frame.sid);
149
- return;
150
- }
151
- const { frame: reply } = await options.registry.subscribe({
152
- socket,
153
- name: frame.target.qid,
154
- input: frame.target.input,
155
- sid: frame.sid,
156
- cursor: frame.target.cursor,
157
- });
158
- socket.send(reply);
159
- return;
160
- }
161
- case 'mutate': {
162
- if (!options.onMutate) {
163
- socket.send({
164
- type: 'ack',
165
- v: PROTOCOL_VERSION,
166
- ref: frame.key,
167
- lsn: null,
168
- error: toWireError({
169
- code: 'X_NOT_IMPLEMENTED',
170
- cause: 'this sync node was started without a mutation handler',
171
- fix: 'pass onMutate to createSyncNode({ onMutate })',
172
- }),
173
- });
174
- return;
175
- }
176
- const result = await options.onMutate({
177
- socket,
178
- name: frame.name,
179
- key: frame.key,
180
- seq: frame.seq,
181
- input: frame.input,
182
- });
183
- socket.send({
184
- type: 'ack',
185
- v: PROTOCOL_VERSION,
186
- ref: frame.key,
187
- lsn: result.lsn ?? null,
188
- error: null,
189
- });
190
- if (result.entity !== undefined) {
191
- socket.send({
192
- type: 'rebase',
193
- v: PROTOCOL_VERSION,
194
- key: frame.key,
195
- entity: result.entity,
196
- strategy: 'server-wins',
197
- row: result.row ?? null,
198
- });
199
- }
200
- return;
201
- }
202
- // Server-authored frames are never received from a client.
203
- case 'snapshot':
204
- case 'patch':
205
- case 'ack':
206
- case 'rebase':
207
- case 'presence':
208
- case 'reconnect':
209
- case 'update-available':
210
- return;
178
+ /**
179
+ * Everything `start()` acquired that is not a socket: the change subscription and the presence
180
+ * sweep. Both `drain()` and `stop()` run it, because a `drain()` is terminal on its own — it
181
+ * closes the hub — and `listenSyncNode` is the only caller that follows one with the other. A
182
+ * node that drained and kept its subscription goes on pulling changes off the bus and sweeping
183
+ * presence for a fleet it has already left, with no socket to deliver either to. Idempotent:
184
+ * running it twice is the normal case.
185
+ */
186
+ const release = (): void => {
187
+ changes?.unsubscribe();
188
+ changes = null;
189
+ if (sweeping !== null) clearInterval(sweeping);
190
+ sweeping = null;
191
+ if (reauthing !== null) clearInterval(reauthing);
192
+ reauthing = null;
193
+ if (idling !== null) clearInterval(idling);
194
+ idling = null;
195
+ gaps.forget();
196
+ };
197
+
198
+ /**
199
+ * Everything one socket held, released once. Bun's `close` callback runs it, and so does a
200
+ * revoked grant — a socket this node closes itself gets no callback in a unit test, and in
201
+ * production the second run is the no-op every step here already is.
202
+ */
203
+ const teardown = (socket: SyncSocket): void => {
204
+ options.registry.unsubscribeSocket(socket.id);
205
+ const topics = [...socket.topics] as Topic[];
206
+ for (const name of topics) options.hub.unsubscribe(socket, name);
207
+ sockets.remove(socket.id);
208
+ grants.delete(socket.id);
209
+ // A closed socket is a leave, said now rather than left to TTL: everyone else would otherwise
210
+ // keep rendering a member who is provably gone for the rest of its window. The write is on the
211
+ // bus and the close callback is synchronous, so it cannot be awaited here.
212
+ if (presence) {
213
+ for (const name of topics) detach(presence.leave(name, socket.id), 'presence.leave', name);
211
214
  }
212
215
  };
213
216
 
217
+ /**
218
+ * The node's one eviction: close, then release everything the socket held. Every path that ends
219
+ * a socket without a `close` callback behind it — the drain, the idle sweep — goes through it,
220
+ * because dropping the socket from the table is three of `teardown`'s five steps and the two it
221
+ * misses are the ones another node can see.
222
+ */
223
+ const evict = (socket: SyncSocket, code: number, reason: string): void => {
224
+ socket.close(code, reason);
225
+ teardown(socket);
226
+ };
227
+
228
+ /**
229
+ * One pass over the grants whose window has closed. This is the half R2 was missing: `reauthorize`
230
+ * and `onActorChange` were both written and neither had a caller, so a socket that was accepted
231
+ * was authorized for as long as it stayed open — and an active client's socket never idles out,
232
+ * because every inbound frame touches it.
233
+ */
234
+ const reauthenticate = async (): Promise<void> => {
235
+ await sweepGrants({
236
+ grants,
237
+ clock,
238
+ onActor: async (socketId, actor) => {
239
+ const socket = sockets.get(socketId);
240
+ if (!socket) return;
241
+ // The hub sets `socket.actor` and drops the topics this actor may no longer read; the
242
+ // registry re-decides every live subscription and desyncs the survivors, so the next
243
+ // delivery re-snapshots them under the new authority rather than the old window.
244
+ await options.hub.onActorChange(socket, actor);
245
+ await options.registry.reauthorize(socket);
246
+ },
247
+ onRevoked: (socketId) => {
248
+ const socket = sockets.get(socketId);
249
+ if (!socket) return;
250
+ teardown(socket);
251
+ socket.close(CLOSE.policy, 'grant expired');
252
+ },
253
+ onRefreshFailed: (socketId, error) => {
254
+ // Not a denial: the grant is kept and retried next pass. Reported because a socket nobody
255
+ // can re-decide is not something to discover from a connection graph.
256
+ reportError(error, {
257
+ source: 'realtime',
258
+ scope: { operation: 'sync.reauthenticate', extra: { socketId } },
259
+ });
260
+ },
261
+ });
262
+ };
263
+
264
+ const routeFrame = createFrameRouter({
265
+ hub: options.hub,
266
+ registry: options.registry,
267
+ buildId: options.buildId,
268
+ presence,
269
+ onMutate: options.onMutate,
270
+ });
271
+
214
272
  return {
215
273
  sockets,
216
274
 
@@ -220,68 +278,121 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
220
278
 
221
279
  async start(): Promise<void> {
222
280
  changes = await options.transport.subscribe(`${CHANGE_SUBJECT_PREFIX}.>`, (payload) => {
223
- const change = parseChange(payload);
224
- if (change) void options.registry.deliver(change);
281
+ const envelope = parseEnvelope(payload);
282
+ if (!envelope) return;
283
+ // Fanout is at-most-once over core NATS, so a reconnect is changes this node never saw.
284
+ // Nothing downstream could notice: no window's lsn moved, so no cursor moved, so nothing
285
+ // ever asked for a re-snapshot. A gap invalidates every window here instead, and the
286
+ // subscribers are re-served on the next change to each query.
287
+ if (gaps.observe(envelope)) {
288
+ const marked = options.registry.invalidate();
289
+ logger.warn('live.change_gap', { entity: envelope.change.entity, desynced: marked });
290
+ }
291
+ // Not awaited: the bus handler must return before the next change, and ordering is the
292
+ // registry's — one serial lane per query id. What this call site owes is the failure. An
293
+ // unhandled rejection here is a fanout that reached nobody, reported as a dead process.
294
+ detach(options.registry.deliver(envelope.change), 'live.deliver', envelope.change.entity);
225
295
  });
226
296
  // One pass per heartbeat window: a member is swept only once it has actually missed its
227
297
  // window, and the interval never holds the process open — shutdown is the drain's job.
228
298
  if (presence) {
229
- sweeping = setInterval(() => detach(presence.sweepAll(), 'sweep'), presence.heartbeatMs);
299
+ sweeping = setInterval(
300
+ () => detach(presence.sweepAll(), 'presence.sweep'),
301
+ presence.heartbeatMs,
302
+ );
230
303
  sweeping.unref();
231
304
  }
305
+ // The half-open connection Bun's own `idleTimeout` renews through its ping/pong: a client
306
+ // whose frame loop is wedged answers pings and keeps its grant, its subscriptions and its
307
+ // topic membership. `sweepIdle` was written for this and never called, so `touch()` and the
308
+ // 120s budget under it decided nothing.
309
+ idling = setInterval(() => {
310
+ for (const socket of sockets.idle()) evict(socket, CLOSE.idle, 'idle timeout');
311
+ }, idleSweepPeriodMs(sockets.idleTimeoutMs));
312
+ idling.unref();
313
+ if (options.authenticate) {
314
+ reauthing = setInterval(
315
+ () => detach(reauthenticate(), 'sync.reauthenticate'),
316
+ options.reauthenticateIntervalMs ?? DEFAULT_REAUTH_INTERVAL_MS,
317
+ );
318
+ reauthing.unref();
319
+ } else {
320
+ // Enforced where it can be: nothing here can invent a credential, so the one honest signal
321
+ // is that every policy on this node is about to be asked about `null`.
322
+ logger.warn('sync node has no authenticator: every socket is anonymous', {
323
+ buildId: options.buildId,
324
+ fix: 'pass authenticate to createSyncNode({ authenticate })',
325
+ });
326
+ }
232
327
  ready = true;
233
328
  markReady();
234
329
  logger.info('sync node ready', { buildId: options.buildId, path });
235
330
  },
236
331
 
332
+ stopAccepting(): void {
333
+ ready = false;
334
+ },
335
+
237
336
  async stop(): Promise<void> {
238
337
  ready = false;
239
- changes?.unsubscribe();
240
- changes = null;
241
- if (sweeping !== null) clearInterval(sweeping);
242
- sweeping = null;
338
+ release();
243
339
  },
244
340
 
245
- fetch(request: Request, server: UpgradeTarget): Response | undefined {
246
- const url = new URL(request.url);
247
- // Health is the process's, readiness is this node's: a draining node stays healthy while it
248
- // hands its sockets to the rest of the fleet.
249
- if (url.pathname === '/healthz') return json(healthzPayload());
250
- if (url.pathname === '/readyz') {
251
- const payload = readyzPayload();
252
- return ready ? json(payload) : json({ status: 503, body: payload.body });
253
- }
254
- if (url.pathname !== path) return new Response('not found', { status: 404 });
255
- if (!ready || !accept.tryAccept()) {
256
- // Load shedding with a delay attached: refusing without one just moves the herd next door.
257
- return new Response('retry', {
258
- status: 503,
259
- headers: { 'retry-after-ms': String(accept.retryAfterMs(options.rng ?? Math.random)) },
260
- });
261
- }
262
- const data: WsData = {
263
- socketId: uuid(),
264
- clientBuildId: url.searchParams.get('build') ?? options.buildId,
265
- actorId: null,
266
- };
267
- return server.upgrade(request, { data })
268
- ? undefined
269
- : new Response('expected websocket', { status: 426 });
341
+ async fetch(request: Request, server: UpgradeTarget): Promise<Response | undefined> {
342
+ return await handleUpgrade(
343
+ {
344
+ path,
345
+ buildId: options.buildId,
346
+ maxConnections,
347
+ accept,
348
+ rng: options.rng ?? Math.random,
349
+ // Read per call, never captured: `ready` and the socket count both move while a request
350
+ // is parked inside `authenticate`, which is the whole reason they are functions.
351
+ ready: () => ready,
352
+ socketCount: () => sockets.count,
353
+ newSocketId: () => uuid(),
354
+ authenticate: options.authenticate,
355
+ onGranted: (socketId, grant) => grants.set(socketId, grant),
356
+ },
357
+ request,
358
+ server,
359
+ );
270
360
  },
271
361
 
272
362
  websocket: {
273
363
  idleTimeout: 120,
274
- backpressureLimit: 1024 * 1024,
275
- publishToSelf: false,
364
+ // The same number `SyncSocket` refuses to add past, never a second spelling of it. Bun's
365
+ // limit set lower and our own check never fires: the runtime drops the frame with nothing
366
+ // marked desynced, which is the silent divergence the mark exists to prevent.
367
+ backpressureLimit: DEFAULT_MAX_BUFFERED_BYTES,
368
+ // No `publishToSelf`: this node never publishes to a native topic. Every channel frame is
369
+ // one filtered `send` per socket through `SocketRegistry.deliver`, which is the only path
370
+ // that can count the frame it dropped — a flag configuring a mechanism nothing uses reads
371
+ // as a live one to the next person who has to decide how delivery works.
372
+ maxPayloadLength: options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES,
276
373
  sendPings: true,
277
374
 
278
375
  open(ws: SyncWs): void {
376
+ // The actor the upgrade resolved, carried into the socket the whole pipeline decides
377
+ // against — the topic guard, `authorize`, `visible`, the per-tenant cap. It was hardcoded
378
+ // `null` here, which made every one of those a decision about nobody.
279
379
  const socket = new SyncSocket({
280
380
  ws,
281
381
  id: ws.data.socketId,
282
382
  clientBuildId: ws.data.clientBuildId,
283
383
  serverBuildId: options.buildId,
384
+ actor: grants.get(ws.data.socketId)?.actor ?? null,
284
385
  clock,
386
+ ...(options.maxFramesPerSecond === undefined
387
+ ? {}
388
+ : { maxFramesPerSecond: options.maxFramesPerSecond }),
389
+ ...(options.frameBurst === undefined ? {} : { frameBurst: options.frameBurst }),
390
+ ...(options.maxBufferedBytes === undefined
391
+ ? {}
392
+ : { maxBufferedBytes: options.maxBufferedBytes }),
393
+ ...(options.maxDroppedFrames === undefined
394
+ ? {}
395
+ : { maxDroppedFrames: options.maxDroppedFrames }),
285
396
  });
286
397
  sockets.add(socket);
287
398
  },
@@ -290,13 +401,26 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
290
401
  const socket = sockets.get(ws.data.socketId);
291
402
  if (!socket) return;
292
403
  void (async () => {
404
+ // Decoded into a binding the failure path can read: an ack has to name the thing that
405
+ // failed — the mutation key the client's queue holds, the sid its subscription holds —
406
+ // and a frame that could not be decoded is the one case where there is nothing to name.
407
+ let frame: Frame | null = null;
293
408
  try {
294
- await routeFrame(socket, decode(message));
409
+ frame = decode(message);
410
+ await routeFrame(socket, frame);
295
411
  } catch (error) {
412
+ // The ack frame tells the client what it did wrong; the monitor only hears about what
413
+ // this node did wrong. Same rule the HTTP pipeline applies at `status >= 500`.
414
+ if (!isClientFault(error)) {
415
+ reportError(error, {
416
+ source: 'realtime',
417
+ scope: { operation: 'sync.frame', extra: { socketId: socket.id } },
418
+ });
419
+ }
296
420
  socket.send({
297
421
  type: 'ack',
298
422
  v: PROTOCOL_VERSION,
299
- ref: ws.data.socketId,
423
+ ref: ackRefOf(frame, ws.data.socketId),
300
424
  lsn: null,
301
425
  error: toWireError(error),
302
426
  });
@@ -306,15 +430,13 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
306
430
 
307
431
  close(ws: SyncWs): void {
308
432
  const socket = sockets.get(ws.data.socketId);
309
- if (!socket) return;
310
- options.registry.unsubscribeSocket(socket.id);
311
- const topics = [...socket.topics] as Topic[];
312
- for (const name of topics) options.hub.unsubscribe(socket, name);
313
- sockets.remove(socket.id);
314
- // A closed socket is a leave, said now rather than left to TTL: everyone else would
315
- // otherwise keep rendering a member who is provably gone for the rest of its window. The
316
- // write is on the bus, and this callback is synchronous, so it cannot be awaited here.
317
- if (presence) for (const name of topics) detach(presence.leave(name, socket.id), name);
433
+ if (!socket) {
434
+ // The socket is already gone, but a grant recorded for an upgrade whose `open` never ran
435
+ // is not and nothing else would ever reach it.
436
+ grants.delete(ws.data.socketId);
437
+ return;
438
+ }
439
+ teardown(socket);
318
440
  },
319
441
  },
320
442
 
@@ -330,71 +452,25 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
330
452
  }
331
453
  const graceMs = drainOptions.graceMs ?? 5_000;
332
454
  if (graceMs > 0) await new Promise((resolve) => setTimeout(resolve, graceMs));
333
- for (const socket of [...sockets.all()]) {
334
- socket.close(CLOSE.goingAway, 'drain');
335
- sockets.remove(socket.id);
336
- }
455
+ // Through `evict`, never `sockets.remove` + `grants.delete`: those are three of `teardown`'s
456
+ // five steps, and the two they skip are the ones the rest of the fleet can see. A drained
457
+ // socket that never left its presence set is a member every other node renders for a full
458
+ // TTL — during a rolling restart, beside the same client's reconnection under a new id —
459
+ // and its live subscriptions stay in the registry, so `entry.subscribers` never empties and
460
+ // the matcher, the shared window and the retained ring are pinned for the process's life.
461
+ for (const socket of [...sockets.all()]) evict(socket, CLOSE.goingAway, 'drain');
462
+ // Released once the sockets are gone rather than at the top: a client is entitled to its
463
+ // patches for the whole grace window, and it is entitled to them *before* the hub the
464
+ // fanout writes through is closed.
465
+ release();
337
466
  await options.hub.close();
338
467
  return plan;
339
468
  },
340
469
  };
341
470
  }
342
471
 
343
- export interface ListenOptions {
344
- readonly port?: number;
345
- }
346
-
347
- export interface SyncListener {
348
- /** The bound websocket origin, e.g. `ws://localhost:3001`. With `port: 0` only the OS knows it. */
349
- readonly url: string;
350
- stop(): void;
351
- }
352
-
353
- /**
354
- * Binds the node to `Bun.serve` and wires SIGTERM to `drain()`. Kept tiny so the node itself stays
355
- * testable without a server.
356
- */
357
- export function listenSyncNode(node: SyncNode, options: ListenOptions = {}): SyncListener {
358
- const server = Bun.serve({
359
- port: options.port ?? 3001,
360
- fetch: node.fetch,
361
- websocket: node.websocket,
362
- });
363
- // Same rule as @ultimat3/http: every socket the framework opens announces itself, so a request
364
- // back to it is recognisably this process calling itself rather than egress.
365
- const stopListening = markListening(server.url.origin);
366
- // Unregistered by `stop()`: a hook left behind after the listener is gone drains a node that is
367
- // already stopped, and the next process-wide shutdown hangs on it.
368
- const unregister = onShutdown('realtime:sync', async () => {
369
- await node.drain();
370
- await node.stop();
371
- server.stop();
372
- stopListening();
373
- });
374
- return {
375
- url: websocketOrigin(server.url),
376
- stop: () => {
377
- unregister();
378
- server.stop();
379
- stopListening();
380
- },
381
- };
382
- }
383
-
384
472
  /**
385
- * The listener reports where it actually landed: a caller asking for `port: 0` cannot guess the
386
- * port, and a guessed URL is a client that connects to someone else. Swapped on the URL's
387
- * protocol, never on the string a hostname is allowed to contain "http".
473
+ * An upgrade refused before a socket exists, rendered as the error contract rather than as a word.
474
+ * There is no frame to carry it — the client never got a connection so the body is the only
475
+ * channel, and `--json` on every error means this one too.
388
476
  */
389
- function websocketOrigin(url: URL): string {
390
- const ws = new URL(url);
391
- ws.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
392
- return ws.origin;
393
- }
394
-
395
- function json(payload: { status: number; body: unknown }): Response {
396
- return new Response(JSON.stringify(payload.body), {
397
- status: payload.status,
398
- headers: { 'content-type': 'application/json' },
399
- });
400
- }