@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.
- package/CLAUDE.md +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- package/src/pg-entity-row.ts +16 -0
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// What a `sync` node IS, as types: the options `
|
|
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
|
-
* `
|
|
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
|
|
58
|
-
*
|
|
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 {
|
|
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 {
|
|
14
|
-
import {
|
|
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 {
|
|
18
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
149
|
-
|
|
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 =
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
*/
|
package/src/sync-origin.ts
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
* -
|
|
13
|
-
* the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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:
|
|
32
|
+
selfOrigins: admitted.length > 0 && !admitReached ? [] : selfOrigins(url),
|
|
28
33
|
origin,
|
|
29
34
|
secFetchSite: request.headers.get('sec-fetch-site'),
|
|
30
|
-
listed: () =>
|
|
31
|
-
listName: 'APP_URL or
|
|
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
|
+
}
|