@camstack/server 1.2.269 → 1.2.271
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/dist/api/backup-download-pump.js +139 -0
- package/dist/api/backup-download.js +109 -0
- package/dist/api/trpc/generated-cap-routers.js +18 -0
- package/dist/main.js +15 -83
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -958,6 +958,24 @@ function createCapRouter_audioAnalyzer(getProvider, createRemoteProxy) {
|
|
|
958
958
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
959
959
|
return p.classify(methodInput);
|
|
960
960
|
}),
|
|
961
|
+
attachDevice: trpc_middleware_js_1.protectedProcedure
|
|
962
|
+
.input(types_15.audioAnalyzerCapability.methods.attachDevice.input.loose())
|
|
963
|
+
.output(types_15.audioAnalyzerCapability.methods.attachDevice.output)
|
|
964
|
+
.mutation(async ({ input, ctx }) => {
|
|
965
|
+
const { nodeId, ...methodInput } = input;
|
|
966
|
+
const p = resolveProvider('audio-analyzer', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
967
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
968
|
+
return p.attachDevice(methodInput);
|
|
969
|
+
}),
|
|
970
|
+
detachDevice: trpc_middleware_js_1.protectedProcedure
|
|
971
|
+
.input(types_15.audioAnalyzerCapability.methods.detachDevice.input.loose())
|
|
972
|
+
.output(types_15.audioAnalyzerCapability.methods.detachDevice.output)
|
|
973
|
+
.mutation(async ({ input, ctx }) => {
|
|
974
|
+
const { nodeId, ...methodInput } = input;
|
|
975
|
+
const p = resolveProvider('audio-analyzer', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
976
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
977
|
+
return p.detachDevice(methodInput);
|
|
978
|
+
}),
|
|
961
979
|
isReady: trpc_middleware_js_1.protectedProcedure
|
|
962
980
|
.input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
|
|
963
981
|
.output(types_15.audioAnalyzerCapability.methods.isReady.output)
|
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
|
|
825
|
-
// Streams an archive
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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) => {
|