@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.
- package/dist/api/backup-download-pump.js +139 -0
- package/dist/api/backup-download.js +109 -0
- package/dist/api/trpc/ws-slow-consumer-guard.js +166 -0
- package/dist/core/addon/addon-registry.service.js +76 -136
- package/dist/core/addon/forked-custom-actions.js +63 -0
- package/dist/core/moleculer/moleculer.service.js +31 -0
- package/dist/core/update-availability-emitter.js +6 -0
- package/dist/core/updates/update-check-scheduler.js +3 -0
- package/dist/core/updates/wrapper-update-checker.js +63 -0
- package/dist/main.js +48 -84
- package/dist/manual-boot.js +22 -0
- package/dist/server-root/index.js +131 -1
- 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
|
+
}
|
|
@@ -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
|
+
}
|