@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.
- package/CLAUDE.md +641 -0
- package/README.md +336 -19
- package/package.json +6 -3
- package/src/apply-patches.ts +60 -0
- package/src/change-buffer.ts +77 -11
- package/src/channel.ts +202 -20
- package/src/client-contract.ts +81 -0
- package/src/client-frames.ts +175 -0
- package/src/client-heartbeat.ts +77 -0
- package/src/client-mutations.ts +114 -0
- package/src/client-topics.ts +54 -0
- package/src/client.ts +307 -273
- package/src/cursor.ts +7 -1
- package/src/errors.ts +193 -4
- package/src/frame-lanes.ts +58 -0
- package/src/hooks.ts +19 -5
- package/src/identity-map.ts +141 -0
- package/src/index.ts +99 -28
- package/src/json.ts +38 -1
- package/src/live-contract.ts +67 -0
- package/src/live-definition.ts +16 -11
- package/src/live-fanout.ts +150 -0
- package/src/live-query.ts +215 -268
- package/src/live-rows.ts +143 -0
- package/src/local-store.ts +86 -43
- package/src/nats-client.ts +132 -0
- package/src/nats-fake.ts +389 -344
- package/src/nats-jetstream.ts +21 -20
- package/src/nats-kv.ts +7 -7
- package/src/nats-lib-client.ts +210 -0
- package/src/nats-transport.ts +109 -138
- package/src/offline-queue.ts +146 -30
- package/src/pg-entity-row.ts +99 -31
- package/src/pg-replication.ts +84 -27
- package/src/pg-socket.ts +4 -1
- package/src/policy-gate.ts +13 -5
- package/src/presence.ts +76 -6
- package/src/query-hook.ts +56 -0
- package/src/query-window.ts +187 -0
- package/src/rebase.ts +68 -8
- package/src/replicator.ts +84 -11
- package/src/socket.ts +225 -34
- package/src/subscriber-gate.ts +209 -0
- package/src/subscription-book.ts +237 -0
- package/src/sync-auth.ts +124 -0
- package/src/sync-frames.ts +185 -0
- package/src/sync-listen.ts +73 -0
- package/src/sync-node.ts +324 -248
- package/src/sync-protocol.ts +115 -24
- package/src/sync-upgrade.ts +124 -0
- package/src/thundering-herd.ts +21 -0
- package/src/transport-env.ts +3 -3
- package/src/type-pins.ts +72 -0
- package/src/window-lock.ts +21 -0
- package/src/nats-commands.ts +0 -97
- package/src/nats-connection-fixture.ts +0 -105
- package/src/nats-connection.ts +0 -464
- package/src/nats-protocol.ts +0 -222
- package/src/nats-socket.ts +0 -236
- package/src/pg-connection-fixture.ts +0 -215
- package/src/pg-replication-fixture.ts +0 -261
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// What a RECEIVED frame does to client state — the mirror of `sync-node.ts`'s inbound handler,
|
|
2
|
+
// and the only inbound surface `client.ts` exposes. `ClientFrameTarget` is the point: it names
|
|
3
|
+
// every piece of the client a frame may touch, so the blast radius of a new frame kind is a
|
|
4
|
+
// reviewable list rather than "whatever the router could reach through `this`".
|
|
5
|
+
|
|
6
|
+
import { advance } from './cursor';
|
|
7
|
+
import type { JsonObject, JsonValue } from './json';
|
|
8
|
+
import type { Registration, RowWindows } from './live-rows';
|
|
9
|
+
import type { LocalStore, TableMap } from './local-store';
|
|
10
|
+
import type { OfflineQueue } from './offline-queue';
|
|
11
|
+
import { type RebaseLog, reconcile, rollbackMutation } from './rebase';
|
|
12
|
+
import type { Frame, PresenceMember } from './sync-protocol';
|
|
13
|
+
|
|
14
|
+
/** Declared with the window it projects; re-exported here because the router is what writes it. */
|
|
15
|
+
export type { LiveState, Registration } from './live-rows';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Everything an inbound frame is allowed to reach. Narrow on purpose — a router that took the
|
|
19
|
+
* client itself could touch the reconnect timer, the socket and the outbound path, none of which
|
|
20
|
+
* a received frame has any business writing.
|
|
21
|
+
*/
|
|
22
|
+
export interface ClientFrameTarget<T extends TableMap = TableMap> {
|
|
23
|
+
registration(sid: string): Registration | undefined;
|
|
24
|
+
/** The projection every live window renders through. Rows live in its map, never on a frame. */
|
|
25
|
+
readonly windows: RowWindows;
|
|
26
|
+
topicHandlers(topic: string): ReadonlySet<(message: JsonObject) => void> | undefined;
|
|
27
|
+
readonly queue: OfflineQueue | undefined;
|
|
28
|
+
readonly store: LocalStore<T> | undefined;
|
|
29
|
+
readonly log: RebaseLog<T> | undefined;
|
|
30
|
+
/** The client's clock. A cursor carries `at`, and nothing here may read `Date.now()`. */
|
|
31
|
+
now(): number;
|
|
32
|
+
/** A newer build is live; the app decides when to reload. */
|
|
33
|
+
setUpdate(buildId: string | null): void;
|
|
34
|
+
/** The node assigned this socket its own delay before closing it. */
|
|
35
|
+
scheduleReconnect(afterMs: number | null): void;
|
|
36
|
+
closeSocket(code: number, reason: string): void;
|
|
37
|
+
notifyQueueChange(): void;
|
|
38
|
+
/** Where a promise nobody awaits reports its failure. The client's `onError`, never a swallow. */
|
|
39
|
+
detach(work: Promise<unknown>): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The server refused a mutation: undo its optimistic half. Tier 2 has neither a store nor a log,
|
|
44
|
+
* so there is nothing optimistic to undo and the queue entry is the whole record.
|
|
45
|
+
*/
|
|
46
|
+
function rollbackFailed<T extends TableMap>(key: string, target: ClientFrameTarget<T>): void {
|
|
47
|
+
const store = target.store;
|
|
48
|
+
const log = target.log;
|
|
49
|
+
if (!store || !log) return;
|
|
50
|
+
// One batch for the whole undo: the rollback and every mutator replayed behind it are one
|
|
51
|
+
// frame's worth of change, so a live window holding those rows renders once.
|
|
52
|
+
store.identity.batch(() => {
|
|
53
|
+
rollbackMutation({ store, log, key });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The server took it, so the write is no longer optimistic: the journal goes (there is nothing to
|
|
59
|
+
* roll back TO any more — this write is what the server has) and the rebase entry goes with it, or
|
|
60
|
+
* every later reconcile replays a mutation the server already applied, over rows that have moved
|
|
61
|
+
* on. The row itself stays exactly as the twin left it — an accepted write does not flicker.
|
|
62
|
+
*
|
|
63
|
+
* Both calls are no-ops for a key nothing holds, which is what makes this safe as the tail of the
|
|
64
|
+
* `rebase` + `ack` pair: the rebase in front of it has already reconciled and dropped the same key.
|
|
65
|
+
*/
|
|
66
|
+
function commitAccepted<T extends TableMap>(key: string, target: ClientFrameTarget<T>): void {
|
|
67
|
+
target.store?.commit(key);
|
|
68
|
+
target.log?.drop(key);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Presence members cross the topic channel as plain JSON, like every other channel message. */
|
|
72
|
+
function memberJson(member: PresenceMember): JsonValue {
|
|
73
|
+
return { id: member.id, actorId: member.actorId, meta: member.meta, updatedAt: member.updatedAt };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function applyFrame<T extends TableMap>(frame: Frame, target: ClientFrameTarget<T>): void {
|
|
77
|
+
switch (frame.type) {
|
|
78
|
+
case 'snapshot': {
|
|
79
|
+
const registration = target.registration(frame.sid);
|
|
80
|
+
if (!registration) return;
|
|
81
|
+
// The entity is the server's, and it is what upgrades this window from its own private scope
|
|
82
|
+
// to the one every other query over the same entity shares.
|
|
83
|
+
target.windows.snapshot(registration, frame.entity ?? null, frame.rows);
|
|
84
|
+
registration.cursor = frame.cursor;
|
|
85
|
+
registration.setCursor(frame.cursor);
|
|
86
|
+
registration.setState('live');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
case 'patch': {
|
|
90
|
+
const registration = target.registration(frame.sid);
|
|
91
|
+
if (registration) {
|
|
92
|
+
target.windows.patch(registration, frame.patches);
|
|
93
|
+
// The cursor moves with the patches, not only with a snapshot. Left behind, `cursor.at`
|
|
94
|
+
// froze at the last snapshot and `shouldResnapshot`'s lag check answered "re-snapshot" for
|
|
95
|
+
// every client connected longer than `maxLagMs` — the delta resume the retained change
|
|
96
|
+
// window exists for, dead exactly during the deploy storm it was built for. An empty lsn
|
|
97
|
+
// is a tier-1 channel frame's, so it never rewinds one.
|
|
98
|
+
if (registration.cursor && frame.lsn !== '') {
|
|
99
|
+
const next = advance(registration.cursor, frame.patches, frame.lsn, target.now());
|
|
100
|
+
registration.cursor = next;
|
|
101
|
+
registration.setCursor(next);
|
|
102
|
+
}
|
|
103
|
+
registration.setState('live');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// No registration: it is a tier-1 channel message on `sid = topic`.
|
|
107
|
+
const handlers = target.topicHandlers(frame.sid);
|
|
108
|
+
if (!handlers) return;
|
|
109
|
+
for (const patch of frame.patches) {
|
|
110
|
+
if (patch.row === null) continue;
|
|
111
|
+
for (const handler of handlers) handler(patch.row);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
case 'ack': {
|
|
116
|
+
const queue = target.queue;
|
|
117
|
+
// A refused mutation is not a mutation: its optimistic twin has to come off the screen, and
|
|
118
|
+
// its rebase entry has to leave the log, or a denied write stays rendered forever and every
|
|
119
|
+
// later reconcile replays it. `ref` is the mutation key — the same key the `mutate` frame
|
|
120
|
+
// carried — which is what makes both halves reachable from one frame.
|
|
121
|
+
if (frame.error) rollbackFailed(frame.ref, target);
|
|
122
|
+
else commitAccepted(frame.ref, target);
|
|
123
|
+
// `ack`/`fail` mutate the queue synchronously and persist asynchronously; chaining rather
|
|
124
|
+
// than notifying right after the call keeps this correct even if that ordering ever
|
|
125
|
+
// changes, and it still fires exactly once the persisted write actually lands.
|
|
126
|
+
const settled = frame.error ? queue?.fail(frame.ref, frame.error) : queue?.ack(frame.ref);
|
|
127
|
+
if (settled) target.detach(settled.then(() => target.notifyQueueChange()));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
case 'rebase': {
|
|
131
|
+
const store = target.store;
|
|
132
|
+
const log = target.log;
|
|
133
|
+
if (!store || !log) return;
|
|
134
|
+
// One batch for the whole reconcile — a rollback, server truth and every replayed mutator
|
|
135
|
+
// are one frame's worth of change, so a live window holding those rows renders once.
|
|
136
|
+
store.identity.batch(() => {
|
|
137
|
+
reconcile({
|
|
138
|
+
store,
|
|
139
|
+
log,
|
|
140
|
+
ack: {
|
|
141
|
+
key: frame.key,
|
|
142
|
+
entity: frame.entity,
|
|
143
|
+
id: frame.row?.id ?? frame.key,
|
|
144
|
+
row: frame.row,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
case 'reconnect': {
|
|
151
|
+
// Order is load-bearing: arming first is what makes the close this triggers keep the delay
|
|
152
|
+
// the node assigned to *this* socket instead of falling back to a local backoff.
|
|
153
|
+
target.scheduleReconnect(frame.afterMs);
|
|
154
|
+
target.closeSocket(1001, frame.reason);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
case 'update-available': {
|
|
158
|
+
target.setUpdate(frame.buildId);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
case 'presence': {
|
|
162
|
+
const handlers = target.topicHandlers(frame.topic);
|
|
163
|
+
if (!handlers) return;
|
|
164
|
+
const message: JsonObject = { op: frame.op, members: frame.members.map(memberJson) };
|
|
165
|
+
for (const handler of handlers) handler(message);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
case 'hello':
|
|
169
|
+
case 'subscribe':
|
|
170
|
+
case 'mutate':
|
|
171
|
+
// Client-authored frames: never received. Ignored rather than thrown, so a future
|
|
172
|
+
// bidirectional use of the same kind cannot break an old client.
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The client's liveness pass: re-announce this socket before the node forgets it, and notice a
|
|
2
|
+
// socket that has stopped answering. A policy (when to beat, when to give up), not a wire detail —
|
|
3
|
+
// which is why it is here and not inside `client.ts`'s connection lifecycle.
|
|
4
|
+
|
|
5
|
+
import type { Scheduler } from './thundering-herd';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The same 15s as `realtime.heartbeatMs` in `@ultimat3/core`'s config, restated rather than read:
|
|
9
|
+
* that value is server configuration and this is browser code, so the client cannot reach it. The
|
|
10
|
+
* two are kept equal on purpose — a node's presence TTL is sized against this interval.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_HEARTBEAT_MS = 15_000;
|
|
13
|
+
|
|
14
|
+
export interface HeartbeatOptions {
|
|
15
|
+
/** `0` (or less) disables the pass entirely — the shape a test that owns the clock wants. */
|
|
16
|
+
readonly intervalMs: number;
|
|
17
|
+
readonly schedule: Scheduler;
|
|
18
|
+
readonly now: () => number;
|
|
19
|
+
/** Re-announces this socket. Called only while the socket is still answering. */
|
|
20
|
+
readonly beat: () => void;
|
|
21
|
+
/** Two windows of silence: the socket is half-open and only this client can end it. */
|
|
22
|
+
readonly onSilence: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One armed tick at a time, re-armed by itself. It is deliberately NOT an interval: the reconnect
|
|
27
|
+
* timer is the same injected `Scheduler` seam, and a client is either beating on a live socket or
|
|
28
|
+
* backing off towards a new one — never both, so one armed timer is the whole mechanism.
|
|
29
|
+
*/
|
|
30
|
+
export class Heartbeat {
|
|
31
|
+
readonly #options: HeartbeatOptions;
|
|
32
|
+
#cancel: (() => void) | null = null;
|
|
33
|
+
#lastSeen = 0;
|
|
34
|
+
|
|
35
|
+
constructor(options: HeartbeatOptions) {
|
|
36
|
+
this.#options = options;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The socket is up. `now` seeds the silence window, so the first tick judges this connection. */
|
|
40
|
+
start(now: number): void {
|
|
41
|
+
this.stop();
|
|
42
|
+
if (this.#options.intervalMs <= 0) return;
|
|
43
|
+
this.#lastSeen = now;
|
|
44
|
+
this.#arm();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A frame arrived. Anything counts: the point is that bytes still cross in this direction. */
|
|
48
|
+
saw(now: number): void {
|
|
49
|
+
this.#lastSeen = now;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
stop(): void {
|
|
53
|
+
const cancel = this.#cancel;
|
|
54
|
+
this.#cancel = null;
|
|
55
|
+
cancel?.();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#arm(): void {
|
|
59
|
+
this.#cancel = this.#options.schedule(() => {
|
|
60
|
+
this.#cancel = null;
|
|
61
|
+
this.#tick();
|
|
62
|
+
}, this.#options.intervalMs);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#tick(): void {
|
|
66
|
+
const now = this.#options.now();
|
|
67
|
+
// Two windows, not one: a beat and the answer to it share the window they were sent in, so a
|
|
68
|
+
// single quiet interval is a slow round trip and not a dead socket. Nothing is re-armed after
|
|
69
|
+
// a silence — `onSilence` drops the socket, and the next `start()` is the next connection's.
|
|
70
|
+
if (now - this.#lastSeen > this.#options.intervalMs * 2) {
|
|
71
|
+
this.#options.onSilence();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.#options.beat();
|
|
75
|
+
this.#arm();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The outbound mutation path: the optimistic twin, the durable queue entry, and the sender the
|
|
2
|
+
// drain hands each frame to. One file because those are the three places a single intent is
|
|
3
|
+
// recorded, and an intent that reaches two of them is the divergence tier 3 exists to prevent.
|
|
4
|
+
|
|
5
|
+
import { uuid } from '@ultimat3/core';
|
|
6
|
+
import type { ClientSocket, MutatorRef } from './client-contract';
|
|
7
|
+
import { TransportUnavailableError } from './errors';
|
|
8
|
+
import type { JsonValue } from './json';
|
|
9
|
+
import type { LocalStore, TableMap } from './local-store';
|
|
10
|
+
import { type MutationSender, mutateFrame, type OfflineQueue } from './offline-queue';
|
|
11
|
+
import type { RebaseLog } from './rebase';
|
|
12
|
+
import { encode, type Frame } from './sync-protocol';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Queued bytes past which the drain stops rather than adds. The same number the node uses at its
|
|
16
|
+
* end of the socket (`DEFAULT_MAX_BUFFERED_BYTES`), and deliberately NOT imported from it: this is
|
|
17
|
+
* browser code, and `socket.ts` is the node's socket registry, its metrics and its close codes —
|
|
18
|
+
* one import pulls the whole server half into the tab's bundle to read an integer. The node's two
|
|
19
|
+
* spellings were merged because they configure one buffer on one side; these are two sides.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
/** Everything the mutation path touches. Narrow on purpose, exactly like `ClientFrameTarget`. */
|
|
24
|
+
export interface MutationDeps<T extends TableMap = TableMap> {
|
|
25
|
+
readonly store: LocalStore<T> | undefined;
|
|
26
|
+
readonly queue: OfflineQueue | undefined;
|
|
27
|
+
readonly log: RebaseLog<T> | undefined;
|
|
28
|
+
readonly now: () => number;
|
|
29
|
+
/** Read per send, never captured: the socket a drain started on may already be gone. */
|
|
30
|
+
socket(): ClientSocket | null;
|
|
31
|
+
send(frame: Frame): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Record one intent everywhere it has to be recorded: the local store (so the UI moves now), the
|
|
36
|
+
* rebase log (so it can be taken back) and the durable queue (so it survives the tab). Nothing is
|
|
37
|
+
* sent here — `drain` is the only thing that puts a mutation on a socket, and with no queue at all
|
|
38
|
+
* (tier 2) the frame goes straight out because there is nothing to drain it from later.
|
|
39
|
+
*/
|
|
40
|
+
export async function recordMutation<T extends TableMap>(
|
|
41
|
+
deps: MutationDeps<T>,
|
|
42
|
+
mutator: MutatorRef<T>,
|
|
43
|
+
input: JsonValue,
|
|
44
|
+
key?: string,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const idempotencyKey = key ?? `${mutator.name}:${uuid()}`;
|
|
47
|
+
const { store, queue } = deps;
|
|
48
|
+
const local = mutator.local;
|
|
49
|
+
const existing = queue?.find(idempotencyKey);
|
|
50
|
+
const queued = await queue?.enqueue({
|
|
51
|
+
key: idempotencyKey,
|
|
52
|
+
name: mutator.name,
|
|
53
|
+
input,
|
|
54
|
+
at: deps.now(),
|
|
55
|
+
});
|
|
56
|
+
// Identity, not a second copy of the queue's collapse rule: `enqueue` hands back the SAME entry
|
|
57
|
+
// when it collapses and a new one when it does not. A repeated key is ONE intent whose twin is
|
|
58
|
+
// already applied — applying it again double-counts the write (a like becomes two) and replaces
|
|
59
|
+
// the log entry a rollback would have undone to the pre-mutation row.
|
|
60
|
+
const collapsed = existing !== undefined && queued === existing;
|
|
61
|
+
if (store && local && !collapsed) {
|
|
62
|
+
store.apply(idempotencyKey, (tx) => local(tx, input));
|
|
63
|
+
deps.log?.record({
|
|
64
|
+
key: idempotencyKey,
|
|
65
|
+
seq: queued?.seq ?? 0,
|
|
66
|
+
entity: mutator.entity ?? mutator.name,
|
|
67
|
+
strategy: mutator.conflict ?? 'server-wins',
|
|
68
|
+
apply: (tx) => local(tx, input),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (queue) return;
|
|
72
|
+
deps.send(
|
|
73
|
+
mutateFrame({
|
|
74
|
+
key: idempotencyKey,
|
|
75
|
+
seq: 0,
|
|
76
|
+
name: mutator.name,
|
|
77
|
+
input,
|
|
78
|
+
enqueuedAt: deps.now(),
|
|
79
|
+
attempts: 0,
|
|
80
|
+
status: 'pending',
|
|
81
|
+
error: null,
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The queue's sender. Throwing is how a sender declines: the queue keeps that mutation pending,
|
|
88
|
+
* stops the pass rather than reordering the ones behind it, and the next drain resumes there.
|
|
89
|
+
*
|
|
90
|
+
* Backpressure is a decline and not a failure — the frames already queued in the tab are ones the
|
|
91
|
+
* socket has not managed to write, so adding to them is how a client sends a burst it will never
|
|
92
|
+
* see acknowledged. A socket that does not report `bufferedAmount` is treated as never backed up.
|
|
93
|
+
*/
|
|
94
|
+
export function mutationSender<T extends TableMap>(deps: MutationDeps<T>): MutationSender {
|
|
95
|
+
return async (mutation) => {
|
|
96
|
+
const socket = deps.socket();
|
|
97
|
+
if (!socket) {
|
|
98
|
+
throw new TransportUnavailableError({
|
|
99
|
+
transport: 'websocket',
|
|
100
|
+
reason: 'the socket went away before this mutation reached it',
|
|
101
|
+
fix: 'it stays queued: await useMutationQueue().drain() once useConnection().online',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const buffered = socket.bufferedAmount ?? 0;
|
|
105
|
+
if (buffered > MAX_BUFFERED_BYTES) {
|
|
106
|
+
throw new TransportUnavailableError({
|
|
107
|
+
transport: 'websocket',
|
|
108
|
+
reason: `${buffered} bytes are already queued on this socket, over the ${MAX_BUFFERED_BYTES} ceiling`,
|
|
109
|
+
fix: 'it stays queued: await useMutationQueue().drain() once the socket has caught up',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
socket.send(encode(mutateFrame(mutation)));
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The client's channel book: which handlers hold which topic, and the one frame that announces a
|
|
2
|
+
// membership. Split out of `client.ts` because the announcement has two callers that must never
|
|
3
|
+
// disagree — `subscribe()` and the reconnect replay — and one of them was missing.
|
|
4
|
+
|
|
5
|
+
import type { Topic } from './channel';
|
|
6
|
+
import type { JsonObject } from './json';
|
|
7
|
+
import { PROTOCOL_VERSION, type SubscribeFrame } from './sync-protocol';
|
|
8
|
+
|
|
9
|
+
export type TopicHandler = (message: JsonObject) => void;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The membership frame. `sid` is the topic itself: a channel subscription is identified by what it
|
|
13
|
+
* is subscribed to, so re-sending it after a reconnect re-establishes the same membership rather
|
|
14
|
+
* than a second one — and on the node, sending it again IS the presence heartbeat.
|
|
15
|
+
*/
|
|
16
|
+
export function topicSubscribeFrame(name: string, op: 'add' | 'drop'): SubscribeFrame {
|
|
17
|
+
return {
|
|
18
|
+
type: 'subscribe',
|
|
19
|
+
v: PROTOCOL_VERSION,
|
|
20
|
+
op,
|
|
21
|
+
sid: name,
|
|
22
|
+
target: { kind: 'topic', topic: name },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Topic -> the handlers holding it. One entry per topic, however many components subscribed. */
|
|
27
|
+
export class TopicBook {
|
|
28
|
+
readonly #topics = new Map<string, Set<TopicHandler>>();
|
|
29
|
+
|
|
30
|
+
add(name: Topic, handler: TopicHandler): void {
|
|
31
|
+
const handlers = this.#topics.get(name) ?? new Set<TopicHandler>();
|
|
32
|
+
handlers.add(handler);
|
|
33
|
+
this.#topics.set(name, handlers);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when that was the last holder, so the caller is the one that sends the drop frame. */
|
|
37
|
+
remove(name: Topic, handler: TopicHandler): boolean {
|
|
38
|
+
const handlers = this.#topics.get(name);
|
|
39
|
+
if (!handlers) return false;
|
|
40
|
+
handlers.delete(handler);
|
|
41
|
+
if (handlers.size > 0) return false;
|
|
42
|
+
this.#topics.delete(name);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
handlers(name: string): ReadonlySet<TopicHandler> | undefined {
|
|
47
|
+
return this.#topics.get(name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every membership this client still holds — what a reconnect has to re-announce. */
|
|
51
|
+
names(): readonly string[] {
|
|
52
|
+
return [...this.#topics.keys()];
|
|
53
|
+
}
|
|
54
|
+
}
|