@ultimat3/realtime 23.0.0 → 25.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 (111) hide show
  1. package/CLAUDE.md +118 -126
  2. package/README.md +229 -54
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +62 -0
  5. package/src/boot.ts +25 -15
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +9 -9
  8. package/src/changefeed.ts +81 -19
  9. package/src/channel-authz.ts +48 -17
  10. package/src/channel-describe.ts +1 -1
  11. package/src/channel-latch.ts +52 -0
  12. package/src/channel-logs.ts +36 -15
  13. package/src/channel-presence.ts +3 -3
  14. package/src/channel-records.ts +35 -4
  15. package/src/channel-ref.ts +2 -2
  16. package/src/channel-registry.ts +2 -2
  17. package/src/channel-sids.ts +37 -0
  18. package/src/channel.ts +148 -48
  19. package/src/client-channels.ts +12 -1
  20. package/src/client-contract.ts +4 -1
  21. package/src/client-frames.ts +16 -3
  22. package/src/client-heartbeat.ts +39 -9
  23. package/src/client.ts +30 -2
  24. package/src/cursor.ts +11 -0
  25. package/src/error-titles.ts +91 -0
  26. package/src/errors.ts +113 -105
  27. package/src/fanout.ts +13 -0
  28. package/src/first-paint-hold.ts +49 -0
  29. package/src/idb-types.ts +2 -2
  30. package/src/index.ts +25 -7
  31. package/src/json.ts +16 -3
  32. package/src/live-contract.ts +14 -0
  33. package/src/live-definition.ts +34 -9
  34. package/src/live-deliver.ts +55 -0
  35. package/src/live-fanout.ts +122 -29
  36. package/src/live-floor.ts +35 -0
  37. package/src/live-query-options.ts +44 -0
  38. package/src/live-query.ts +96 -175
  39. package/src/live-reauth.ts +127 -0
  40. package/src/live-replicator.ts +28 -8
  41. package/src/live-resume.ts +44 -8
  42. package/src/live-rows.ts +32 -3
  43. package/src/live-spend.ts +50 -0
  44. package/src/local-store-idb.ts +5 -0
  45. package/src/matcher-bridge.ts +11 -5
  46. package/src/nats-client.ts +37 -6
  47. package/src/nats-lib-client.ts +36 -7
  48. package/src/nats-subscriptions.ts +63 -0
  49. package/src/nats-transport.ts +149 -15
  50. package/src/offline-queue.ts +53 -5
  51. package/src/page-errors.ts +25 -9
  52. package/src/page-outbox.ts +125 -54
  53. package/src/page-runtime-wait.ts +72 -0
  54. package/src/page-runtime.ts +32 -0
  55. package/src/page-socket.ts +7 -16
  56. package/src/page-store.ts +83 -16
  57. package/src/pg-advisory-lock.ts +54 -5
  58. package/src/pg-auth.ts +9 -4
  59. package/src/pg-bytes.ts +4 -1
  60. package/src/pg-connection.ts +126 -22
  61. package/src/pg-entity-row.ts +16 -0
  62. package/src/pg-identity-tables.ts +25 -0
  63. package/src/pg-preflight.ts +50 -4
  64. package/src/pg-replication-row.ts +82 -0
  65. package/src/pg-replication.ts +50 -51
  66. package/src/pg-socket.ts +26 -7
  67. package/src/pg-tls.ts +18 -0
  68. package/src/pg-values.ts +29 -14
  69. package/src/pg-wire.ts +5 -3
  70. package/src/pgoutput.ts +19 -9
  71. package/src/policy-gate.ts +7 -7
  72. package/src/presence.ts +87 -10
  73. package/src/principal-sockets.ts +75 -0
  74. package/src/query-window.ts +17 -5
  75. package/src/record-persister.ts +7 -2
  76. package/src/record-publisher.ts +178 -0
  77. package/src/record-store.ts +25 -4
  78. package/src/record-synced.ts +43 -1
  79. package/src/replication-errors.ts +11 -7
  80. package/src/replicator-envelope.ts +160 -0
  81. package/src/replicator-row.ts +75 -0
  82. package/src/replicator.ts +313 -191
  83. package/src/server.ts +45 -23
  84. package/src/socket-defaults.ts +20 -0
  85. package/src/socket-engine.ts +14 -2
  86. package/src/socket-idle.ts +3 -1
  87. package/src/socket-routes.ts +8 -3
  88. package/src/socket.ts +14 -19
  89. package/src/subscriber-gate.ts +11 -1
  90. package/src/subscription-book.ts +129 -71
  91. package/src/subscription-count.ts +151 -0
  92. package/src/sync-actor-change.ts +56 -0
  93. package/src/sync-auth.ts +47 -2
  94. package/src/sync-bus-handlers.ts +131 -0
  95. package/src/sync-frames.ts +74 -21
  96. package/src/sync-node-bounds.ts +16 -1
  97. package/src/sync-node-contract.ts +36 -4
  98. package/src/sync-node.ts +163 -82
  99. package/src/sync-origin.ts +27 -12
  100. package/src/sync-protocol.ts +86 -16
  101. package/src/sync-upgrade.ts +115 -8
  102. package/src/type-pins.ts +9 -1
  103. package/src/use-channel.ts +19 -14
  104. package/src/use-connection.ts +3 -3
  105. package/src/use-mutation.ts +41 -20
  106. package/src/use-query.ts +11 -11
  107. package/src/use-record.ts +89 -10
  108. package/src/wire-channel.ts +18 -9
  109. package/src/wire-read.ts +2 -2
  110. package/src/idb-fake.ts +0 -133
  111. package/src/policy-fake.ts +0 -14
@@ -1,4 +1,4 @@
1
- // What a `sync` node IS, as types: the options `createSyncNode` takes, the node it returns and the
1
+ // What a `sync` node IS, as types: the options `syncNode` takes, the node it returns and the
2
2
  // socket Bun hands its handlers. Apart from `sync-node.ts` so the lifecycle there stays under the
3
3
  // file ceiling, and so a host can name the shapes without reading the lifecycle.
4
4
 
@@ -33,7 +33,7 @@ export interface SyncNodeOptions {
33
33
  /**
34
34
  * When a socket starts dropping frames, and how many drops close it. On `SyncSocket` too, but
35
35
  * this node builds every socket it holds — so unforwarded they were reachable only by abandoning
36
- * `createSyncNode`, and a dropped channel frame is the one loss nothing replays.
36
+ * `syncNode`, and a dropped channel frame is the one loss nothing replays.
37
37
  */
38
38
  readonly maxBufferedBytes?: number;
39
39
  readonly maxDroppedFrames?: number;
@@ -54,11 +54,35 @@ export interface SyncNodeOptions {
54
54
  */
55
55
  readonly authenticate?: SyncAuthenticator;
56
56
  /**
57
- * Exact origins a page may open a socket from, besides this node's own host name — the page's
58
- * origin when it is served on another host (`SYNC_URL` on a separate domain). Anything else is
57
+ * Exact origins a page may open a socket from — the app's public origin (`APP_URL`). Declared,
58
+ * they are the WHOLE list; with none, the node admits the origin it was reached on (its `Host`
59
+ * and port, and that host's https spelling when reached over plain http). Anything else is
59
60
  * refused `X_SOCKET_ORIGIN_REFUSED` before `authenticate` runs.
60
61
  */
61
62
  readonly allowedOrigins?: readonly string[];
63
+ /**
64
+ * Also admit the origin the node was reached on BESIDE a declared list. For a loopback dev
65
+ * server reached through a forwarded host; never a container, where `Host` is the caller's.
66
+ */
67
+ readonly admitReachedOrigin?: boolean;
68
+ /**
69
+ * The caller's address from the upgrade request — the boot injects `@ultimat3/http`'s
70
+ * `clientAddress()` under the deployment's `TRUSTED_PROXY_HOPS`. Absent, the socket's own
71
+ * address. It keys an anonymous subscriber's live rate limit (`UpgradeDeps.clientAddressOf`).
72
+ */
73
+ readonly clientAddressOf?: (request: Request, socketAddress: string | null) => string | null;
74
+ /**
75
+ * Peers this node's own `/healthz` and `/readyz` tell the whole report to: address classes
76
+ * (`loopback`, `private`, …) or exact IP literals — the app's `http.healthDetailPeers`. Defaults
77
+ * to core's `DEFAULT_HEALTH_DETAIL_PEERS` (the box itself); anyone else, and any request that
78
+ * says it was forwarded, gets `{ state, ready, role }`.
79
+ */
80
+ readonly healthDetailPeers?: readonly string[];
81
+ /**
82
+ * How long one grant's `refresh()` may hold the re-auth pass before it is reported and skipped
83
+ * (`DEFAULT_GRANT_REFRESH_DEADLINE_MS`). The grant stays expired and is asked again next pass.
84
+ */
85
+ readonly grantRefreshDeadlineMs?: number;
62
86
  /** How often an expired grant is re-decided. The clock a socket's authority runs on. */
63
87
  readonly reauthenticateIntervalMs?: number;
64
88
  readonly clock?: Clock;
@@ -66,6 +90,12 @@ export interface SyncNodeOptions {
66
90
  /** WS endpoint. One path, no negotiation — the protocol version lives in the frames. */
67
91
  readonly path?: string;
68
92
  readonly drainSpreadMs?: number;
93
+ /**
94
+ * Sockets one principal — an actor, or an anonymous caller's network (IPv4, IPv6 /64) — may hold
95
+ * on this node. Default `DEFAULT_MAX_SOCKETS_PER_ACTOR` (16); the boot passes the app's
96
+ * `realtime.maxSocketsPerActor`. The next upgrade is 429 `X_SOCKET_LIMIT`.
97
+ */
98
+ readonly maxSocketsPerActor?: number;
69
99
  }
70
100
 
71
101
  export interface SyncNode {
@@ -73,6 +103,8 @@ export interface SyncNode {
73
103
  readonly ready: boolean;
74
104
  /** The one path it answers an upgrade on: a HOST has to route it, and must not restate it. */
75
105
  readonly path: string;
106
+ /** Sockets one principal (`actor:<id>` / `address:<network>`) holds here — the cap's count. */
107
+ principalSockets(principal: string): number;
76
108
  start(): Promise<void>;
77
109
  /**
78
110
  * Refuse new connections, keep every one this node holds. The SIGTERM `accept` phase calls it —
package/src/sync-node.ts CHANGED
@@ -4,18 +4,36 @@
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 { logger, markReady, reportError, systemClock, uuid } from '@ultimat3/core';
7
+ import {
8
+ DEFAULT_HEALTH_DETAIL_PEERS,
9
+ logger,
10
+ markReady,
11
+ reportError,
12
+ systemClock,
13
+ uuidV7,
14
+ } from '@ultimat3/core';
8
15
  import type { Topic } from './channel';
16
+ import { ChannelSids } from './channel-sids';
9
17
  import { detach } from './detach';
10
18
  import { evictInChunks } from './drain-evictions';
11
19
  import { isClientFault } from './errors';
12
20
  import type { TransportSubscription } from './fanout';
13
- import { CHANGE_SUBJECT_ALL, parseEnvelope, SeqGapDetector } from './replicator';
14
- import { CLOSE, DEFAULT_MAX_BUFFERED_BYTES, SocketRegistry, SyncSocket } from './socket';
21
+ import { PrincipalSockets } from './principal-sockets';
22
+ import { CHANGE_SUBJECT_ALL } from './replicator';
23
+ import { SeqGapDetector } from './replicator-envelope';
24
+ import { CLOSE, SocketRegistry, SyncSocket } from './socket';
25
+ import { DEFAULT_MAX_BUFFERED_BYTES } from './socket-defaults';
15
26
  import { idleSweepPeriodMs } from './socket-idle';
27
+ import { actorChangeHandler } from './sync-actor-change';
16
28
  import { GrantBook, sweepGrants } from './sync-auth';
17
- import { ackRefOf, createFrameRouter } from './sync-frames';
18
- import { drainGraceMs, socketCeilings, syncNodeBounds } from './sync-node-bounds';
29
+ import { changeHandler, ProducerKinds, reconnectHandler } from './sync-bus-handlers';
30
+ import { ackRefOf, frameRouter } from './sync-frames';
31
+ import {
32
+ clientHeartbeatMs,
33
+ drainGraceMs,
34
+ socketCeilings,
35
+ syncNodeBounds,
36
+ } from './sync-node-bounds';
19
37
  import type { SyncNode, SyncNodeOptions, SyncWs } from './sync-node-contract';
20
38
  import { decode, type Frame, PROTOCOL_VERSION, toWireError } from './sync-protocol';
21
39
  import { handleUpgrade, type UpgradeTarget } from './sync-upgrade';
@@ -34,7 +52,7 @@ export type { SyncNode, SyncNodeOptions, SyncWs } from './sync-node-contract';
34
52
  /** Declared with the upgrade that builds it — this file only ever reads one. */
35
53
  export type { UpgradeTarget, WsData } from './sync-upgrade';
36
54
 
37
- export function createSyncNode(options: SyncNodeOptions): SyncNode {
55
+ export function syncNode(options: SyncNodeOptions): SyncNode {
38
56
  const sockets =
39
57
  options.sockets ??
40
58
  new SocketRegistry({
@@ -51,8 +69,22 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
51
69
  const path = options.path ?? '/_x/sync';
52
70
  const presence = options.presence;
53
71
  const grants = new GrantBook();
72
+ const principals = new PrincipalSockets(options.maxSocketsPerActor);
54
73
  const gaps = new SeqGapDetector();
74
+ // Kept across restarts of the bus subscription: a table's producer kind is a fact of the fleet.
75
+ const producers = new ProducerKinds();
76
+ const channelSids = new ChannelSids();
77
+ let reconnects: (() => void) | null = null;
78
+ /** The re-auth pass in flight, shared by every tick that lands while it runs. */
79
+ let reauthPass: Promise<void> | null = null;
55
80
  let ready = false;
81
+ /**
82
+ * The start in flight or done, and whether a stop/drain has run since. `start()` awaits the bus,
83
+ * and a stop inside that await found nothing to release: the subscription landed after it and
84
+ * the node came up `ready` and delivering after it was stopped. The `replicator.ts` pattern.
85
+ */
86
+ let started: Promise<void> | null = null;
87
+ let generation = 0;
56
88
  /** Resolved by `teardown` when the last socket leaves, for a drain that is waiting its grace. */
57
89
  let lastSocketLeft: (() => void) | null = null;
58
90
  let changes: TransportSubscription | null = null;
@@ -71,6 +103,8 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
71
103
  const release = (): void => {
72
104
  changes?.unsubscribe();
73
105
  changes = null;
106
+ reconnects?.();
107
+ reconnects = null;
74
108
  if (sweeping !== null) clearInterval(sweeping);
75
109
  sweeping = null;
76
110
  if (reauthing !== null) clearInterval(reauthing);
@@ -91,17 +125,23 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
91
125
  */
92
126
  const teardown = (socket: SyncSocket): readonly Promise<unknown>[] => {
93
127
  options.registry.unsubscribeSocket(socket.id);
94
- const topics = [...socket.topics] as Topic[];
128
+ // Suspended seats included, and the presence rooms read BEFORE the seats are given back: the
129
+ // hub forgets a topic's declaration with its last member.
130
+ const topics = options.hub.topicsOf(socket);
131
+ const rooms = topics.filter(hasRoster);
95
132
  for (const name of topics) options.hub.unsubscribe(socket, name);
96
133
  sockets.remove(socket.id);
97
134
  grants.delete(socket.id);
135
+ principals.release(socket.id);
98
136
  if (sockets.count === 0) lastSocketLeft?.();
99
137
  // A closed socket is a leave, said now rather than left to TTL: everyone else would otherwise
100
138
  // keep rendering a member who is provably gone for the rest of its window. The write is on the
101
139
  // bus and the close callback is synchronous, so it cannot be awaited here.
102
140
  const leaves: Promise<unknown>[] = [];
103
141
  if (presence) {
104
- for (const name of topics) {
142
+ // Only a channel declared `events: true` has a roster: a leave on any other was a KV read,
143
+ // a KV write and a presence event on a channel that never carried one.
144
+ for (const name of rooms) {
105
145
  const leave = presence.leave(name, socket.id);
106
146
  // Detached as well as returned: `detach` attaches the reporting catch, so a caller that
107
147
  // awaits this later is awaiting a promise whose rejection is already handled.
@@ -130,6 +170,11 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
130
170
  lastSocketLeft = done;
131
171
  });
132
172
 
173
+ /** Whether this topic's channel carries presence — asked while the hub still knows the topic. */
174
+ function hasRoster(name: Topic): boolean {
175
+ return options.hub.channelOf(name)?.channel.events === true;
176
+ }
177
+
133
178
  const evict = (socket: SyncSocket, code: number, reason: string): readonly Promise<unknown>[] => {
134
179
  socket.close(code, reason);
135
180
  return teardown(socket);
@@ -141,19 +186,30 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
141
186
  * was authorized for as long as it stayed open — and an active client's socket never idles out,
142
187
  * because every inbound frame touches it.
143
188
  */
144
- const reauthenticate = async (): Promise<void> => {
189
+ const reauthenticate = (): Promise<void> => {
190
+ // One pass at a time. The interval does not wait for the pass it started: after a deploy every
191
+ // grant expires in one window, a pass over them can outlast the interval, and each overlapping
192
+ // tick then refreshed the same grants again and re-snapshotted the same subscriptions.
193
+ reauthPass ??= reauthPassOnce().finally(() => {
194
+ reauthPass = null;
195
+ });
196
+ return reauthPass;
197
+ };
198
+
199
+ const onActor = actorChangeHandler({
200
+ sockets,
201
+ hub: options.hub,
202
+ registry: options.registry,
203
+ channelSids,
204
+ presence,
205
+ });
206
+
207
+ const reauthPassOnce = async (): Promise<void> => {
145
208
  await sweepGrants({
146
209
  grants,
147
210
  clock,
148
- onActor: async (socketId, actor) => {
149
- const socket = sockets.get(socketId);
150
- if (!socket) return;
151
- // The hub sets `socket.actor` and drops the topics this actor may no longer read; the
152
- // registry re-decides every live subscription and desyncs the survivors, so the next
153
- // delivery re-snapshots them under the new authority rather than the old window.
154
- await options.hub.onActorChange(socket, actor);
155
- await options.registry.reauthorize(socket);
156
- },
211
+ refreshDeadlineMs: options.grantRefreshDeadlineMs,
212
+ onActor,
157
213
  onRevoked: (socketId) => {
158
214
  const socket = sockets.get(socketId);
159
215
  if (!socket) return;
@@ -175,75 +231,92 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
175
231
  });
176
232
  };
177
233
 
178
- const routeFrame = createFrameRouter({
234
+ const routeFrame = frameRouter({
179
235
  hub: options.hub,
180
236
  registry: options.registry,
181
237
  buildId: options.buildId,
182
238
  presence,
239
+ channelSids,
240
+ heartbeatMs: clientHeartbeatMs(presence?.heartbeatMs, sockets.idleTimeoutMs),
183
241
  });
184
242
 
243
+ /** Everything `start()` does, fenced on the generation it began under — see `started`. */
244
+ const begin = async (): Promise<void> => {
245
+ const run = generation;
246
+ const bus = { registry: options.registry, hub: options.hub, gaps, producers };
247
+ const subscription = await options.transport.subscribe(CHANGE_SUBJECT_ALL, changeHandler(bus));
248
+ // A stop or drain ran inside that await: what it released did not include this yet.
249
+ if (run !== generation) {
250
+ subscription.unsubscribe();
251
+ return;
252
+ }
253
+ changes = subscription;
254
+ reconnects = options.transport.onReconnect(reconnectHandler(bus));
255
+ // One pass per heartbeat window: a member is swept only once it has actually missed its
256
+ // window, and the interval never holds the process open — shutdown is the drain's job.
257
+ if (presence) {
258
+ sweeping = setInterval(
259
+ () => detach(presence.sweepAll(), 'presence.sweep'),
260
+ presence.heartbeatMs,
261
+ );
262
+ sweeping.unref();
263
+ }
264
+ // The half-open connection Bun's own `idleTimeout` renews through its ping/pong: a client
265
+ // whose frame loop is wedged answers pings and keeps its grant, its subscriptions and its
266
+ // topic membership. `sweepIdle` was written for this and never called, so `touch()` and the
267
+ // 120s budget under it decided nothing.
268
+ idling = setInterval(() => {
269
+ for (const socket of sockets.idle()) evict(socket, CLOSE.idle, 'idle timeout');
270
+ }, idleSweepPeriodMs(sockets.idleTimeoutMs));
271
+ idling.unref();
272
+ if (options.authenticate) {
273
+ reauthing = setInterval(
274
+ () => detach(reauthenticate(), 'sync.reauthenticate'),
275
+ bounds.reauthenticateIntervalMs,
276
+ );
277
+ reauthing.unref();
278
+ } else {
279
+ // Enforced where it can be: nothing here can invent a credential, so the one honest signal
280
+ // is that every policy on this node is about to be asked about `null`.
281
+ logger.warn('sync node has no authenticator: every socket is anonymous', {
282
+ buildId: options.buildId,
283
+ fix: 'pass authenticate to syncNode({ authenticate })',
284
+ });
285
+ }
286
+ ready = true;
287
+ markReady();
288
+ logger.info('sync node ready', { buildId: options.buildId, path });
289
+ };
290
+
291
+ /** Ends the current generation and waits out a start in flight, so nothing it took outlives us. */
292
+ const halt = async (): Promise<void> => {
293
+ generation += 1;
294
+ ready = false;
295
+ const inFlight = started;
296
+ started = null;
297
+ if (inFlight !== null) await inFlight.catch(() => undefined);
298
+ };
299
+
185
300
  return {
186
301
  sockets,
187
302
  path,
303
+ principalSockets: (principal) => principals.count(principal),
188
304
 
189
305
  get ready(): boolean {
190
306
  return ready;
191
307
  },
192
308
 
193
- async start(): Promise<void> {
194
- changes = await options.transport.subscribe(CHANGE_SUBJECT_ALL, (payload) => {
195
- const envelope = parseEnvelope(payload);
196
- if (!envelope) return;
197
- // Fanout is at-most-once over core NATS, so a reconnect is changes this node never saw.
198
- // Nothing downstream could notice: no window's lsn moved, so no cursor moved, so nothing
199
- // ever asked for a re-snapshot. A gap invalidates every window here instead, and the
200
- // subscribers are re-served on the next change to each query.
201
- if (gaps.observe(envelope)) {
202
- const marked = options.registry.invalidate();
203
- logger.warn('live.change_gap', { entity: envelope.change.entity, desynced: marked });
204
- }
205
- // Not awaited: the bus handler must return before the next change, and ordering is the
206
- // registry's — one serial lane per query id. What this call site owes is the failure. An
207
- // unhandled rejection here is a fanout that reached nobody, reported as a dead process.
208
- detach(options.registry.deliver(envelope.change), 'live.deliver', envelope.change.entity);
209
- // The same stream feeds the declared channels: a write names no channel (axiom 2), and the
210
- // hub turns this change into `records` frames on every channel it touches.
211
- options.hub.deliverChange(envelope.change);
309
+ start(): Promise<void> {
310
+ // Memoised: two overlapping starts were two subscriptions, every change fanned out twice.
311
+ if (started !== null) return started;
312
+ // Cleared only if it is still THIS attempt's memo: an older start failing after a stop and
313
+ // a newer start must not wipe the newer one, or a third start subscribes a second time.
314
+ const attempt: Promise<void> = begin().catch((error: unknown) => {
315
+ if (started === attempt) started = null;
316
+ throw error;
212
317
  });
213
- // One pass per heartbeat window: a member is swept only once it has actually missed its
214
- // window, and the interval never holds the process open — shutdown is the drain's job.
215
- if (presence) {
216
- sweeping = setInterval(
217
- () => detach(presence.sweepAll(), 'presence.sweep'),
218
- presence.heartbeatMs,
219
- );
220
- sweeping.unref();
221
- }
222
- // The half-open connection Bun's own `idleTimeout` renews through its ping/pong: a client
223
- // whose frame loop is wedged answers pings and keeps its grant, its subscriptions and its
224
- // topic membership. `sweepIdle` was written for this and never called, so `touch()` and the
225
- // 120s budget under it decided nothing.
226
- idling = setInterval(() => {
227
- for (const socket of sockets.idle()) evict(socket, CLOSE.idle, 'idle timeout');
228
- }, idleSweepPeriodMs(sockets.idleTimeoutMs));
229
- idling.unref();
230
- if (options.authenticate) {
231
- reauthing = setInterval(
232
- () => detach(reauthenticate(), 'sync.reauthenticate'),
233
- bounds.reauthenticateIntervalMs,
234
- );
235
- reauthing.unref();
236
- } else {
237
- // Enforced where it can be: nothing here can invent a credential, so the one honest signal
238
- // is that every policy on this node is about to be asked about `null`.
239
- logger.warn('sync node has no authenticator: every socket is anonymous', {
240
- buildId: options.buildId,
241
- fix: 'pass authenticate to createSyncNode({ authenticate })',
242
- });
243
- }
244
- ready = true;
245
- markReady();
246
- logger.info('sync node ready', { buildId: options.buildId, path });
318
+ started = attempt;
319
+ return attempt;
247
320
  },
248
321
 
249
322
  stopAccepting(): void {
@@ -251,8 +324,13 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
251
324
  },
252
325
 
253
326
  async stop(): Promise<void> {
254
- ready = false;
327
+ // `halt()` ends the generation synchronously, before its first await; what the current
328
+ // start installed is released right then. An older start still in flight is fenced by its
329
+ // generation and drops what it subscribes late itself, and a start() issued after this line
330
+ // owns a fresh bus that the wait below must not touch.
331
+ const waiting = halt();
255
332
  release();
333
+ await waiting;
256
334
  },
257
335
 
258
336
  async fetch(request: Request, server: UpgradeTarget): Promise<Response | undefined> {
@@ -267,13 +345,20 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
267
345
  // is parked inside `authenticate`, which is the whole reason they are functions.
268
346
  ready: () => ready,
269
347
  socketCount: () => sockets.count,
270
- newSocketId: () => uuid(),
348
+ newSocketId: () => uuidV7(),
271
349
  authenticate: options.authenticate,
272
350
  allowedOrigins: options.allowedOrigins,
351
+ admitReachedOrigin: options.admitReachedOrigin,
352
+ clientAddressOf: options.clientAddressOf,
353
+ healthDetailPeers: options.healthDetailPeers ?? DEFAULT_HEALTH_DETAIL_PEERS,
273
354
  onGranted: (socketId, grant) => grants.set(socketId, grant),
274
355
  // The other half of recording the grant before the upgrade: an upgrade that never took
275
356
  // gets no `close` callback, so this is the only thing that can free its entry.
276
- onUngranted: (socketId) => grants.delete(socketId),
357
+ onUngranted: (socketId) => {
358
+ grants.delete(socketId);
359
+ principals.release(socketId);
360
+ },
361
+ principals,
277
362
  },
278
363
  request,
279
364
  server,
@@ -303,6 +388,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
303
388
  clientBuildId: ws.data.clientBuildId,
304
389
  serverBuildId: options.buildId,
305
390
  actor: grants.get(ws.data.socketId)?.actor ?? null,
391
+ clientAddress: ws.data.clientAddress ?? null,
306
392
  clock,
307
393
  ...ceilings,
308
394
  });
@@ -353,6 +439,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
353
439
  // The socket is already gone, but a grant recorded for an upgrade whose `open` never ran
354
440
  // is not — and nothing else would ever reach it.
355
441
  grants.delete(ws.data.socketId);
442
+ principals.release(ws.data.socketId);
356
443
  return;
357
444
  }
358
445
  teardown(socket);
@@ -360,7 +447,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
360
447
  },
361
448
 
362
449
  async drain(drainOptions = {}): Promise<readonly DrainedSocket[]> {
363
- ready = false;
450
+ await halt();
364
451
  const ids = [...sockets.all()].map((socket) => socket.id);
365
452
  const spread = drainPlan(ids, {
366
453
  spreadMs: bounds.drainSpreadMs,
@@ -403,9 +490,3 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
403
490
  },
404
491
  };
405
492
  }
406
-
407
- /**
408
- * An upgrade refused before a socket exists, rendered as the error contract rather than as a word.
409
- * There is no frame to carry it — the client never got a connection — so the body is the only
410
- * channel, and `--json` on every error means this one too.
411
- */
@@ -1,7 +1,7 @@
1
1
  // Single responsibility: whether a websocket upgrade may carry this app's ambient credential. No
2
- // CORS applies to a websocket and the session cookie rides it, so without this a page on a sibling
3
- // host (same-site, so `SameSite=Lax` does not stop it) opened a socket as its visitor. The rule is
4
- // core's `proveSameOrigin`, the one `@ultimat3/http`'s CSRF check asks too.
2
+ // CORS applies to a websocket and the session cookie rides it, so without this a page that is not
3
+ // this app opened a socket as its visitor. The rule is core's `proveSameOrigin`, the one
4
+ // `@ultimat3/http`'s CSRF check asks too.
5
5
 
6
6
  import { type OriginVerdict, proveSameOrigin } from '@ultimat3/core';
7
7
 
@@ -9,25 +9,40 @@ import { type OriginVerdict, proveSameOrigin } from '@ultimat3/core';
9
9
  * Admitted, in order:
10
10
  * - **no `Origin`** — RFC 6455 has a browser send one on every handshake, so a dial without it is
11
11
  * not a browser and no visitor's cookie can be riding it;
12
- * - **the node's own host name**, whatever the port or scheme — cookies are not port-isolated, so
13
- * the page on `:3000` dialling the Compose rung's `:3001` is the same credential boundary, and a
14
- * TLS-terminating ingress means the node cannot know the page's scheme;
15
- * - otherwise core's rule, with `allowedOrigins` as the exact list (`APP_URL`'s origin, from the
16
- * CLI, when the page is served on another host than the node).
12
+ * - **`allowedOrigins`, exactly, when any is declared** (`APP_URL`'s origin, from the CLI). A
13
+ * declaration IS the app's origin, so it is the whole list: the origin the node sees is derived
14
+ * from the `Host` header, which names whatever host the request was aimed at — a sibling
15
+ * subdomain pointed at this node is "its own origin" by that reading, and a `Domain=`-wide
16
+ * session cookie rides its socket;
17
+ * - **the node's own ORIGIN, only when nothing is declared** — host and port exactly as the
18
+ * request reached it (`x dev`, a combined-role container). A host name alone is not this app:
19
+ * cookies are not isolated by port. `admitReached` keeps it beside a declared list (`x dev`).
17
20
  */
18
21
  export function upgradeOrigin(
19
22
  request: Request,
20
23
  url: URL,
21
24
  allowedOrigins: readonly string[],
25
+ admitReached = false,
22
26
  ): OriginVerdict {
23
27
  const origin = request.headers.get('origin');
24
28
  if (origin === null) return { ok: true };
25
- if (URL.parse(origin)?.hostname === url.hostname) return { ok: true };
29
+ // Normalised once (`https://x/` and `https://x` are one origin), then matched as whole strings.
30
+ const admitted = allowedOrigins.map((allowed) => URL.parse(allowed)?.origin);
26
31
  return proveSameOrigin({
27
- selfOrigins: allowedOrigins,
32
+ selfOrigins: admitted.length > 0 && !admitReached ? [] : selfOrigins(url),
28
33
  origin,
29
34
  secFetchSite: request.headers.get('sec-fetch-site'),
30
- listed: () => false,
31
- listName: 'APP_URL or createSyncNode({ allowedOrigins })',
35
+ listed: (asked) => admitted.includes(asked),
36
+ listName: 'APP_URL or syncNode({ allowedOrigins })',
32
37
  });
33
38
  }
39
+
40
+ /**
41
+ * The origins an UNDECLARED node is reached on. TLS usually ends at a proxy, so a node reached over
42
+ * plain http cannot know its pages' scheme: the https spelling of the same host and port is this
43
+ * app too. That also admits the plain-http page, which is why a deployment behind TLS declares
44
+ * `APP_URL` — the chart sets it from the ingress host.
45
+ */
46
+ function selfOrigins(url: URL): readonly string[] {
47
+ return url.protocol === 'http:' ? [url.origin, `https://${url.host}`] : [url.origin];
48
+ }