@ultimat3/realtime 24.0.0 → 25.1.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 +61 -63
- package/README.md +131 -46
- package/package.json +9 -6
- package/src/advisory-lock.ts +10 -5
- package/src/boot.ts +14 -9
- package/src/changefeed-env.ts +8 -8
- package/src/changefeed.ts +42 -18
- package/src/channel-authz.ts +7 -7
- package/src/channel-describe.ts +1 -1
- package/src/channel-logs.ts +16 -25
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +3 -3
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel.ts +8 -7
- package/src/client-channels.ts +2 -0
- package/src/client-frames.ts +9 -2
- package/src/client.ts +29 -2
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +47 -90
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +24 -6
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +12 -2
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +81 -24
- package/src/live-query-options.ts +6 -0
- package/src/live-query.ts +66 -144
- 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 +26 -4
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +127 -15
- package/src/offline-queue.ts +5 -0
- package/src/page-errors.ts +8 -9
- package/src/page-outbox.ts +5 -5
- 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 +9 -4
- package/src/pg-connection.ts +7 -1
- package/src/pg-replication.ts +6 -6
- package/src/pg-socket.ts +20 -3
- package/src/pg-values.ts +29 -14
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +2 -2
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +1 -1
- 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 +3 -3
- package/src/replicator-envelope.ts +61 -11
- package/src/replicator-row.ts +1 -1
- package/src/replicator.ts +12 -23
- package/src/server.ts +36 -18
- package/src/socket-defaults.ts +20 -0
- package/src/socket.ts +14 -19
- 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-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +1 -1
- package/src/sync-node-bounds.ts +1 -1
- package/src/sync-node-contract.ts +16 -2
- package/src/sync-node.ts +115 -109
- package/src/sync-origin.ts +1 -1
- package/src/sync-protocol.ts +73 -13
- package/src/sync-upgrade.ts +39 -0
- 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 +9 -16
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +5 -5
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// One cap that spans sockets: live subscriptions counted per KEY (a tenant, an actor), held plus
|
|
2
|
+
// reserved, with the key each socket was counted under remembered. Split from
|
|
3
|
+
// `subscription-book.ts` so the tenant cap and the actor cap are one mechanism, not two copies.
|
|
4
|
+
|
|
5
|
+
import { SubscriptionLimitError } from './errors';
|
|
6
|
+
import type { SyncSocket } from './socket';
|
|
7
|
+
|
|
8
|
+
export interface CappedCountInit {
|
|
9
|
+
/** Which key a socket's subscriptions count against, or `null` for none (uncapped here). */
|
|
10
|
+
readonly principalOf: (socket: SyncSocket) => string | null;
|
|
11
|
+
/** `undefined` counts without refusing — the tenant cap's opt-out. Screened by the caller. */
|
|
12
|
+
readonly max: number | undefined;
|
|
13
|
+
readonly scope: 'tenant' | 'actor';
|
|
14
|
+
readonly knob: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every question answered from a `Map`, never a scan: the cap is asked on every subscribe frame,
|
|
19
|
+
* and walking the node's subscriptions for it was one socket consuming the node.
|
|
20
|
+
*/
|
|
21
|
+
export class CappedCount {
|
|
22
|
+
readonly #init: CappedCountInit;
|
|
23
|
+
readonly #held = new Map<string, number>();
|
|
24
|
+
readonly #claimed = new Map<string, number>();
|
|
25
|
+
/**
|
|
26
|
+
* The key each socket's subscriptions were counted under. Remembered rather than re-derived,
|
|
27
|
+
* because `socket.actor` is replaced by a re-auth: deriving it again at `delete` time would
|
|
28
|
+
* decrement a key that was never incremented and leave the old one counting forever.
|
|
29
|
+
*/
|
|
30
|
+
readonly #principalOfSocket = new Map<string, string>();
|
|
31
|
+
|
|
32
|
+
constructor(init: CappedCountInit) {
|
|
33
|
+
this.#init = init;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
count(key: string): number {
|
|
37
|
+
return this.#held.get(key) ?? 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The key a socket counts under: the remembered one, or what its actor says now. */
|
|
41
|
+
principalFor(socket: SyncSocket): string | null {
|
|
42
|
+
return this.#principalOfSocket.get(socket.id) ?? this.#init.principalOf(socket);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Claims count: a subscribe past this check has already committed the node to its work. */
|
|
46
|
+
assert(socket: SyncSocket): void {
|
|
47
|
+
const max = this.#init.max;
|
|
48
|
+
const key = this.principalFor(socket);
|
|
49
|
+
if (max === undefined || key === null) return;
|
|
50
|
+
if (this.count(key) + (this.#claimed.get(key) ?? 0) >= max) {
|
|
51
|
+
throw new SubscriptionLimitError({
|
|
52
|
+
scope: this.#init.scope,
|
|
53
|
+
id: key,
|
|
54
|
+
limit: max,
|
|
55
|
+
knob: this.#init.knob,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A reservation, given back to the key that took it even if the socket re-keys meanwhile. */
|
|
61
|
+
claim(socket: SyncSocket): { readonly principal: string | null; readonly release: () => void } {
|
|
62
|
+
const key = this.principalFor(socket);
|
|
63
|
+
if (key === null) return { principal: key, release: () => undefined };
|
|
64
|
+
bump(this.#claimed, key, 1);
|
|
65
|
+
return { principal: key, release: () => bump(this.#claimed, key, -1) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A subscribe reserved under `claimedPrincipal` is attaching, and its socket now counts under another
|
|
70
|
+
* key (a re-auth landed while it was in flight): it joins the new key only if that key has room —
|
|
71
|
+
* the check the new key never got at reserve time.
|
|
72
|
+
*/
|
|
73
|
+
assertIfMoved(socket: SyncSocket, claimedPrincipal: string | null): void {
|
|
74
|
+
if (this.principalFor(socket) !== claimedPrincipal) this.assert(socket);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* How many of a socket's `held` subscriptions the key it is about to move to cannot take — `0`
|
|
79
|
+
* when it is not moving or there is room. A re-auth refuses that many rather than carrying a key
|
|
80
|
+
* past its cap: moving 128 onto a principal at 1,000 was 1,128, and filling an address then
|
|
81
|
+
* signing in was a way to repeat it.
|
|
82
|
+
*/
|
|
83
|
+
overflowOnRekey(socket: SyncSocket, held: number): number {
|
|
84
|
+
const max = this.#init.max;
|
|
85
|
+
const after = this.#init.principalOf(socket);
|
|
86
|
+
if (max === undefined || after === null || held === 0) return 0;
|
|
87
|
+
if (after === (this.#principalOfSocket.get(socket.id) ?? null)) return 0;
|
|
88
|
+
const room = max - this.count(after) - (this.#claimed.get(after) ?? 0);
|
|
89
|
+
return Math.max(0, held - Math.max(0, room));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The refusal this cap answers for the key `socket` would count under. */
|
|
93
|
+
refusalFor(socket: SyncSocket): SubscriptionLimitError {
|
|
94
|
+
return new SubscriptionLimitError({
|
|
95
|
+
scope: this.#init.scope,
|
|
96
|
+
id: this.#init.principalOf(socket) ?? 'unknown',
|
|
97
|
+
limit: this.#init.max ?? 0,
|
|
98
|
+
knob: this.#init.knob,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The socket closed: nothing it remembers may outlive it. */
|
|
103
|
+
forget(socketId: string): void {
|
|
104
|
+
this.#principalOfSocket.delete(socketId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Sockets this cap still remembers a key for — bounded by sockets that hold a subscription. */
|
|
108
|
+
get remembered(): number {
|
|
109
|
+
return this.#principalOfSocket.size;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
added(socket: SyncSocket): void {
|
|
113
|
+
const key = this.principalFor(socket);
|
|
114
|
+
if (key === null) return;
|
|
115
|
+
this.#principalOfSocket.set(socket.id, key);
|
|
116
|
+
bump(this.#held, key, 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `emptied`: the socket now holds nothing, so its remembered key goes too. */
|
|
120
|
+
deleted(socketId: string, emptied: boolean): void {
|
|
121
|
+
const key = this.#principalOfSocket.get(socketId);
|
|
122
|
+
if (key === undefined) return;
|
|
123
|
+
bump(this.#held, key, -1);
|
|
124
|
+
if (emptied) this.#principalOfSocket.delete(socketId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A re-auth moved the socket to another key: its `held` subscriptions move with it. */
|
|
128
|
+
reassign(socket: SyncSocket, held: number): void {
|
|
129
|
+
// Holding nothing, there is nothing to move and nothing to remember: `keyFor` falls back to
|
|
130
|
+
// the actor's key. Stored anyway, a re-auth on an idle socket was an entry nothing deleted.
|
|
131
|
+
if (held === 0) {
|
|
132
|
+
this.#principalOfSocket.delete(socket.id);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const before = this.#principalOfSocket.get(socket.id) ?? null;
|
|
136
|
+
const after = this.#init.principalOf(socket);
|
|
137
|
+
if (before === after) return;
|
|
138
|
+
if (before !== null) bump(this.#held, before, -held);
|
|
139
|
+
if (after === null) this.#principalOfSocket.delete(socket.id);
|
|
140
|
+
else {
|
|
141
|
+
this.#principalOfSocket.set(socket.id, after);
|
|
142
|
+
if (held > 0) bump(this.#held, after, held);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function bump(map: Map<string, number>, key: string, by: number): void {
|
|
148
|
+
const next = (map.get(key) ?? 0) + by;
|
|
149
|
+
if (next > 0) map.set(key, next);
|
|
150
|
+
else map.delete(key);
|
|
151
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// What a socket's re-decided grant does to it: the hub re-asks every channel seat under the new
|
|
2
|
+
// actor (each one it drops is refused under its sid, and left in presence), and the registry
|
|
3
|
+
// re-decides every live subscription. Split from `sync-node.ts` at its size ceiling.
|
|
4
|
+
|
|
5
|
+
import type { Actor } from '@ultimat3/core';
|
|
6
|
+
import type { ChannelHub, Topic } from './channel';
|
|
7
|
+
import type { ChannelSids } from './channel-sids';
|
|
8
|
+
import { detach } from './detach';
|
|
9
|
+
import { TopicForbiddenError } from './errors';
|
|
10
|
+
import type { LiveQueryRegistry } from './live-query';
|
|
11
|
+
import { refuseSubscription } from './live-refusal';
|
|
12
|
+
import type { PresenceRegistry } from './presence';
|
|
13
|
+
import type { SocketRegistry } from './socket';
|
|
14
|
+
|
|
15
|
+
export interface ActorChangeDeps {
|
|
16
|
+
readonly sockets: SocketRegistry;
|
|
17
|
+
readonly hub: ChannelHub;
|
|
18
|
+
readonly registry: LiveQueryRegistry;
|
|
19
|
+
readonly channelSids: ChannelSids;
|
|
20
|
+
readonly presence: PresenceRegistry | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The `onActor` a grant sweep calls once per socket whose grant was refreshed. */
|
|
24
|
+
export function actorChangeHandler(
|
|
25
|
+
deps: ActorChangeDeps,
|
|
26
|
+
): (socketId: string, actor: Actor) => Promise<void> {
|
|
27
|
+
const hasRoster = (name: Topic): boolean => deps.hub.channelOf(name)?.channel.events === true;
|
|
28
|
+
return async (socketId, actor) => {
|
|
29
|
+
const socket = deps.sockets.get(socketId);
|
|
30
|
+
if (!socket) return;
|
|
31
|
+
const rooms = new Set(deps.hub.topicsOf(socket).filter(hasRoster));
|
|
32
|
+
// The hub sets `socket.actor` and drops the topics this actor may no longer read; the
|
|
33
|
+
// registry re-decides every live subscription (refusing each one it drops under its sid)
|
|
34
|
+
// and desyncs the survivors, so the next delivery re-snapshots them under the new
|
|
35
|
+
// authority rather than the old window.
|
|
36
|
+
for (const name of await deps.hub.onActorChange(socket, actor)) {
|
|
37
|
+
// A dropped seat is SAID: unsaid, the client kept rendering the channel as live.
|
|
38
|
+
const sid = deps.channelSids.sidOf(socket, name);
|
|
39
|
+
deps.channelSids.delete(socket, name);
|
|
40
|
+
if (sid !== undefined) {
|
|
41
|
+
refuseSubscription(
|
|
42
|
+
socket,
|
|
43
|
+
sid,
|
|
44
|
+
new TopicForbiddenError({
|
|
45
|
+
topic: name,
|
|
46
|
+
actorId: socket.actorId,
|
|
47
|
+
reason: 'the session changed and the channel policy no longer admits it',
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (deps.presence && rooms.has(name))
|
|
52
|
+
detach(deps.presence.leave(name, socket.id), 'presence.leave', name);
|
|
53
|
+
}
|
|
54
|
+
await deps.registry.reauthorize(socket);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// What a sync node does with the change bus: one change in, fanned to the live queries and the
|
|
2
|
+
// declared channels; a gap or a reconnect, answered by invalidating both; and one producer KIND
|
|
3
|
+
// per table, the other's envelopes dropped. Split out of `sync-node.ts` so the node's lifecycle
|
|
4
|
+
// (start, stop, drain) stays readable in one file.
|
|
5
|
+
|
|
6
|
+
import { logger } from '@ultimat3/core';
|
|
7
|
+
import type { ChannelHub } from './channel';
|
|
8
|
+
import { detach } from './detach';
|
|
9
|
+
import type { LiveQueryRegistry } from './live-query';
|
|
10
|
+
import { RealtimeError } from './realtime-error';
|
|
11
|
+
import {
|
|
12
|
+
type ChangeEnvelope,
|
|
13
|
+
type EnvelopeSource,
|
|
14
|
+
parseEnvelope,
|
|
15
|
+
type SeqGapDetector,
|
|
16
|
+
} from './replicator-envelope';
|
|
17
|
+
|
|
18
|
+
export interface SyncBus {
|
|
19
|
+
readonly registry: LiveQueryRegistry;
|
|
20
|
+
readonly hub: ChannelHub;
|
|
21
|
+
readonly gaps: SeqGapDetector;
|
|
22
|
+
readonly producers: ProducerKinds;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The WAL replicator and a `recordPublisher` both carrying one table is every committed row twice
|
|
27
|
+
* on every channel. Logged, never thrown: the bus handler must keep serving every other table.
|
|
28
|
+
*/
|
|
29
|
+
export class ProducerConflictError extends RealtimeError {
|
|
30
|
+
constructor(args: { table: string; kept: EnvelopeSource; dropped: EnvelopeSource }) {
|
|
31
|
+
super({
|
|
32
|
+
code: 'X_REALTIME_PRODUCER_CONFLICT',
|
|
33
|
+
cause:
|
|
34
|
+
`table "${args.table}" is carried by the ${args.kept} and also by a ${args.dropped}, so ` +
|
|
35
|
+
`every committed row would reach its channels twice; this node keeps the ${args.kept} ` +
|
|
36
|
+
`(seen first) and drops the ${args.dropped}'s changes`,
|
|
37
|
+
// One fix for both orders: the replicator carries every entity table it can see, so the
|
|
38
|
+
// publisher is the half that can let go of one.
|
|
39
|
+
fix: `recordPublisher({ entities: [/* every entity but table ${args.table}'s */] }) # where the replicator runs: it already carries every committed row; then restart the sync node`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Which producer kind carries each table, decided by the first envelope this node reads for it and
|
|
46
|
+
* kept for the node's life. The other kind's envelopes for that table are dropped and the conflict
|
|
47
|
+
* said once per table (`X_REALTIME_PRODUCER_CONFLICT`). The `x dev` bridge is no producer here: it
|
|
48
|
+
* delivers in-process and stands in for the replicator, skipping a claimed table itself.
|
|
49
|
+
*/
|
|
50
|
+
export class ProducerKinds {
|
|
51
|
+
readonly #kinds = new Map<string, EnvelopeSource>();
|
|
52
|
+
readonly #said = new Set<string>();
|
|
53
|
+
|
|
54
|
+
/** `true` when this envelope's producer kind is the one its table is carried by. */
|
|
55
|
+
admit(envelope: ChangeEnvelope): boolean {
|
|
56
|
+
const table = envelope.change.table;
|
|
57
|
+
const kind = envelope.source ?? 'replicator';
|
|
58
|
+
const kept = this.#kinds.get(table);
|
|
59
|
+
if (kept === undefined) {
|
|
60
|
+
this.#kinds.set(table, kind);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if (kept === kind) return true;
|
|
64
|
+
if (!this.#said.has(table)) {
|
|
65
|
+
this.#said.add(table);
|
|
66
|
+
const conflict = new ProducerConflictError({ table, kept, dropped: kind });
|
|
67
|
+
// FIELDS, never interpolation, and the message is the CODE alone (`pg-preflight.ts`).
|
|
68
|
+
logger.warn(conflict.code, {
|
|
69
|
+
cause: conflict.cause,
|
|
70
|
+
fix: conflict.fix,
|
|
71
|
+
table,
|
|
72
|
+
kept,
|
|
73
|
+
dropped: kind,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The handler a node subscribes to `CHANGE_SUBJECT_ALL` with. */
|
|
81
|
+
export function changeHandler(bus: SyncBus): (payload: string) => void {
|
|
82
|
+
return (payload) => {
|
|
83
|
+
const envelope = parseEnvelope(payload);
|
|
84
|
+
if (!envelope) return;
|
|
85
|
+
// Before the gap detector: a dropped producer's sequence is no stream this node follows.
|
|
86
|
+
if (!bus.producers.admit(envelope)) return;
|
|
87
|
+
// Fanout is at-most-once over core NATS, so a reconnect is changes this node never saw.
|
|
88
|
+
// Nothing downstream could notice: no window's lsn moved, so no cursor moved, so nothing
|
|
89
|
+
// ever asked for a re-snapshot. A gap invalidates every window here instead, and the
|
|
90
|
+
// subscribers are re-served on the next change to each query.
|
|
91
|
+
const replicated = envelope.source !== 'publisher';
|
|
92
|
+
if (bus.gaps.observe(envelope)) {
|
|
93
|
+
// A publisher's lost change was never a window's (below), so only the channels re-read.
|
|
94
|
+
const marked = replicated ? bus.registry.invalidate() : 0;
|
|
95
|
+
// Channels too: their seq is minted from what this node sees, so the hole is invisible
|
|
96
|
+
// to a ring that would otherwise replay across it as complete history.
|
|
97
|
+
const channelGaps = bus.hub.invalidate();
|
|
98
|
+
logger.warn('live.change_gap', {
|
|
99
|
+
table: envelope.change.table,
|
|
100
|
+
desynced: marked,
|
|
101
|
+
channelGaps,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// Not awaited: the bus handler must return before the next change, and ordering is the
|
|
105
|
+
// registry's — one serial lane per query id. What this call site owes is the failure. An
|
|
106
|
+
// unhandled rejection here is a fanout that reached nobody, reported as a dead process.
|
|
107
|
+
// A publisher's change has no commit position (its lsn orders nothing across processes), and
|
|
108
|
+
// a window refuses a change below its own lsn as stale: fed one, a live query would drop rows
|
|
109
|
+
// silently. Channels mint their own seq, so they take it (`record-publisher.ts`).
|
|
110
|
+
if (replicated) {
|
|
111
|
+
detach(bus.registry.deliver(envelope.change), 'live.deliver', envelope.change.table);
|
|
112
|
+
}
|
|
113
|
+
// The same stream feeds the declared channels: a write names no channel (axiom 2), and the
|
|
114
|
+
// hub turns this change into `records` frames on every channel it touches.
|
|
115
|
+
bus.hub.deliverChange(envelope.change);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A bus that reconnected is changes this node never saw, and the gap detector only notices on the
|
|
121
|
+
* NEXT message: with no later write, every window and cursor here stayed behind and every new
|
|
122
|
+
* subscriber joined the stale window. Said by the transport, repaired now.
|
|
123
|
+
*/
|
|
124
|
+
export function reconnectHandler(bus: SyncBus): () => void {
|
|
125
|
+
return () => {
|
|
126
|
+
bus.gaps.forget();
|
|
127
|
+
const marked = bus.registry.invalidate();
|
|
128
|
+
const channelGaps = bus.hub.invalidate();
|
|
129
|
+
logger.warn('live.bus_reconnected', { desynced: marked, channelGaps });
|
|
130
|
+
};
|
|
131
|
+
}
|
package/src/sync-frames.ts
CHANGED
|
@@ -47,7 +47,7 @@ export function ackRefOf(frame: Frame | null, socketId: string): string {
|
|
|
47
47
|
return socketId;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export function
|
|
50
|
+
export function frameRouter(options: FrameRouterOptions): FrameRouter {
|
|
51
51
|
const presence = options.presence;
|
|
52
52
|
const channelSids = options.channelSids ?? new ChannelSids();
|
|
53
53
|
|
package/src/sync-node-bounds.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function syncNodeBounds(options: SyncNodeNumericOptions): SyncNodeBounds
|
|
|
82
82
|
* that `SyncSocket` — not this module — owns the default of.
|
|
83
83
|
*
|
|
84
84
|
* They were screened only in `SyncSocket`'s constructor, which Bun runs inside `websocket.open`,
|
|
85
|
-
* which Bun runs SYNCHRONOUSLY inside `server.upgrade`. Measured: `
|
|
85
|
+
* which Bun runs SYNCHRONOUSLY inside `server.upgrade`. Measured: `syncNode` did not throw,
|
|
86
86
|
* `/healthz` and `/readyz` both answered, `ready` was true, and every upgrade threw `X_INVARIANT`
|
|
87
87
|
* with the node holding zero sockets — a misconfiguration that fails per connection instead of at
|
|
88
88
|
* boot, and each of those throws leaked the grant `handleUpgrade` records before the upgrade.
|
|
@@ -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;
|
|
@@ -65,6 +65,12 @@ export interface SyncNodeOptions {
|
|
|
65
65
|
* server reached through a forwarded host; never a container, where `Host` is the caller's.
|
|
66
66
|
*/
|
|
67
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;
|
|
68
74
|
/**
|
|
69
75
|
* Peers this node's own `/healthz` and `/readyz` tell the whole report to: address classes
|
|
70
76
|
* (`loopback`, `private`, …) or exact IP literals — the app's `http.healthDetailPeers`. Defaults
|
|
@@ -84,6 +90,12 @@ export interface SyncNodeOptions {
|
|
|
84
90
|
/** WS endpoint. One path, no negotiation — the protocol version lives in the frames. */
|
|
85
91
|
readonly path?: string;
|
|
86
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;
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
export interface SyncNode {
|
|
@@ -91,6 +103,8 @@ export interface SyncNode {
|
|
|
91
103
|
readonly ready: boolean;
|
|
92
104
|
/** The one path it answers an upgrade on: a HOST has to route it, and must not restate it. */
|
|
93
105
|
readonly path: string;
|
|
106
|
+
/** Sockets one principal (`actor:<id>` / `address:<network>`) holds here — the cap's count. */
|
|
107
|
+
principalSockets(principal: string): number;
|
|
94
108
|
start(): Promise<void>;
|
|
95
109
|
/**
|
|
96
110
|
* Refuse new connections, keep every one this node holds. The SIGTERM `accept` phase calls it —
|