@k2b/cloud 0.22.0 → 0.23.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/package.json +4 -3
- package/scripts/README.md +1 -1
- package/scripts/build-canvas-workers.ts +58 -0
- package/scripts/build.ts +4 -5
- package/src/_internal/canvas-worker.ts +24 -0
- package/src/_internal/define-app.ts +81 -7
- package/src/_internal/page-responses.ts +19 -3
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/static-assets.ts +18 -3
- package/src/access/ResourceApiKeys.tsx +30 -30
- package/src/ai/capability-execution.ts +5 -0
- package/src/ai/client/controller.ts +17 -3
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/live-routes.ts +2 -2
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +161 -0
- package/src/api/pwa.ts +179 -0
- package/src/browser/app-session.ts +69 -0
- package/src/browser/live-connection.ts +211 -0
- package/src/browser/live-websocket.ts +31 -11
- package/src/browser/live.ts +2 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/pwa-paths.ts +6 -0
- package/src/contracts/pwa.ts +156 -0
- package/src/contracts/registry.ts +7 -0
- package/src/contracts/shared.ts +7 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-engine.ts +826 -0
- package/src/events/live-protocol.ts +31 -0
- package/src/events/live.ts +298 -0
- package/src/server/actor.ts +7 -0
- package/src/server/api-client.ts +23 -2
- package/src/server/index.ts +3 -1
- package/src/server/middleware/auth.ts +80 -8
- package/src/server/middleware/openapi.ts +2 -1
- package/src/server/services/access.ts +49 -0
- package/src/server/services/index.ts +1 -0
- package/src/services/app-approval.ts +6 -25
- package/src/services/audit/index.ts +2 -0
- package/src/services/branding/app-icon-source.ts +40 -0
- package/src/services/branding/app-icons.ts +112 -0
- package/src/services/branding/icon-render-worker.ts +69 -0
- package/src/services/branding/icon-svg.ts +22 -0
- package/src/services/identity/invocation-actor.ts +8 -5
- package/src/services/identity/invocation-authority.ts +1 -0
- package/src/services/identity/invocation-token.ts +6 -0
- package/src/services/index.ts +1 -0
- package/src/services/outbox.ts +150 -30
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pwa-devices.ts +574 -0
- package/src/services/session/index.ts +212 -57
- package/src/services/session/recent.ts +42 -0
- package/src/services/session/user.ts +15 -5
- package/src/shared/index.ts +1 -0
- package/src/{browser → shared}/locale-preference.ts +1 -1
- package/src/shared/markdown/extensions/info-blocks.ts +18 -47
- package/src/shared/markdown/index.ts +5 -14
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/Layout.tsx +10 -12
- package/src/ssr/LayoutHeader.tsx +14 -2
- package/src/ssr/LayoutHelp.tsx +2 -4
- package/src/ssr/LayoutRail.tsx +13 -4
- package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +19 -13
- package/src/ssr/PageError.tsx +23 -1
- package/src/ssr/ProfilePreferences.island.tsx +9 -1
- package/src/ssr/PwaLayout.tsx +69 -0
- package/src/ssr/PwaRuntime.island.tsx +105 -0
- package/src/ssr/TimezoneCookie.island.tsx +7 -1
- package/src/ssr/app-navigation.ts +25 -10
- package/src/ssr/index.ts +8 -1
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/preference-controller.ts +2 -2
- package/src/ssr/profile-actions.ts +3 -1
- package/src/ssr/profile-preferences-messages.ts +2 -0
- package/src/ssr/pwa-messages.ts +24 -0
- package/src/styles/global.css +6 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
import { RetentionGapError, SyncLifecycleError, type Topic, type TopicEvent } from "@k2b/sync";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { RequestActor } from "../server/middleware/auth";
|
|
4
|
+
import type { AccessSubject } from "../server/services/access";
|
|
5
|
+
import { logger } from "../services/logging";
|
|
6
|
+
import { type LiveClientMessage, LiveClientMessageSchema } from "./live-protocol";
|
|
7
|
+
|
|
8
|
+
/** Bounds of every live socket. Code constants; tests only shorten the intervals. */
|
|
9
|
+
export const LIVE_LIMITS = {
|
|
10
|
+
ringEvents: 5_000,
|
|
11
|
+
ringBytes: 32 * 1024 ** 2,
|
|
12
|
+
fillWaitMs: 5_000,
|
|
13
|
+
frameBytes: 16 * 1024,
|
|
14
|
+
/** A reconnect sends every subscription at once, so a full set and a few `unsub` may wait. */
|
|
15
|
+
pendingMessages: 24,
|
|
16
|
+
subscriptions: 16,
|
|
17
|
+
keys: 1_000,
|
|
18
|
+
socketsPerViewer: 16,
|
|
19
|
+
sendBufferBytes: 256 * 1024,
|
|
20
|
+
replayWaitMs: 10_000,
|
|
21
|
+
cacheMs: 2_000,
|
|
22
|
+
/** A check that takes longer than a sweep round fails, so the 10-second bounds hold. */
|
|
23
|
+
checkMs: 10_000,
|
|
24
|
+
cacheEntries: 50_000,
|
|
25
|
+
viewersPerCall: 500,
|
|
26
|
+
callsInFlight: 8,
|
|
27
|
+
sweepInFlight: 4,
|
|
28
|
+
batchEvents: 256,
|
|
29
|
+
sweepMs: 10_000,
|
|
30
|
+
keysEveryRounds: 6,
|
|
31
|
+
progressMs: 30_000,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Who a delivery is for. Viewers with the same `id` share access decisions. */
|
|
35
|
+
export type LiveViewer = {
|
|
36
|
+
/**
|
|
37
|
+
* `user:<id>` or `service_account:<id>`, extended by everything of the
|
|
38
|
+
* credential that can change a read decision: `:app` for a phone's app
|
|
39
|
+
* session, the scopes of an API key or OAuth token.
|
|
40
|
+
*/
|
|
41
|
+
id: string;
|
|
42
|
+
actor: RequestActor;
|
|
43
|
+
accessSubject: AccessSubject;
|
|
44
|
+
/** Credential scopes; empty for sessions. */
|
|
45
|
+
scopes: readonly string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type LiveChannel<Scope extends z.ZodType = z.ZodType> = {
|
|
49
|
+
/** The subscribe payload, validated on every `sub`. */
|
|
50
|
+
scope: Scope;
|
|
51
|
+
/** Routing keys of the scope; Cloud follows the first 1,000. `null` when it does not exist or is not readable. */
|
|
52
|
+
keys(scope: z.output<Scope>, viewer: LiveViewer): Promise<readonly string[] | null>;
|
|
53
|
+
/** IDs of the viewers allowed to read `key`. Called with at most 500 viewers. */
|
|
54
|
+
authorize(key: string, viewers: readonly LiveViewer[]): Promise<ReadonlySet<string>>;
|
|
55
|
+
/** The key set can change without an event; it is refreshed every minute and on access changes. */
|
|
56
|
+
collection?: true;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** One outbox row on the topic: `r` asks subscribers of `k` to reload, `a` announces an access change of `k`. */
|
|
60
|
+
export const LiveEnvelopeSchema = z.object({
|
|
61
|
+
v: z.literal(1),
|
|
62
|
+
k: z.string().min(1),
|
|
63
|
+
d: z.unknown().optional(),
|
|
64
|
+
r: z.literal(true).optional(),
|
|
65
|
+
a: z.literal(true).optional(),
|
|
66
|
+
});
|
|
67
|
+
export type LiveEnvelope = z.infer<typeof LiveEnvelopeSchema>;
|
|
68
|
+
|
|
69
|
+
/** The transport of one socket; Bun's `ServerWebSocket` satisfies it. */
|
|
70
|
+
export type LiveSocket = {
|
|
71
|
+
send(data: string): unknown;
|
|
72
|
+
close(code?: number, reason?: string): void;
|
|
73
|
+
getBufferedAmount(): number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type LiveConnectionHandle = { message(raw: unknown): void; closed(): void };
|
|
77
|
+
|
|
78
|
+
/** The parts of the application's topic the engine reads. */
|
|
79
|
+
export type LiveTopic = Pick<Topic<LiveEnvelope>, "head" | "replay" | "follow" | "cursorAt" | "cursorSequence">;
|
|
80
|
+
|
|
81
|
+
type Entry = {
|
|
82
|
+
seq: number;
|
|
83
|
+
cursor: string;
|
|
84
|
+
/** null: malformed row, or a gap in the topic's history. */
|
|
85
|
+
key: string | null;
|
|
86
|
+
data: string | undefined;
|
|
87
|
+
resync: boolean;
|
|
88
|
+
access: boolean;
|
|
89
|
+
gap: boolean;
|
|
90
|
+
bytes: number;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
type Connection = {
|
|
94
|
+
socket: LiveSocket;
|
|
95
|
+
viewer: LiveViewer;
|
|
96
|
+
revalidate: () => Promise<LiveViewer | null>;
|
|
97
|
+
subs: Map<string, Subscription>;
|
|
98
|
+
pending: number;
|
|
99
|
+
work: Promise<void>;
|
|
100
|
+
closed: boolean;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
type Subscription = {
|
|
104
|
+
id: string;
|
|
105
|
+
conn: Connection;
|
|
106
|
+
channel: string;
|
|
107
|
+
collection: boolean;
|
|
108
|
+
scope: unknown;
|
|
109
|
+
keys: Set<string>;
|
|
110
|
+
/** Events at or below this sequence are not delivered. */
|
|
111
|
+
from: number;
|
|
112
|
+
/** Live frames held back while the replay is written, and their size. */
|
|
113
|
+
backlog: string[] | null;
|
|
114
|
+
backlogBytes: number;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const log = logger("events:live");
|
|
118
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
119
|
+
const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
120
|
+
|
|
121
|
+
/** Rejects when `run` has not settled within `LIVE_LIMITS.checkMs`. */
|
|
122
|
+
const bounded = async <T>(what: string, run: Promise<T>): Promise<T> => {
|
|
123
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
124
|
+
try {
|
|
125
|
+
return await Promise.race([
|
|
126
|
+
run,
|
|
127
|
+
new Promise<never>((_, reject) => {
|
|
128
|
+
timer = setTimeout(() => reject(new Error(`${what} did not answer within ${LIVE_LIMITS.checkMs} ms`)), LIVE_LIMITS.checkMs);
|
|
129
|
+
}),
|
|
130
|
+
]);
|
|
131
|
+
} finally {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Runs `run` for every item with at most `limit` in flight. */
|
|
137
|
+
const eachLimited = async <T>(items: readonly T[], limit: number, run: (item: T) => Promise<void>): Promise<void> => {
|
|
138
|
+
let next = 0;
|
|
139
|
+
await Promise.all(
|
|
140
|
+
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
141
|
+
while (next < items.length) await run(items[next++] as T);
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Delivery of one application's live topic in this process: one follower with a
|
|
148
|
+
* ring of recent events, an index from key to subscriptions, delivery-time
|
|
149
|
+
* authorization with a short cache, and a periodic sweep of sessions and access.
|
|
150
|
+
*/
|
|
151
|
+
export const createLiveEngine = (input: { appId: string; topic: () => LiveTopic; channels: Readonly<Record<string, LiveChannel>> }) => {
|
|
152
|
+
const { appId, channels } = input;
|
|
153
|
+
const stopping = new AbortController();
|
|
154
|
+
const connections = new Set<Connection>();
|
|
155
|
+
const index = new Map<string, Set<Subscription>>();
|
|
156
|
+
const cache = new Map<string, { at: number; ok: boolean }>();
|
|
157
|
+
let ring: Entry[] = [];
|
|
158
|
+
let ringBytes = 0;
|
|
159
|
+
/** Every event up to `head` was delivered; `received` is the follower's read position. */
|
|
160
|
+
let head = 0;
|
|
161
|
+
let received = 0;
|
|
162
|
+
let started: Promise<void> | null = null;
|
|
163
|
+
let timers: ReturnType<typeof setInterval>[] = [];
|
|
164
|
+
const inbox: Entry[] = [];
|
|
165
|
+
let draining = false;
|
|
166
|
+
let room: (() => void) | null = null;
|
|
167
|
+
|
|
168
|
+
const cursorAt = (seq: number) => input.topic().cursorAt(seq);
|
|
169
|
+
const frame = {
|
|
170
|
+
ready: (id: string, seq: number) => JSON.stringify({ t: "ready", id, cursor: cursorAt(seq) }),
|
|
171
|
+
resync: (id: string, cursor: string) => JSON.stringify({ t: "resync", id, cursor }),
|
|
172
|
+
revoked: (id: string, code: "not_found" | "access_denied") => JSON.stringify({ t: "revoked", id, code }),
|
|
173
|
+
// The data is serialized once per event and spliced into each subscription's frame.
|
|
174
|
+
event: (id: string, entry: Entry) =>
|
|
175
|
+
entry.resync
|
|
176
|
+
? frame.resync(id, entry.cursor)
|
|
177
|
+
: `{"t":"event","id":${JSON.stringify(id)},"cursor":${JSON.stringify(entry.cursor)},"data":${entry.data}}`,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// ---------- sockets ----------
|
|
181
|
+
|
|
182
|
+
const detach = (conn: Connection) => {
|
|
183
|
+
for (const sub of conn.subs.values()) removeSubscription(sub);
|
|
184
|
+
connections.delete(conn);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const close = (conn: Connection, code: number, reason: string, text?: string) => {
|
|
188
|
+
if (conn.closed) return;
|
|
189
|
+
conn.closed = true;
|
|
190
|
+
try {
|
|
191
|
+
if (text) conn.socket.send(JSON.stringify({ t: "error", code: reason, message: text }));
|
|
192
|
+
conn.socket.close(code, reason);
|
|
193
|
+
} catch {
|
|
194
|
+
// The peer is already gone.
|
|
195
|
+
}
|
|
196
|
+
detach(conn);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** Closes sockets whose check failed; they reconnect and resume from their cursor. Logged once per cause. */
|
|
200
|
+
const fail = (conns: Iterable<Connection>, error: unknown, what: string) => {
|
|
201
|
+
let closed = 0;
|
|
202
|
+
for (const conn of conns) {
|
|
203
|
+
if (conn.closed) continue;
|
|
204
|
+
close(conn, 1011, "unavailable", "Live updates are briefly unavailable.");
|
|
205
|
+
closed++;
|
|
206
|
+
}
|
|
207
|
+
if (closed > 0) log.warn(`Live sockets closed: ${what} failed`, { appId, sockets: closed, error: message(error) });
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const violation = (conn: Connection, code: string) => close(conn, 1008, code, "The live socket received an invalid message.");
|
|
211
|
+
|
|
212
|
+
const tooSlow = (conn: Connection) => {
|
|
213
|
+
log.warn("Live socket closed because the client reads too slowly", { appId });
|
|
214
|
+
close(conn, 1013, "backpressure", "The client reads live updates too slowly.");
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const write = (conn: Connection, text: string) => {
|
|
218
|
+
if (conn.closed) return;
|
|
219
|
+
conn.socket.send(text);
|
|
220
|
+
if (conn.socket.getBufferedAmount() > LIVE_LIMITS.sendBufferBytes) tooSlow(conn);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** Writes and waits while the send buffer is full, until `deadline`. */
|
|
224
|
+
const writeWaiting = async (conn: Connection, text: string, deadline: number): Promise<boolean> => {
|
|
225
|
+
if (conn.closed) return false;
|
|
226
|
+
conn.socket.send(text);
|
|
227
|
+
while (conn.socket.getBufferedAmount() > LIVE_LIMITS.sendBufferBytes) {
|
|
228
|
+
if (conn.closed) return false;
|
|
229
|
+
if (Date.now() > deadline) {
|
|
230
|
+
tooSlow(conn);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
await sleep(25);
|
|
234
|
+
}
|
|
235
|
+
return !conn.closed;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** During a replay, live frames wait in a backlog that is bounded like the send buffer. */
|
|
239
|
+
const send = (sub: Subscription, text: string) => {
|
|
240
|
+
if (!sub.backlog) return write(sub.conn, text);
|
|
241
|
+
sub.backlog.push(text);
|
|
242
|
+
sub.backlogBytes += text.length;
|
|
243
|
+
if (sub.backlogBytes > LIVE_LIMITS.sendBufferBytes) tooSlow(sub.conn);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// ---------- index ----------
|
|
247
|
+
|
|
248
|
+
const indexKey = (sub: Subscription, key: string) => {
|
|
249
|
+
const subs = index.get(key);
|
|
250
|
+
if (subs) subs.add(sub);
|
|
251
|
+
else index.set(key, new Set([sub]));
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const unindexKey = (sub: Subscription, key: string) => {
|
|
255
|
+
const subs = index.get(key);
|
|
256
|
+
subs?.delete(sub);
|
|
257
|
+
if (subs?.size === 0) index.delete(key);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const removeSubscription = (sub: Subscription) => {
|
|
261
|
+
for (const key of sub.keys) unindexKey(sub, key);
|
|
262
|
+
if (sub.conn.subs.get(sub.id) === sub) sub.conn.subs.delete(sub.id);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** A single-key subscription ends; a collection drops the key and reloads. */
|
|
266
|
+
const loseKey = (sub: Subscription, key: string) => {
|
|
267
|
+
if (!sub.collection) {
|
|
268
|
+
send(sub, frame.revoked(sub.id, "access_denied"));
|
|
269
|
+
removeSubscription(sub);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
sub.keys.delete(key);
|
|
273
|
+
unindexKey(sub, key);
|
|
274
|
+
send(sub, frame.resync(sub.id, cursorAt(head)));
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/** Distinct viewers of `key`'s subscriptions, per channel. */
|
|
278
|
+
const viewersOf = (key: string): Map<string, LiveViewer[]> => {
|
|
279
|
+
const byChannel = new Map<string, Map<string, LiveViewer>>();
|
|
280
|
+
for (const sub of index.get(key) ?? []) {
|
|
281
|
+
const viewers = byChannel.get(sub.channel) ?? new Map<string, LiveViewer>();
|
|
282
|
+
viewers.set(sub.conn.viewer.id, sub.conn.viewer);
|
|
283
|
+
byChannel.set(sub.channel, viewers);
|
|
284
|
+
}
|
|
285
|
+
return new Map([...byChannel].map(([channel, viewers]) => [channel, [...viewers.values()]]));
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// ---------- authorization ----------
|
|
289
|
+
|
|
290
|
+
let inFlight = 0;
|
|
291
|
+
const waiting: (() => void)[] = [];
|
|
292
|
+
const limited = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
293
|
+
while (inFlight >= LIVE_LIMITS.callsInFlight) await new Promise<void>((resolve) => waiting.push(resolve));
|
|
294
|
+
inFlight++;
|
|
295
|
+
try {
|
|
296
|
+
return await run();
|
|
297
|
+
} finally {
|
|
298
|
+
inFlight--;
|
|
299
|
+
waiting.shift()?.();
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const cacheKey = (channel: string, key: string, viewerId: string) => `${channel}\u0000${key}\u0000${viewerId}`;
|
|
304
|
+
/** Counts access updates; an answer requested before one is used, but not cached. */
|
|
305
|
+
let accessUpdates = 0;
|
|
306
|
+
|
|
307
|
+
type Decision = { decided: Map<string, boolean> /** When the oldest of them was requested. */; at: number };
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Whether each viewer may read `key`: cached for 2 seconds from the request
|
|
311
|
+
* unless `fresh`, one call per 500 uncached viewers.
|
|
312
|
+
*/
|
|
313
|
+
const decide = async (channel: string, key: string, viewers: readonly LiveViewer[], fresh = false): Promise<Decision> => {
|
|
314
|
+
const decided = new Map<string, boolean>();
|
|
315
|
+
const missing = new Map<string, LiveViewer>();
|
|
316
|
+
let at = Date.now();
|
|
317
|
+
for (const viewer of viewers) {
|
|
318
|
+
const hit = fresh ? undefined : cache.get(cacheKey(channel, key, viewer.id));
|
|
319
|
+
if (hit && Date.now() - hit.at < LIVE_LIMITS.cacheMs) {
|
|
320
|
+
decided.set(viewer.id, hit.ok);
|
|
321
|
+
at = Math.min(at, hit.at);
|
|
322
|
+
} else missing.set(viewer.id, viewer);
|
|
323
|
+
}
|
|
324
|
+
const pending = [...missing.values()];
|
|
325
|
+
for (let start = 0; start < pending.length; start += LIVE_LIMITS.viewersPerCall) {
|
|
326
|
+
const part = pending.slice(start, start + LIVE_LIMITS.viewersPerCall);
|
|
327
|
+
const asked = { at: Date.now(), updates: accessUpdates };
|
|
328
|
+
const allowed = await limited(() => bounded("authorize()", (channels[channel] as LiveChannel).authorize(key, part)));
|
|
329
|
+
for (const viewer of part) {
|
|
330
|
+
const ok = allowed.has(viewer.id);
|
|
331
|
+
decided.set(viewer.id, ok);
|
|
332
|
+
if (asked.updates !== accessUpdates) continue;
|
|
333
|
+
const id = cacheKey(channel, key, viewer.id);
|
|
334
|
+
cache.delete(id);
|
|
335
|
+
cache.set(id, { at: asked.at, ok });
|
|
336
|
+
if (cache.size > LIVE_LIMITS.cacheEntries) cache.delete(cache.keys().next().value as string);
|
|
337
|
+
}
|
|
338
|
+
at = Math.min(at, asked.at);
|
|
339
|
+
}
|
|
340
|
+
return { decided, at };
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const readableKeys = async (channel: string, keys: readonly string[], viewer: LiveViewer): Promise<string[]> => {
|
|
344
|
+
const unique = [...new Set(keys)];
|
|
345
|
+
const decided = await Promise.all(unique.map((key) => decide(channel, key, [viewer])));
|
|
346
|
+
return unique.filter((_, position) => decided[position]?.decided.get(viewer.id) === true);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/** Checks every subscriber of `key` without the cache; denied viewers lose the key. */
|
|
350
|
+
const reauthorize = async (key: string) => {
|
|
351
|
+
for (const [channel, viewers] of viewersOf(key)) {
|
|
352
|
+
const subs = [...(index.get(key) ?? [])].filter((sub) => sub.channel === channel);
|
|
353
|
+
try {
|
|
354
|
+
const { decided } = await decide(channel, key, viewers, true);
|
|
355
|
+
for (const sub of subs) if (sub.keys.has(key) && decided.get(sub.conn.viewer.id) === false) loseKey(sub, key);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
fail(
|
|
358
|
+
subs.map((sub) => sub.conn),
|
|
359
|
+
error,
|
|
360
|
+
"access check",
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/** The keys a subscription follows: the first 1,000, with a warning about the rest. */
|
|
367
|
+
const followed = (channel: string, keys: readonly string[]): readonly string[] => {
|
|
368
|
+
const unique = [...new Set(keys)];
|
|
369
|
+
if (unique.length <= LIVE_LIMITS.keys) return unique;
|
|
370
|
+
log.warn("A live subscription follows only the first keys of its scope", {
|
|
371
|
+
appId,
|
|
372
|
+
channel,
|
|
373
|
+
keys: unique.length,
|
|
374
|
+
limit: LIVE_LIMITS.keys,
|
|
375
|
+
});
|
|
376
|
+
return unique.slice(0, LIVE_LIMITS.keys);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Runs `keys()` again for collection subscriptions; a changed key set
|
|
381
|
+
* reloads. Only added keys are authorized: kept keys are checked at delivery
|
|
382
|
+
* and by the sweep.
|
|
383
|
+
*/
|
|
384
|
+
const refreshCollections = async () => {
|
|
385
|
+
const subs = [...connections].flatMap((conn) => [...conn.subs.values()]).filter((sub) => sub.collection);
|
|
386
|
+
const unchecked: Connection[] = [];
|
|
387
|
+
let failure: unknown;
|
|
388
|
+
await eachLimited(subs, LIVE_LIMITS.sweepInFlight, async (sub) => {
|
|
389
|
+
try {
|
|
390
|
+
const keys = await bounded("keys()", (channels[sub.channel] as LiveChannel).keys(sub.scope, sub.conn.viewer));
|
|
391
|
+
const listed = keys === null ? null : followed(sub.channel, keys);
|
|
392
|
+
const added =
|
|
393
|
+
listed === null
|
|
394
|
+
? []
|
|
395
|
+
: await readableKeys(
|
|
396
|
+
sub.channel,
|
|
397
|
+
listed.filter((key) => !sub.keys.has(key)),
|
|
398
|
+
sub.conn.viewer,
|
|
399
|
+
);
|
|
400
|
+
if (sub.conn.subs.get(sub.id) !== sub) return;
|
|
401
|
+
if (listed === null) {
|
|
402
|
+
send(sub, frame.revoked(sub.id, "not_found"));
|
|
403
|
+
removeSubscription(sub);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const next = new Set([...listed.filter((key) => sub.keys.has(key)), ...added]);
|
|
407
|
+
if (next.size === sub.keys.size && [...next].every((key) => sub.keys.has(key))) return;
|
|
408
|
+
for (const key of sub.keys) if (!next.has(key)) unindexKey(sub, key);
|
|
409
|
+
for (const key of next) if (!sub.keys.has(key)) indexKey(sub, key);
|
|
410
|
+
sub.keys = next;
|
|
411
|
+
send(sub, frame.resync(sub.id, cursorAt(head)));
|
|
412
|
+
} catch (error) {
|
|
413
|
+
unchecked.push(sub.conn);
|
|
414
|
+
failure = error;
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
fail(unchecked, failure, "collection keys");
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
/** One refresh at a time; requests while it runs share a single next run. */
|
|
421
|
+
let refreshing: Promise<void> | null = null;
|
|
422
|
+
let refreshAgain = false;
|
|
423
|
+
const refresh = (): Promise<void> => {
|
|
424
|
+
if (refreshing) {
|
|
425
|
+
refreshAgain = true;
|
|
426
|
+
return refreshing;
|
|
427
|
+
}
|
|
428
|
+
refreshing = (async () => {
|
|
429
|
+
try {
|
|
430
|
+
do {
|
|
431
|
+
refreshAgain = false;
|
|
432
|
+
await refreshCollections();
|
|
433
|
+
} while (refreshAgain && !stopping.signal.aborted);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
log.error("Live collection refresh failed", { appId, error: message(error) });
|
|
436
|
+
} finally {
|
|
437
|
+
refreshing = null;
|
|
438
|
+
}
|
|
439
|
+
})();
|
|
440
|
+
return refreshing;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// ---------- follower and delivery ----------
|
|
444
|
+
|
|
445
|
+
const toEntry = (event: TopicEvent<LiveEnvelope>): Entry => {
|
|
446
|
+
const parsed = LiveEnvelopeSchema.safeParse(event.data);
|
|
447
|
+
const base = { seq: event.sequence, cursor: event.cursor, gap: false };
|
|
448
|
+
if (!parsed.success) {
|
|
449
|
+
log.warn("Skipped a malformed live update", { appId, cursor: event.cursor });
|
|
450
|
+
return { ...base, key: null, data: undefined, resync: false, access: false, bytes: 64 };
|
|
451
|
+
}
|
|
452
|
+
const { k, d, r, a } = parsed.data;
|
|
453
|
+
const data = d === undefined ? undefined : JSON.stringify(d);
|
|
454
|
+
return { ...base, key: k, data, resync: r === true, access: a === true, bytes: (data?.length ?? 0) + k.length + 64 };
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const remember = (entry: Entry) => {
|
|
458
|
+
ring.push(entry);
|
|
459
|
+
ringBytes += entry.bytes;
|
|
460
|
+
while (ring.length > LIVE_LIMITS.ringEvents || ringBytes > LIVE_LIMITS.ringBytes) ringBytes -= ring.shift()?.bytes ?? 0;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
/** Decisions of a batch, requested together before its first event is delivered. */
|
|
464
|
+
type Prefetched = Map<string, Promise<Decision>>;
|
|
465
|
+
|
|
466
|
+
const deliver = async (entry: Entry, decisions: Prefetched) => {
|
|
467
|
+
// Remembered before the candidates are taken: a subscription that registers
|
|
468
|
+
// from here on starts after this event and gets it from the ring instead.
|
|
469
|
+
remember(entry);
|
|
470
|
+
const key = entry.key;
|
|
471
|
+
if (key && (entry.data !== undefined || entry.resync)) {
|
|
472
|
+
const groups = new Map<string, Subscription[]>();
|
|
473
|
+
for (const sub of index.get(key) ?? []) {
|
|
474
|
+
if (entry.seq <= sub.from) continue;
|
|
475
|
+
const group = groups.get(sub.channel);
|
|
476
|
+
if (group) group.push(sub);
|
|
477
|
+
else groups.set(sub.channel, [sub]);
|
|
478
|
+
}
|
|
479
|
+
for (const [channel, candidates] of groups) {
|
|
480
|
+
let decided: Map<string, boolean>;
|
|
481
|
+
try {
|
|
482
|
+
const prefetched = await decisions.get(`${channel}\u0000${key}`);
|
|
483
|
+
// A decision older than the cache, for example behind a slow batch, is requested again.
|
|
484
|
+
decided = prefetched && Date.now() - prefetched.at < LIVE_LIMITS.cacheMs ? new Map(prefetched.decided) : new Map();
|
|
485
|
+
// Subscriptions added after the batch started are checked on their own.
|
|
486
|
+
const late = candidates.map((sub) => sub.conn.viewer).filter((viewer) => !decided.has(viewer.id));
|
|
487
|
+
if (late.length > 0) for (const [id, ok] of (await decide(channel, key, late)).decided) decided.set(id, ok);
|
|
488
|
+
} catch (error) {
|
|
489
|
+
fail(
|
|
490
|
+
candidates.map((sub) => sub.conn),
|
|
491
|
+
error,
|
|
492
|
+
"access check",
|
|
493
|
+
);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
for (const sub of candidates) {
|
|
497
|
+
if (sub.conn.closed || !sub.keys.has(key)) continue;
|
|
498
|
+
if (decided.get(sub.conn.viewer.id)) send(sub, frame.event(sub.id, entry));
|
|
499
|
+
else loseKey(sub, key);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
head = entry.seq;
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* An access change: forget the key's decisions and check its subscribers
|
|
508
|
+
* before the update is delivered. Collections run `keys()` again next to
|
|
509
|
+
* delivery, not in its way; a changed key set reloads them.
|
|
510
|
+
*/
|
|
511
|
+
const changeAccess = async (entry: Entry) => {
|
|
512
|
+
const key = entry.key as string;
|
|
513
|
+
accessUpdates++;
|
|
514
|
+
for (const id of [...cache.keys()]) if (id.includes(`\u0000${key}\u0000`)) cache.delete(id);
|
|
515
|
+
await reauthorize(key);
|
|
516
|
+
void refresh();
|
|
517
|
+
await deliver(entry, new Map());
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
/** The topic lost events before the follower read them: every subscription reloads. */
|
|
521
|
+
const recoverGap = (entry: Entry) => {
|
|
522
|
+
ring = [];
|
|
523
|
+
ringBytes = 0;
|
|
524
|
+
head = entry.seq;
|
|
525
|
+
for (const conn of connections) for (const sub of conn.subs.values()) send(sub, frame.resync(sub.id, cursorAt(head)));
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
/** Starts every authorization of a batch at once, then delivers it in order. */
|
|
529
|
+
const deliverBatch = async (batch: Entry[]) => {
|
|
530
|
+
let start = 0;
|
|
531
|
+
while (start < batch.length) {
|
|
532
|
+
let end = start;
|
|
533
|
+
while (end < batch.length && !batch[end]?.access && !batch[end]?.gap) end++;
|
|
534
|
+
const decisions: Prefetched = new Map();
|
|
535
|
+
for (const entry of batch.slice(start, end)) {
|
|
536
|
+
if (!entry.key) continue;
|
|
537
|
+
for (const [channel, viewers] of viewersOf(entry.key)) {
|
|
538
|
+
const id = `${channel}\u0000${entry.key}`;
|
|
539
|
+
if (decisions.has(id)) continue;
|
|
540
|
+
const decision = decide(channel, entry.key, viewers);
|
|
541
|
+
decision.catch(() => undefined);
|
|
542
|
+
decisions.set(id, decision);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
for (const entry of batch.slice(start, end)) await deliver(entry, decisions);
|
|
546
|
+
const special = batch[end];
|
|
547
|
+
if (special?.gap) recoverGap(special);
|
|
548
|
+
else if (special) await changeAccess(special);
|
|
549
|
+
start = end + 1;
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const drain = async () => {
|
|
554
|
+
if (draining) return;
|
|
555
|
+
draining = true;
|
|
556
|
+
try {
|
|
557
|
+
while (inbox.length > 0) {
|
|
558
|
+
const batch = inbox.splice(0, LIVE_LIMITS.batchEvents);
|
|
559
|
+
room?.();
|
|
560
|
+
room = null;
|
|
561
|
+
try {
|
|
562
|
+
await deliverBatch(batch);
|
|
563
|
+
} catch (error) {
|
|
564
|
+
// Never skip silently: every socket resumes from its cursor.
|
|
565
|
+
log.error("Live delivery failed", { appId, error: message(error) });
|
|
566
|
+
head = batch.at(-1)?.seq ?? head;
|
|
567
|
+
fail([...connections], error, "delivery");
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
} finally {
|
|
571
|
+
draining = false;
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const follow = async (topic: LiveTopic) => {
|
|
576
|
+
while (!stopping.signal.aborted) {
|
|
577
|
+
try {
|
|
578
|
+
for await (const event of topic.follow({ after: topic.cursorAt(received), signal: stopping.signal })) {
|
|
579
|
+
received = event.sequence;
|
|
580
|
+
inbox.push(toEntry(event));
|
|
581
|
+
void drain();
|
|
582
|
+
if (inbox.length >= LIVE_LIMITS.batchEvents) await new Promise<void>((resolve) => (room = resolve));
|
|
583
|
+
}
|
|
584
|
+
} catch (error) {
|
|
585
|
+
// A stopped Sync does not come back: the process is shutting down.
|
|
586
|
+
if (stopping.signal.aborted || error instanceof SyncLifecycleError) return;
|
|
587
|
+
if (error instanceof RetentionGapError && error.resumeAfter) {
|
|
588
|
+
received = topic.cursorSequence(error.resumeAfter);
|
|
589
|
+
const cursor = topic.cursorAt(received);
|
|
590
|
+
inbox.push({ seq: received, cursor, key: null, data: undefined, resync: false, access: false, gap: true, bytes: 0 });
|
|
591
|
+
void drain();
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
log.warn("Live follower failed; it resumes", { appId, error: message(error) });
|
|
595
|
+
await sleep(1_000);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
/** Fills the ring with one replay of the recent events, then follows the topic. */
|
|
601
|
+
const start = (): Promise<void> => {
|
|
602
|
+
if (timers.length === 0) {
|
|
603
|
+
timers = [setInterval(() => void sweep(), LIVE_LIMITS.sweepMs), setInterval(progress, LIVE_LIMITS.progressMs)];
|
|
604
|
+
for (const timer of timers) timer.unref?.();
|
|
605
|
+
}
|
|
606
|
+
started ??= (async () => {
|
|
607
|
+
const topic = input.topic();
|
|
608
|
+
const target = topic.cursorSequence(await topic.head());
|
|
609
|
+
let after = Math.max(0, target - LIVE_LIMITS.ringEvents);
|
|
610
|
+
for (;;) {
|
|
611
|
+
// An attempt that failed halfway must not leave its events behind for the next one.
|
|
612
|
+
ring = [];
|
|
613
|
+
ringBytes = 0;
|
|
614
|
+
try {
|
|
615
|
+
const until = topic.cursorAt(target);
|
|
616
|
+
for await (const event of topic.replay({ after: topic.cursorAt(after), until, signal: stopping.signal }))
|
|
617
|
+
remember(toEntry(event));
|
|
618
|
+
break;
|
|
619
|
+
} catch (error) {
|
|
620
|
+
if (!(error instanceof RetentionGapError) || !error.resumeAfter) throw error;
|
|
621
|
+
after = topic.cursorSequence(error.resumeAfter);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
head = received = target;
|
|
625
|
+
void follow(topic);
|
|
626
|
+
})().catch((error) => {
|
|
627
|
+
started = null;
|
|
628
|
+
throw error;
|
|
629
|
+
});
|
|
630
|
+
return started;
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const whenStarted = () =>
|
|
634
|
+
Promise.race([
|
|
635
|
+
start(),
|
|
636
|
+
sleep(LIVE_LIMITS.fillWaitMs).then(() => {
|
|
637
|
+
throw new Error("The live follower did not start in time");
|
|
638
|
+
}),
|
|
639
|
+
]);
|
|
640
|
+
|
|
641
|
+
// ---------- subscribe ----------
|
|
642
|
+
|
|
643
|
+
const sequenceOf = (cursor: string): number | null => {
|
|
644
|
+
try {
|
|
645
|
+
return input.topic().cursorSequence(cursor);
|
|
646
|
+
} catch {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Writes the replay, then `ready` and the live frames held back meanwhile.
|
|
653
|
+
* A subscription that ends or loses a key during the replay gets nothing
|
|
654
|
+
* more of it: its `revoked` or `resync` waits in the backlog. The whole
|
|
655
|
+
* replay waits for a full send buffer at most 10 seconds.
|
|
656
|
+
*/
|
|
657
|
+
const replayTo = async (sub: Subscription, entries: Entry[]) => {
|
|
658
|
+
const deadline = Date.now() + LIVE_LIMITS.replayWaitMs;
|
|
659
|
+
for (const entry of entries) {
|
|
660
|
+
if (sub.conn.subs.get(sub.id) !== sub) break;
|
|
661
|
+
if (!sub.keys.has(entry.key as string)) continue;
|
|
662
|
+
if (!(await writeWaiting(sub.conn, frame.event(sub.id, entry), deadline))) return;
|
|
663
|
+
}
|
|
664
|
+
if (sub.conn.subs.get(sub.id) === sub && !(await writeWaiting(sub.conn, frame.ready(sub.id, sub.from), deadline))) return;
|
|
665
|
+
while (sub.backlog && sub.backlog.length > 0) {
|
|
666
|
+
const text = sub.backlog.shift() as string;
|
|
667
|
+
sub.backlogBytes -= text.length;
|
|
668
|
+
if (!(await writeWaiting(sub.conn, text, deadline))) return;
|
|
669
|
+
}
|
|
670
|
+
sub.backlog = null;
|
|
671
|
+
sub.backlogBytes = 0;
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const subscribe = async (conn: Connection, request: Extract<LiveClientMessage, { t: "sub" }>) => {
|
|
675
|
+
const channel = Object.hasOwn(channels, request.channel) ? channels[request.channel] : undefined;
|
|
676
|
+
if (!channel) return violation(conn, "unknown_channel");
|
|
677
|
+
if (conn.subs.has(request.id)) return violation(conn, "duplicate_subscription");
|
|
678
|
+
if (conn.subs.size >= LIVE_LIMITS.subscriptions) return violation(conn, "too_many_subscriptions");
|
|
679
|
+
const scope = channel.scope.safeParse(request.scope);
|
|
680
|
+
if (!scope.success) return violation(conn, "invalid_scope");
|
|
681
|
+
await whenStarted();
|
|
682
|
+
const keys = await channel.keys(scope.data, conn.viewer);
|
|
683
|
+
const readable = keys === null ? [] : await readableKeys(request.channel, followed(request.channel, keys), conn.viewer);
|
|
684
|
+
// Not found and not readable look the same.
|
|
685
|
+
if (keys === null || (readable.length === 0 && !channel.collection)) return write(conn, frame.revoked(request.id, "not_found"));
|
|
686
|
+
const after = request.after === undefined ? null : sequenceOf(request.after);
|
|
687
|
+
// A cursor ahead of the follower is fine unless it is also ahead of the topic, which was then recreated.
|
|
688
|
+
const recreated = after !== null && after > head && after > input.topic().cursorSequence(await input.topic().head());
|
|
689
|
+
if (conn.closed) return;
|
|
690
|
+
|
|
691
|
+
// Synchronous from here. The position includes an event that is being delivered right now:
|
|
692
|
+
// the replay covers everything up to it, live delivery everything after it.
|
|
693
|
+
const position = Math.max(head, ring.at(-1)?.seq ?? 0);
|
|
694
|
+
const oldest = ring[0]?.seq ?? position + 1;
|
|
695
|
+
// A collection cannot tell which keys it had before: an access update of a key it follows now,
|
|
696
|
+
// missed while it was away, may have added that key.
|
|
697
|
+
const missedAccess =
|
|
698
|
+
channel.collection === true &&
|
|
699
|
+
after !== null &&
|
|
700
|
+
ring.some((entry) => entry.access && entry.seq > after && entry.seq <= position && readable.includes(entry.key as string));
|
|
701
|
+
const resync = request.after !== undefined && (after === null || after < oldest - 1 || recreated || missedAccess);
|
|
702
|
+
const sub: Subscription = {
|
|
703
|
+
id: request.id,
|
|
704
|
+
conn,
|
|
705
|
+
channel: request.channel,
|
|
706
|
+
collection: channel.collection === true,
|
|
707
|
+
scope: scope.data,
|
|
708
|
+
keys: new Set(readable),
|
|
709
|
+
from: after !== null && !resync && after > position ? after : position,
|
|
710
|
+
backlog: null,
|
|
711
|
+
backlogBytes: 0,
|
|
712
|
+
};
|
|
713
|
+
conn.subs.set(sub.id, sub);
|
|
714
|
+
for (const key of sub.keys) indexKey(sub, key);
|
|
715
|
+
if (resync) return send(sub, frame.resync(sub.id, cursorAt(position)));
|
|
716
|
+
const replay =
|
|
717
|
+
after === null || after >= position
|
|
718
|
+
? []
|
|
719
|
+
: ring.filter(
|
|
720
|
+
(entry) => entry.seq > after && entry.key !== null && sub.keys.has(entry.key) && (entry.data !== undefined || entry.resync),
|
|
721
|
+
);
|
|
722
|
+
if (replay.length === 0) return send(sub, frame.ready(sub.id, sub.from));
|
|
723
|
+
sub.backlog = [];
|
|
724
|
+
await replayTo(sub, replay);
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
const handle = async (conn: Connection, raw: string) => {
|
|
728
|
+
let request: LiveClientMessage;
|
|
729
|
+
try {
|
|
730
|
+
request = LiveClientMessageSchema.parse(JSON.parse(raw));
|
|
731
|
+
} catch {
|
|
732
|
+
return violation(conn, "invalid_message");
|
|
733
|
+
}
|
|
734
|
+
if (request.t === "unsub") {
|
|
735
|
+
const sub = conn.subs.get(request.id);
|
|
736
|
+
if (sub) removeSubscription(sub);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
await subscribe(conn, request);
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// ---------- sweep and progress ----------
|
|
743
|
+
|
|
744
|
+
let sweeping = false;
|
|
745
|
+
let round = 0;
|
|
746
|
+
const sweep = async () => {
|
|
747
|
+
if (sweeping || stopping.signal.aborted) return;
|
|
748
|
+
sweeping = true;
|
|
749
|
+
round++;
|
|
750
|
+
try {
|
|
751
|
+
const unchecked: Connection[] = [];
|
|
752
|
+
let failure: unknown;
|
|
753
|
+
await eachLimited([...connections], LIVE_LIMITS.sweepInFlight, async (conn) => {
|
|
754
|
+
if (conn.closed) return;
|
|
755
|
+
try {
|
|
756
|
+
const viewer = await bounded("The credential check", conn.revalidate());
|
|
757
|
+
if (!viewer || viewer.id !== conn.viewer.id) close(conn, 1008, "session_expired", "The session ended.");
|
|
758
|
+
else conn.viewer = viewer;
|
|
759
|
+
} catch (error) {
|
|
760
|
+
unchecked.push(conn);
|
|
761
|
+
failure = error;
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
fail(unchecked, failure, "credential check");
|
|
765
|
+
await eachLimited([...index.keys()], LIVE_LIMITS.sweepInFlight, reauthorize);
|
|
766
|
+
if (round % LIVE_LIMITS.keysEveryRounds === 0) await refresh();
|
|
767
|
+
} catch (error) {
|
|
768
|
+
log.error("Live sweep failed", { appId, error: message(error) });
|
|
769
|
+
} finally {
|
|
770
|
+
sweeping = false;
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
/** Lets quiet subscriptions move their cursor along, so they stay inside the ring. */
|
|
775
|
+
const progress = () => {
|
|
776
|
+
if (stopping.signal.aborted || connections.size === 0) return;
|
|
777
|
+
try {
|
|
778
|
+
const text = JSON.stringify({ t: "progress", cursor: cursorAt(head) });
|
|
779
|
+
for (const conn of [...connections]) {
|
|
780
|
+
if ([...conn.subs.values()].every((sub) => sub.backlog === null && sub.from <= head)) write(conn, text);
|
|
781
|
+
}
|
|
782
|
+
} catch (error) {
|
|
783
|
+
// A timer callback must not throw: the next round tries again.
|
|
784
|
+
log.error("Live progress failed", { appId, error: message(error) });
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
return {
|
|
789
|
+
open: (socket: LiveSocket, viewer: LiveViewer, revalidate: () => Promise<LiveViewer | null>): LiveConnectionHandle => {
|
|
790
|
+
const conn: Connection = { socket, viewer, revalidate, subs: new Map(), pending: 0, work: Promise.resolve(), closed: false };
|
|
791
|
+
// A stopped engine starts no follower or timers again: the socket reconnects to a running replica.
|
|
792
|
+
if (stopping.signal.aborted) close(conn, 1012, "restart");
|
|
793
|
+
else {
|
|
794
|
+
start().catch((error) => log.warn("Live follower could not start", { appId, error: message(error) }));
|
|
795
|
+
const open = [...connections].filter((other) => other.viewer.id === viewer.id).length;
|
|
796
|
+
connections.add(conn);
|
|
797
|
+
if (open >= LIVE_LIMITS.socketsPerViewer) close(conn, 1013, "too_many_sockets", "Too many live sockets are open.");
|
|
798
|
+
}
|
|
799
|
+
return {
|
|
800
|
+
message: (raw) => {
|
|
801
|
+
if (conn.closed) return;
|
|
802
|
+
if (typeof raw !== "string" || raw.length > LIVE_LIMITS.frameBytes) return violation(conn, "invalid_message");
|
|
803
|
+
if (conn.pending >= LIVE_LIMITS.pendingMessages) return close(conn, 1013, "too_many_messages", "Too many live messages wait.");
|
|
804
|
+
conn.pending++;
|
|
805
|
+
conn.work = conn.work
|
|
806
|
+
.then(() => (conn.closed ? undefined : handle(conn, raw)))
|
|
807
|
+
.catch((error) => fail([conn], error, "subscription"))
|
|
808
|
+
.finally(() => {
|
|
809
|
+
conn.pending--;
|
|
810
|
+
});
|
|
811
|
+
},
|
|
812
|
+
closed: () => {
|
|
813
|
+
conn.closed = true;
|
|
814
|
+
detach(conn);
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
},
|
|
818
|
+
stop: () => {
|
|
819
|
+
stopping.abort();
|
|
820
|
+
for (const timer of timers) clearInterval(timer);
|
|
821
|
+
for (const conn of [...connections]) close(conn, 1012, "restart");
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
export type LiveEngine = ReturnType<typeof createLiveEngine>;
|