@camstack/server 1.2.269 → 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
+ }
package/dist/main.js CHANGED
@@ -58,6 +58,7 @@ const ws_1 = require("@trpc/server/adapters/ws");
58
58
  const ws_2 = require("ws");
59
59
  const addon_upload_1 = require("./api/addon-upload");
60
60
  const auth_whoami_1 = require("./api/auth-whoami");
61
+ const backup_download_js_1 = require("./api/backup-download.js");
61
62
  const health_routes_1 = require("./api/health/health.routes");
62
63
  const adaptive_probe_routes_1 = require("./api/health/adaptive-probe.routes");
63
64
  const model_distributor_js_1 = require("./api/model-distributor.js");
@@ -303,6 +304,12 @@ async function bootstrap() {
303
304
  socketPlane: (0, system_1.createSocketPlaneReader)({
304
305
  registry: () => moleculerForEventPlane?.childRegistry ?? null,
305
306
  }),
307
+ // The write queue to every peer NODE, from Moleculer's own writer
308
+ // sockets. Those are unref'd (`TcpWriter.connect`), so the 2026-09-10
309
+ // exclusion of "every socket queue" — a sum over `_getActiveHandles()` —
310
+ // never saw them. With the UDS queues above, every outbound plane this
311
+ // process writes to is now on the line and judged on every probe (D446).
312
+ meshQueue: (0, system_1.createMeshQueueReader)(() => moleculerForEventPlane?.broker ?? null),
306
313
  // The ArrayBuffer census, armed by the probe itself. Twice on 2026-09-10
307
314
  // this process's `arrayBuffers` climbed linearly for an hour (to 1.3 GB
308
315
  // and to 5 GB, both LIVE across a forced compaction) and released in one
@@ -821,89 +828,14 @@ async function bootstrap() {
821
828
  });
822
829
  }
823
830
  });
824
- // ── Backup archive download (Phase 4 / Task 24) ────────────────
825
- // Streams an archive at `<locationId>/<archiveId>` through the
826
- // storage cap's chunked-download protocol straight to an HTTP
827
- // response. The admin UI uses an authenticated `fetch` + Blob to
828
- // fetch and trigger a save dialog (no cookie auth on this server,
829
- // so we can't rely on `window.location.assign`).
830
- fastify.get('/api/backup/download/:locationId/:archiveId', async (request, reply) => {
831
- const authHeader = request.headers.authorization;
832
- if (!authHeader) {
833
- return reply.status(401).send({ error: 'Unauthorized' });
834
- }
835
- try {
836
- const token = authHeader.replace('Bearer ', '');
837
- const downloadAuth = app.get(auth_service_1.AuthService);
838
- const payload = downloadAuth.verifyToken(token);
839
- if (!payload.isAdmin) {
840
- return reply.status(403).send({ error: 'Admin required' });
841
- }
842
- }
843
- catch {
844
- return reply.status(401).send({ error: 'Invalid token' });
845
- }
846
- const { locationId, archiveId } = request.params;
847
- const backupSingleton = capabilityRegistry.getSingleton('backup');
848
- if (!backupSingleton?.listArchives) {
849
- return reply.status(503).send({ error: 'Backup orchestrator unavailable' });
850
- }
851
- const archives = await backupSingleton.listArchives({ destinationId: locationId });
852
- const archive = archives.find((a) => a.id === archiveId);
853
- if (!archive) {
854
- return reply.status(404).send({ error: 'Archive not found' });
855
- }
856
- const storage = capabilityRegistry.getSingleton('storage');
857
- if (!storage?.beginDownload) {
858
- return reply.status(503).send({ error: 'Storage cap unavailable' });
859
- }
860
- const downloadName = `${archive.label ?? archive.id}.tar.gz`;
861
- // Sanitize: strip newlines and double-quotes which would break
862
- // the Content-Disposition header. Whitespace is fine.
863
- const safeName = downloadName.replace(/[\r\n"]/g, '_');
864
- reply.header('content-type', 'application/gzip');
865
- reply.header('content-length', String(archive.sizeBytes));
866
- reply.header('content-disposition', `attachment; filename="${safeName}"`);
867
- const { downloadId, sizeBytes } = await storage.beginDownload({
868
- location: locationId,
869
- relativePath: archive.filename,
870
- });
871
- const CHUNK = 8 * 1024 * 1024;
872
- try {
873
- let offset = 0;
874
- // Stream the chunked response. Fastify's `reply.raw` is the
875
- // Node.js writable; we write each chunk and `end()` once
876
- // we've drained the source. Per-write back-pressure is
877
- // handled inside the runtime — buffered writes pile up but
878
- // never beyond the OS socket buffer.
879
- while (offset < sizeBytes) {
880
- const len = Math.min(CHUNK, sizeBytes - offset);
881
- const chunk = await storage.readChunk({ downloadId, offset, length: len });
882
- if (chunk.byteLength === 0) {
883
- throw new Error(`backup download: empty chunk at offset ${offset}/${sizeBytes}`);
884
- }
885
- reply.raw.write(chunk);
886
- offset += chunk.byteLength;
887
- }
888
- reply.raw.end();
889
- }
890
- catch (err) {
891
- console.error('[backup-download] stream failed:', err);
892
- // Headers may already be flushed — abort the socket if so.
893
- if (!reply.raw.headersSent) {
894
- return reply.status(500).send({ error: 'Download failed' });
895
- }
896
- reply.raw.destroy(err instanceof Error ? err : new Error(String(err)));
897
- }
898
- finally {
899
- try {
900
- await storage.endDownload({ downloadId });
901
- }
902
- catch {
903
- /* best-effort */
904
- }
905
- }
906
- return reply;
831
+ // ── Backup archive download ─────────────────────────────────────
832
+ // Streams an archive through the storage cap's chunked-download
833
+ // protocol at the pace the client reads it (D447). The route, its
834
+ // auth chain and the drain-respecting pump live in `api/backup-download*`.
835
+ (0, backup_download_js_1.registerBackupDownloadRoute)(fastify, {
836
+ verifier: app.get(auth_service_1.AuthService),
837
+ capabilities: capabilityRegistry,
838
+ logger: app.get(logging_service_1.LoggingService).createLogger('backup-download'),
907
839
  });
908
840
  // POST /api/auth/session — upgrade a tRPC-issued JWT to a browser cookie.
909
841
  fastify.post('/api/auth/session', async (request, reply) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.269",
3
+ "version": "1.2.270",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",