@camstack/server 1.2.268 → 1.2.270

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.
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ /**
3
+ * backup-download-pump — a backup archive is streamed at the pace the client
4
+ * READS it, and a client that stops reading is cut, never buffered until
5
+ * hub-main dies.
6
+ *
7
+ * ## What happened
8
+ *
9
+ * `/api/backup/download/:locationId/:archiveId` read an archive from the
10
+ * storage cap in 8 MiB chunks and called `reply.raw.write(chunk)` in a loop
11
+ * that never looked at the return value and never waited for `drain`. A Node
12
+ * writable accepts every write; what the socket cannot send it QUEUES, in
13
+ * hub-main's heap, until the peer reads it. On a fast disk the whole archive
14
+ * is read in seconds, so for a client on a slow link — a phone over the
15
+ * Cloudflare tunnel, a browser tab in the background — the queue is the
16
+ * archive: 464 MB across 9 169 files on this hub, per download, per client,
17
+ * released only when the socket closes. And not one line said so.
18
+ *
19
+ * ## The policy, and why this one
20
+ *
21
+ * It is the WebSocket guard's (`ws-slow-consumer-guard.ts`, D444) and the
22
+ * restreamer's (`rtsp-session.ts`): backpressure measures LIVENESS, not
23
+ * identity. Nobody is cut for being momentarily behind — a client draining an
24
+ * 8 MiB chunk over a 2 Mbit link takes half a minute per chunk and is coming
25
+ * back — and a client that cannot take a single socket buffer in
26
+ * {@link BACKUP_DOWNLOAD_DRAIN_GRACE_MS} is not behind, it is gone.
27
+ *
28
+ * The form differs from the subscription guard in one way that matters: the
29
+ * respect for `drain` does the bounding on its own. A download is a long,
30
+ * legitimate flow, not a subscription, so the loop simply does not READ the
31
+ * next chunk until the socket has taken the previous one — the most the
32
+ * response can hold is one chunk plus the socket buffer, whatever the client's
33
+ * speed. The grace exists only for the peer that stops reading altogether:
34
+ * a half-open TCP connection can sit in that state for the kernel's whole
35
+ * retransmit budget (minutes), holding the chunk, the storage cap's download
36
+ * handle and the request, and `drain` never comes.
37
+ *
38
+ * ## What is said
39
+ *
40
+ * `pumpDownload` returns what happened in numbers — bytes, waits, the longest
41
+ * wait, how long a cut client was held — and the route turns that into ONE
42
+ * line per download: `info` for a completed or client-abandoned transfer,
43
+ * `warn` for a cut client or a failed source. The route previously logged
44
+ * nothing on success and `console.error` on failure.
45
+ */
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.BACKUP_DOWNLOAD_DRAIN_GRACE_MS = exports.BACKUP_DOWNLOAD_CHUNK_BYTES = void 0;
48
+ exports.pumpDownload = pumpDownload;
49
+ /** Bytes read from the storage cap per round trip; one chunk is the most the response holds. */
50
+ exports.BACKUP_DOWNLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
51
+ /**
52
+ * How long a `write` may stay unacknowledged by `drain` before the client is
53
+ * cut. A `ServerResponse` signals `drain` once the socket has taken its
54
+ * buffer (16 KiB by default): a client that cannot take 16 KiB in a minute is
55
+ * under 0.3 KB/s — not a slow link, an absent reader.
56
+ */
57
+ exports.BACKUP_DOWNLOAD_DRAIN_GRACE_MS = 60_000;
58
+ function waitForDrain(sink, graceMs) {
59
+ return new Promise((resolve) => {
60
+ const settle = (outcome) => {
61
+ sink.off('drain', onDrain);
62
+ sink.off('close', onClose);
63
+ clearTimeout(timer);
64
+ resolve(outcome);
65
+ };
66
+ const onDrain = () => settle('drained');
67
+ const onClose = () => settle('closed');
68
+ const timer = setTimeout(() => settle('timeout'), graceMs);
69
+ sink.on('drain', onDrain);
70
+ sink.on('close', onClose);
71
+ });
72
+ }
73
+ function errorOf(err) {
74
+ return err instanceof Error ? err : new Error(String(err));
75
+ }
76
+ /**
77
+ * Stream `sizeBytes` from `read` into `sink`, one chunk at a time, reading the
78
+ * next only once the socket has taken the previous. Never throws: every exit
79
+ * is a {@link PumpResult} the caller can log.
80
+ */
81
+ async function pumpDownload(options) {
82
+ const { sizeBytes, read, sink } = options;
83
+ const chunkBytes = options.chunkBytes ?? exports.BACKUP_DOWNLOAD_CHUNK_BYTES;
84
+ const graceMs = options.graceMs ?? exports.BACKUP_DOWNLOAD_DRAIN_GRACE_MS;
85
+ const now = options.now ?? (() => Date.now());
86
+ const startedAt = now();
87
+ let offset = 0;
88
+ let drainWaits = 0;
89
+ let drainWaitMaxMs = 0;
90
+ const counters = () => ({
91
+ bytesWritten: offset,
92
+ drainWaits,
93
+ drainWaitMaxMs,
94
+ durationMs: now() - startedAt,
95
+ });
96
+ while (offset < sizeBytes) {
97
+ if (sink.destroyed)
98
+ return { status: 'peer-gone', ...counters() };
99
+ const length = Math.min(chunkBytes, sizeBytes - offset);
100
+ let chunk;
101
+ try {
102
+ chunk = await read(offset, length);
103
+ }
104
+ catch (err) {
105
+ const error = errorOf(err);
106
+ sink.destroy(error);
107
+ return { status: 'source-failed', error: error.message, ...counters() };
108
+ }
109
+ if (chunk.byteLength === 0) {
110
+ // A short read before the end is a truncated archive, never "done":
111
+ // `end()` here would hand the client a file that fails to extract.
112
+ const error = new Error(`backup download: empty chunk at offset ${offset}/${sizeBytes}`);
113
+ sink.destroy(error);
114
+ return { status: 'source-failed', error: error.message, ...counters() };
115
+ }
116
+ // The peer may have gone while the storage cap was answering.
117
+ if (sink.destroyed)
118
+ return { status: 'peer-gone', ...counters() };
119
+ const accepted = sink.write(chunk);
120
+ offset += chunk.byteLength;
121
+ if (accepted)
122
+ continue;
123
+ const waitStartedAt = now();
124
+ const outcome = await waitForDrain(sink, graceMs);
125
+ const waitedMs = now() - waitStartedAt;
126
+ drainWaits += 1;
127
+ if (waitedMs > drainWaitMaxMs)
128
+ drainWaitMaxMs = waitedMs;
129
+ if (outcome === 'closed')
130
+ return { status: 'peer-gone', ...counters() };
131
+ if (outcome === 'timeout') {
132
+ const queuedBytes = sink.writableLength;
133
+ sink.destroy(new Error(`backup download: client stopped reading — ${queuedBytes} bytes queued for ${waitedMs} ms`));
134
+ return { status: 'drain-timeout', heldForMs: waitedMs, queuedBytes, ...counters() };
135
+ }
136
+ }
137
+ sink.end();
138
+ return { status: 'completed', ...counters() };
139
+ }
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerBackupDownloadRoute = registerBackupDownloadRoute;
4
+ const backup_download_pump_js_1 = require("./backup-download-pump.js");
5
+ const client_ip_js_1 = require("./trpc/client-ip.js");
6
+ /** Strip what would break the `Content-Disposition` header. Whitespace is fine. */
7
+ function safeAttachmentName(name) {
8
+ return name.replace(/[\r\n"]/g, '_');
9
+ }
10
+ function registerBackupDownloadRoute(fastify, options) {
11
+ const { verifier, capabilities, logger } = options;
12
+ const chunkBytes = options.chunkBytes ?? backup_download_pump_js_1.BACKUP_DOWNLOAD_CHUNK_BYTES;
13
+ const graceMs = options.graceMs ?? backup_download_pump_js_1.BACKUP_DOWNLOAD_DRAIN_GRACE_MS;
14
+ fastify.get('/api/backup/download/:locationId/:archiveId', async (request, reply) => {
15
+ const authHeader = request.headers.authorization;
16
+ if (!authHeader) {
17
+ return reply.status(401).send({ error: 'Unauthorized' });
18
+ }
19
+ try {
20
+ const payload = verifier.verifyToken(authHeader.replace('Bearer ', ''));
21
+ if (!payload.isAdmin) {
22
+ return reply.status(403).send({ error: 'Admin required' });
23
+ }
24
+ }
25
+ catch {
26
+ return reply.status(401).send({ error: 'Invalid token' });
27
+ }
28
+ const { locationId, archiveId } = request.params;
29
+ const backup = capabilities.getSingleton('backup');
30
+ if (!backup?.listArchives) {
31
+ return reply.status(503).send({ error: 'Backup orchestrator unavailable' });
32
+ }
33
+ const archives = await backup.listArchives({ destinationId: locationId });
34
+ const archive = archives.find((a) => a.id === archiveId);
35
+ if (!archive) {
36
+ return reply.status(404).send({ error: 'Archive not found' });
37
+ }
38
+ const storage = capabilities.getSingleton('storage');
39
+ if (!storage?.beginDownload) {
40
+ return reply.status(503).send({ error: 'Storage cap unavailable' });
41
+ }
42
+ const { downloadId, sizeBytes } = await storage.beginDownload({
43
+ location: locationId,
44
+ relativePath: archive.filename,
45
+ });
46
+ // The response is written on the raw socket, so it is taken out of
47
+ // Fastify's hands and the headers go on the raw response. The previous
48
+ // route set them through `reply.header(…)` — which Fastify applies only
49
+ // on `reply.send()`, never reached here — so no download ever carried a
50
+ // `content-length` or a `content-disposition`. `sizeBytes` is the storage
51
+ // cap's figure for the file it is about to serve, not the catalogue's.
52
+ reply.hijack();
53
+ reply.raw.writeHead(200, {
54
+ 'content-type': 'application/gzip',
55
+ 'content-length': String(sizeBytes),
56
+ 'content-disposition': `attachment; filename="${safeAttachmentName(`${archive.label ?? archive.id}.tar.gz`)}"`,
57
+ });
58
+ const facts = {
59
+ locationId,
60
+ archiveId,
61
+ filename: archive.filename,
62
+ sizeBytes,
63
+ ip: (0, client_ip_js_1.extractClientIp)(request.raw) ?? '(unidentified)',
64
+ chunkBytes,
65
+ graceMs,
66
+ };
67
+ let result;
68
+ try {
69
+ result = await (0, backup_download_pump_js_1.pumpDownload)({
70
+ sizeBytes,
71
+ chunkBytes,
72
+ graceMs,
73
+ sink: reply.raw,
74
+ read: (offset, length) => storage.readChunk({ downloadId, offset, length }),
75
+ });
76
+ }
77
+ finally {
78
+ try {
79
+ await storage.endDownload({ downloadId });
80
+ }
81
+ catch (err) {
82
+ logger.warn('backup archive download: the storage cap refused to close the handle', {
83
+ meta: { ...facts, error: err instanceof Error ? err.message : String(err) },
84
+ });
85
+ }
86
+ }
87
+ logOutcome(logger, facts, result);
88
+ });
89
+ }
90
+ function logOutcome(logger, facts, result) {
91
+ const meta = { ...facts, ...result };
92
+ switch (result.status) {
93
+ case 'completed':
94
+ logger.info('backup archive download completed', { meta });
95
+ return;
96
+ case 'peer-gone':
97
+ logger.info('backup archive download abandoned — the client closed the connection', { meta });
98
+ return;
99
+ case 'drain-timeout':
100
+ logger.warn('backup archive download cut — the client stopped reading and the response queue ' +
101
+ 'stayed unsent for the whole grace period (nothing more is read; the queue is released)', { meta });
102
+ return;
103
+ case 'source-failed':
104
+ logger.warn('backup archive download failed — the storage cap could not serve the archive', {
105
+ meta,
106
+ });
107
+ return;
108
+ }
109
+ }
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WsSlowConsumerGuard = exports.WS_SLOW_CONSUMER_POLL_MS = exports.WS_SLOW_CONSUMER_GRACE_MS = exports.WS_SLOW_CONSUMER_MAX_BUFFERED_BYTES = void 0;
4
+ exports.wsConnectionFacts = wsConnectionFacts;
5
+ const client_ip_js_1 = require("./client-ip.js");
6
+ /** Queued bytes above which a client is a slow consumer. */
7
+ exports.WS_SLOW_CONSUMER_MAX_BUFFERED_BYTES = 16 * 1024 * 1024;
8
+ /** How long the queue may stay above the bound before the socket is terminated. */
9
+ exports.WS_SLOW_CONSUMER_GRACE_MS = 30_000;
10
+ /** How often every connection's queue is read. A getter per socket; free. */
11
+ exports.WS_SLOW_CONSUMER_POLL_MS = 5_000;
12
+ /** WebSocket close code for a socket that was never closed cleanly. */
13
+ const ABNORMAL_CLOSURE = 1006;
14
+ const UNIDENTIFIED = '(unidentified)';
15
+ class WsSlowConsumerGuard {
16
+ connections = new Set();
17
+ bySocket = new WeakMap();
18
+ logger;
19
+ maxBufferedBytes;
20
+ graceMs;
21
+ pollMs;
22
+ now;
23
+ timer = null;
24
+ constructor(options) {
25
+ this.logger = options.logger;
26
+ this.maxBufferedBytes = options.maxBufferedBytes ?? exports.WS_SLOW_CONSUMER_MAX_BUFFERED_BYTES;
27
+ this.graceMs = options.graceMs ?? exports.WS_SLOW_CONSUMER_GRACE_MS;
28
+ this.pollMs = options.pollMs ?? exports.WS_SLOW_CONSUMER_POLL_MS;
29
+ this.now = options.now ?? (() => Date.now());
30
+ }
31
+ /** Watch one connection until it closes. */
32
+ attach(socket, facts) {
33
+ const connection = {
34
+ socket,
35
+ facts,
36
+ openedAt: this.now(),
37
+ principal: UNIDENTIFIED,
38
+ overSince: null,
39
+ peakBufferedBytes: 0,
40
+ terminated: false,
41
+ };
42
+ this.connections.add(connection);
43
+ this.bySocket.set(socket, connection);
44
+ socket.once('close', (code, reason) => {
45
+ this.connections.delete(connection);
46
+ this.logger.info('tRPC WS connection closed', {
47
+ meta: {
48
+ ...this.describe(connection),
49
+ code,
50
+ reason: reason.length > 0 ? reason.toString('utf8') : '',
51
+ closedBy: connection.terminated
52
+ ? 'slow-consumer-guard'
53
+ : code === ABNORMAL_CLOSURE
54
+ ? 'peer gone or keep-alive PING unanswered'
55
+ : 'peer',
56
+ },
57
+ });
58
+ });
59
+ }
60
+ /** Name the connection's principal once the WS context resolved it. */
61
+ identify(socket, principal) {
62
+ const connection = this.bySocket.get(socket);
63
+ if (connection !== undefined)
64
+ connection.principal = principal;
65
+ }
66
+ /** Read every queue once. Exposed for the spec; production runs it on {@link start}. */
67
+ poll() {
68
+ const at = this.now();
69
+ for (const connection of this.connections) {
70
+ try {
71
+ this.judge(connection, at);
72
+ }
73
+ catch (err) {
74
+ // A diagnostic must never take down what it guards; say so instead.
75
+ this.logger.warn('tRPC WS slow-consumer guard could not read a socket', {
76
+ meta: { ...this.describe(connection), error: errorMessage(err) },
77
+ });
78
+ }
79
+ }
80
+ }
81
+ start() {
82
+ if (this.timer !== null)
83
+ return;
84
+ this.timer = setInterval(() => this.poll(), this.pollMs);
85
+ this.timer.unref?.();
86
+ }
87
+ stop() {
88
+ if (this.timer === null)
89
+ return;
90
+ clearInterval(this.timer);
91
+ this.timer = null;
92
+ }
93
+ judge(connection, at) {
94
+ if (connection.terminated)
95
+ return;
96
+ const buffered = connection.socket.bufferedAmount;
97
+ if (buffered > connection.peakBufferedBytes)
98
+ connection.peakBufferedBytes = buffered;
99
+ if (buffered <= this.maxBufferedBytes) {
100
+ if (connection.overSince !== null) {
101
+ // It came back: say so, so a merely slow client is visible and not confused
102
+ // with the ones that are terminated.
103
+ this.logger.info('tRPC WS client drained a backlog that had crossed the bound', {
104
+ meta: {
105
+ ...this.describe(connection),
106
+ overForMs: at - connection.overSince,
107
+ bufferedBytes: buffered,
108
+ },
109
+ });
110
+ connection.overSince = null;
111
+ connection.peakBufferedBytes = buffered;
112
+ }
113
+ return;
114
+ }
115
+ connection.overSince ??= at;
116
+ const overForMs = at - connection.overSince;
117
+ if (overForMs < this.graceMs)
118
+ return;
119
+ connection.terminated = true;
120
+ this.logger.warn('tRPC WS client disconnected — slow consumer: it stopped reading and its send queue ' +
121
+ 'stayed above the bound for the whole grace period (a backgrounded viewer keeps the ' +
122
+ 'socket open with its JS suspended; the queue lived in hub-main old_space)', {
123
+ meta: {
124
+ ...this.describe(connection),
125
+ bufferedBytes: buffered,
126
+ overForMs,
127
+ boundBytes: this.maxBufferedBytes,
128
+ graceMs: this.graceMs,
129
+ },
130
+ });
131
+ connection.socket.terminate();
132
+ }
133
+ describe(connection) {
134
+ return {
135
+ ip: connection.facts.ip ?? UNIDENTIFIED,
136
+ userAgent: connection.facts.userAgent ?? UNIDENTIFIED,
137
+ principal: connection.principal,
138
+ bytesRead: safeCount(connection.facts.bytesRead),
139
+ bytesWritten: safeCount(connection.facts.bytesWritten),
140
+ peakBufferedBytes: connection.peakBufferedBytes,
141
+ connectionAgeMs: this.now() - connection.openedAt,
142
+ };
143
+ }
144
+ }
145
+ exports.WsSlowConsumerGuard = WsSlowConsumerGuard;
146
+ function safeCount(read) {
147
+ try {
148
+ return read();
149
+ }
150
+ catch {
151
+ // A destroyed socket may have dropped its handle; null is "unknown", never 0.
152
+ return null;
153
+ }
154
+ }
155
+ function errorMessage(err) {
156
+ return err instanceof Error ? err.message : String(err);
157
+ }
158
+ /** The facts a real `ws` upgrade request provides. */
159
+ function wsConnectionFacts(req) {
160
+ return {
161
+ ip: (0, client_ip_js_1.extractClientIp)(req),
162
+ userAgent: (0, client_ip_js_1.extractUserAgent)(req),
163
+ bytesRead: () => req.socket.bytesRead,
164
+ bytesWritten: () => req.socket.bytesWritten,
165
+ };
166
+ }