@torrent-tv/proxy 2.9.107 → 2.9.109

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.
@@ -1,542 +1,638 @@
1
- /**
2
- * @file WebRTC data channel request handler (proxy side).
3
- *
4
- * When a browser opens a data channel to this proxy, this handler wires up
5
- * message handlers that implement an HTTP-over-DataChannel protocol:
6
- * each incoming `request` message triggers a local `fetch` to the Fastify
7
- * server, and the response is streamed back as base64-encoded chunks.
8
- *
9
- * ## Wire protocol
10
- *
11
- * Browser → Proxy
12
- * ```
13
- * { type: "request", requestId, method, path, query, headers, body }
14
- * { type: "ping", id }
15
- * ```
16
- *
17
- * Proxy → Browser
18
- * ```
19
- * { type: "response-start", requestId, status, headers } (JSON string)
20
- * { type: "response-error", requestId, error: string } (JSON string)
21
- * { type: "pong", id } (JSON string)
22
- * ```
23
- *
24
- * Response bodies are sent as BINARY data-channel messages (not JSON), to
25
- * avoid the ~33% base64 overhead and the JSON encode/decode cost. Each binary
26
- * frame is laid out as:
27
- * ```
28
- * byte 0 flags (bit 0: done)
29
- * byte 1 idLen (length of the requestId in bytes)
30
- * bytes 2..2+N requestId (ASCII)
31
- * bytes 2+N.. payload (raw body bytes; empty on the final done frame)
32
- * ```
33
- * Control messages stay JSON strings so the browser can distinguish them from
34
- * body frames by message type (string vs ArrayBuffer).
35
- *
36
- * The protocol mirrors the tunnel relay protocol so both transports share
37
- * the same mental model and the same browser-side `WebRtcProxy` implementation.
38
- */
39
-
40
- /** @import { DataChannel } from 'node-datachannel' */
41
-
42
- /**
43
- * Configuration for the data channel handler.
44
- *
45
- * @typedef {Object} DataChannelHandlerOptions
46
- * @property {number} proxyPort
47
- * Local port the proxy's Fastify HTTP server is listening on.
48
- * Incoming requests are forwarded to `http://127.0.0.1:{proxyPort}`.
49
- * @property {(message: string) => void} [onLog]
50
- * Optional log sink.
51
- */
52
-
53
- /**
54
- * An incoming request message received over the data channel.
55
- *
56
- * @typedef {Object} DataChannelRequest
57
- * @property {string} requestId
58
- * @property {string} method - HTTP method (GET, POST, …).
59
- * @property {string} path - Request path (e.g. "/api/sources").
60
- * @property {string} query - Raw query string without the leading "?".
61
- * @property {Record<string, string>} headers - Headers to forward.
62
- * @property {string | null} body - Request body string, or null.
63
- */
64
-
65
- /**
66
- * The object returned by {@link createDataChannelHandler}.
67
- *
68
- * @typedef {Object} DataChannelHandler
69
- * @property {(sessionId: string, channel: DataChannel) => void} handleChannel
70
- * Wire message handlers onto a freshly opened data channel.
71
- */
72
-
73
- /**
74
- * Create a handler for incoming WebRTC data channels.
75
- *
76
- * @param {DataChannelHandlerOptions} options
77
- * @returns {DataChannelHandler}
78
- */
79
- import { performance } from "node:perf_hooks";
80
- import { eventLoopDelay, resetEventLoopDelay } from "../utils/perf.js";
81
-
82
- /**
83
- * Build one body frame: `[flags(1)][idLen(1)][requestId][payload]`.
84
- *
85
- * One allocation and one copy. The previous version made two of each — a copy
86
- * of the chunk into a `Buffer`, then a `concat` that copied it again into the
87
- * frame which measured 75.9 ms per 13 MB segment on the field host against
88
- * 40.0 ms this way, and allocated ~600 extra buffers over a segment's 208
89
- * chunks. One copy is the floor: chunks arrive from a web stream that allocates
90
- * them itself, so there is no buffer of ours to read them into.
91
- *
92
- * @param {Buffer} idBytes - The request id, already encoded.
93
- * @param {Uint8Array | null} bytes - Payload, or nothing for the done frame.
94
- * @param {boolean} done
95
- * @returns {Buffer}
96
- */
97
- export function encodeFrame(idBytes, bytes, done) {
98
- const payloadLength = bytes?.length ?? 0;
99
- const frame = Buffer.allocUnsafe(2 + idBytes.length + payloadLength);
100
- frame[0] = done ? 1 : 0;
101
- frame[1] = idBytes.length;
102
- idBytes.copy(frame, 2);
103
- if (payloadLength > 0) {
104
- frame.set(bytes, 2 + idBytes.length);
105
- }
106
- return frame;
107
- }
108
-
109
- export function createDataChannelHandler({ proxyPort, onLog }) {
110
- /** Request id → its ASCII bytes; see {@link requestIdBytes}. */
111
- const requestIdCache = new Map();
112
-
113
- /**
114
- * @param {string} message
115
- * @returns {void}
116
- */
117
- function log(message) {
118
- if (typeof onLog === "function") {
119
- onLog(message);
120
- }
121
- }
122
-
123
- /**
124
- * Wire up the `onMessage`, `onClosed`, and `onError` handlers for a channel.
125
- *
126
- * @param {string} sessionId
127
- * @param {DataChannel} channel
128
- * @returns {void}
129
- */
130
- function handleChannel(sessionId, channel) {
131
- const tag = sessionId.slice(0, 8);
132
- log(`[dc] Session ${tag}: channel open`);
133
-
134
- // Partial chunked-request bodies in flight on THIS channel, keyed by
135
- // requestId. Each entry buffers frames until the done frame, then runs the
136
- // assembled request through the same path as a single-message request.
137
- /** @type {Map<string, { meta: object, chunks: Buffer[], receivedBytes: number, bodyBytes: number, timer: ReturnType<typeof setTimeout> }>} */
138
- const partials = new Map();
139
-
140
- const dropPartial = (requestId) => {
141
- const entry = partials.get(requestId);
142
- if (entry) {
143
- clearTimeout(entry.timer);
144
- partials.delete(requestId);
145
- }
146
- };
147
-
148
- /**
149
- * Begin assembling a chunked request. Validates the path and size up front
150
- * so an invalid or oversized request never buffers a body.
151
- *
152
- * @param {any} message - The `request-start` control message.
153
- */
154
- const startPartialRequest = (message) => {
155
- const { requestId, method, path, query, headers, bodyBytes } = message ?? {};
156
- if (typeof requestId !== "string" || requestId.length === 0) {
157
- return;
158
- }
159
- if (!isValidRequestPath(path)) {
160
- send(channel, { type: "response-error", requestId, error: "Invalid request path." });
161
- return;
162
- }
163
- if (!Number.isInteger(bodyBytes) || bodyBytes < 0 || bodyBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
164
- send(channel, { type: "response-error", requestId, error: "Request body too large." });
165
- return;
166
- }
167
- dropPartial(requestId); // replace any stale entry with the same id
168
- const timer = setTimeout(() => {
169
- const entry = partials.get(requestId);
170
- partials.delete(requestId);
171
- log(`[dc] Session ${tag}: dropped stale partial request ${requestId.slice(0, 8)} (${entry?.receivedBytes ?? 0}B)`);
172
- }, PARTIAL_REQUEST_TTL_MS);
173
- partials.set(requestId, {
174
- meta: { requestId, method, path, query, headers },
175
- chunks: [],
176
- receivedBytes: 0,
177
- bodyBytes,
178
- timer
179
- });
180
- };
181
-
182
- /**
183
- * Handle a binary body frame for a chunked request.
184
- * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
185
- *
186
- * @param {Buffer} buf
187
- */
188
- const handleBodyFrame = (buf) => {
189
- if (buf.length < 2) {
190
- return;
191
- }
192
- const flags = buf[0];
193
- const idLen = buf[1];
194
- if (buf.length < 2 + idLen) {
195
- return;
196
- }
197
- const requestId = buf.toString("ascii", 2, 2 + idLen);
198
- const entry = partials.get(requestId);
199
- if (!entry) {
200
- return; // stale / already-dropped / aborted
201
- }
202
- if (flags & 2) {
203
- // Aborted by the browser — drop silently, no reply.
204
- dropPartial(requestId);
205
- return;
206
- }
207
- if (buf.length > 2 + idLen) {
208
- const payload = buf.subarray(2 + idLen);
209
- entry.chunks.push(Buffer.from(payload));
210
- entry.receivedBytes += payload.length;
211
- }
212
- if (entry.receivedBytes > entry.bodyBytes || entry.receivedBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
213
- dropPartial(requestId);
214
- send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
215
- return;
216
- }
217
- if (flags & 1) {
218
- // Done frame assemble and execute.
219
- dropPartial(requestId);
220
- if (entry.receivedBytes !== entry.bodyBytes) {
221
- send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
222
- return;
223
- }
224
- const body = Buffer.concat(entry.chunks).toString("utf8");
225
- void handleRequest(channel, { ...entry.meta, body }, true).catch((error) => {
226
- log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
227
- });
228
- }
229
- };
230
-
231
- channel.onMessage((raw) => {
232
- // Binary messages are chunked-request body frames; the proxy otherwise
233
- // only ever receives JSON strings, so the type discriminates cleanly.
234
- if (typeof raw !== "string") {
235
- handleBodyFrame(Buffer.isBuffer(raw) ? raw : Buffer.from(raw));
236
- return;
237
- }
238
-
239
- /** @type {DataChannelRequest | { type: string, id?: string }} */
240
- let message;
241
- try {
242
- message = JSON.parse(raw);
243
- } catch {
244
- return;
245
- }
246
-
247
- if (message.type === "request") {
248
- void handleRequest(channel, message).catch((error) => {
249
- log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
250
- });
251
- return;
252
- }
253
-
254
- if (message.type === "request-start") {
255
- startPartialRequest(message);
256
- return;
257
- }
258
-
259
- if (message.type === "ping") {
260
- send(channel, { type: "pong", id: message.id });
261
- }
262
- });
263
-
264
- channel.onClosed(() => {
265
- for (const entry of partials.values()) {
266
- clearTimeout(entry.timer);
267
- }
268
- partials.clear();
269
- log(`[dc] Session ${tag}: channel closed`);
270
- });
271
-
272
- channel.onError((err) => {
273
- log(`[dc] Session ${tag}: channel error: ${err}`);
274
- });
275
- }
276
-
277
- /**
278
- * Fetch a resource from the local proxy HTTP server and stream the response
279
- * back to the browser over the data channel.
280
- *
281
- * The `Host` header is rewritten to `127.0.0.1:{proxyPort}` so that Fastify
282
- * routes the request correctly regardless of what the browser sent.
283
- *
284
- * @param {DataChannel} channel
285
- * @param {DataChannelRequest} req
286
- * @returns {Promise<void>}
287
- */
288
- async function handleRequest(channel, req, viaChunks = false) {
289
- const { requestId, method, path, query, headers: forwardedHeaders, body } = req;
290
-
291
- // Reject paths that are not absolute, contain traversal sequences, or
292
- // do not start with a known proxy route prefix. All valid browser-side
293
- // requests use /api/*, /stream, /transcode/*, /health, or /healthz.
294
- if (!isValidRequestPath(path)) {
295
- send(channel, { type: "response-error", requestId, error: "Invalid request path." });
296
- return;
297
- }
298
-
299
- const queryInfo = query ? `?${query}` : "";
300
- const bodyInfo =
301
- body != null && typeof body === "string" && body.length > 0
302
- ? ` body=${body.length} bytes${viaChunks ? " (chunked)" : ""}`
303
- : "";
304
- log(`[dc] ${method} ${path}${queryInfo}${bodyInfo}`);
305
-
306
- const targetUrl = `http://127.0.0.1:${proxyPort}${path}${query ? `?${query}` : ""}`;
307
- const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
308
-
309
- let response;
310
- // [net-debug] TEMPORARY: time spent in the local fetch (waiting for the
311
- // route to return a response — e.g. long-polling until an HLS segment is
312
- // finalized by ffmpeg) vs. the body transfer over the data channel.
313
- const fetchStartedAt = Date.now();
314
- try {
315
- response = await fetch(targetUrl, {
316
- method,
317
- headers: requestHeaders,
318
- body: body != null ? body : undefined,
319
- redirect: "manual"
320
- });
321
- } catch (fetchError) {
322
- log(`[dc] ${method} ${path}${queryInfo} error: ${fetchError?.message ?? String(fetchError)}`);
323
- send(channel, { type: "response-error", requestId, error: fetchError?.message ?? String(fetchError) });
324
- return;
325
- }
326
-
327
- if (response.status !== 200 && response.status !== 206) {
328
- log(`[dc] ${method} ${path}${queryInfo} → ${response.status}`);
329
- }
330
-
331
- /** @type {Record<string, string>} */
332
- const responseHeaders = {};
333
- for (const [name, value] of response.headers.entries()) {
334
- responseHeaders[name] = value;
335
- }
336
-
337
- send(channel, { type: "response-start", requestId, status: response.status, headers: responseHeaders });
338
-
339
- if (!response.body) {
340
- sendChunk(channel, requestId, null, true);
341
- return;
342
- }
343
-
344
- try {
345
- const reader = response.body.getReader();
346
- // [net-debug] TEMPORARY: measure transfer size/time and channel buffering.
347
- // fetchMs = time waiting for the route (incl. ffmpeg segment finalization).
348
- // ttfbMs = time from body-read start to the first chunk with data (loopback).
349
- // sendMs = total body read+send duration over the data channel.
350
- const fetchMs = Date.now() - fetchStartedAt;
351
- const sendStartedAt = Date.now();
352
- let firstByteMs = -1;
353
- let chunks = 0;
354
- let totalBytes = 0;
355
- let maxBuffered = 0;
356
- // Attribute the transfer to the step that actually consumes the time.
357
- // Without this split a slow transfer is indistinguishable between "the
358
- // source is slow", "the channel is slow" and "the event loop is blocked",
359
- // which is exactly the argument a field seek left unresolved.
360
- let readMs = 0;
361
- let sendMs2 = 0;
362
- let drainMs = 0;
363
- resetEventLoopDelay();
364
- while (true) {
365
- const readStartedAt = performance.now();
366
- const { done, value } = await reader.read();
367
- readMs += performance.now() - readStartedAt;
368
- if (done) {
369
- sendChunk(channel, requestId, null, true);
370
- const elapsedMs = Date.now() - sendStartedAt;
371
- let bufferedNow = 0;
372
- try { bufferedNow = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0; } catch { /* ignore */ }
373
- const loop = eventLoopDelay();
374
- const mbps = elapsedMs > 0 ? (totalBytes * 8) / (elapsedMs * 1000) : 0;
375
- log(
376
- `[net-debug] sent ${path}${queryInfo} bytes=${totalBytes} fetchMs=${fetchMs} ` +
377
- `ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
378
- `maxBuffered=${maxBuffered} bufferedAtEnd=${bufferedNow} ` +
379
- // Where the time went: reading the body from the local route,
380
- // handing chunks to the channel, or waiting for its queue. Plus
381
- // the event-loop delay over the same window a large max here
382
- // means the transfer was blocked by synchronous work, not by the
383
- // network, and the three figures above will all look inflated.
384
- `readMs=${readMs.toFixed(0)} chanMs=${sendMs2.toFixed(0)} drainMs=${drainMs.toFixed(0)} ` +
385
- `loopMean=${loop.meanMs.toFixed(1)} loopP99=${loop.p99Ms.toFixed(1)} loopMax=${loop.maxMs.toFixed(1)} ` +
386
- `rate=${mbps.toFixed(1)}Mbps`
387
- );
388
- break;
389
- }
390
- if (firstByteMs < 0) firstByteMs = Date.now() - sendStartedAt;
391
- chunks += 1;
392
- totalBytes += value.length;
393
- try {
394
- const b = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
395
- if (b > maxBuffered) maxBuffered = b;
396
- } catch { /* ignore */ }
397
- const sendStepAt = performance.now();
398
- sendChunk(channel, requestId, value, false);
399
- sendMs2 += performance.now() - sendStepAt;
400
- // Backpressure: do not keep queuing chunks once the channel's outgoing
401
- // buffer is large wait for it to drain. Prevents the SCTP send buffer
402
- // from ballooning, which stalls throughput.
403
- const drainStepAt = performance.now();
404
- await waitForBufferDrain(channel);
405
- drainMs += performance.now() - drainStepAt;
406
- }
407
- } catch {
408
- sendChunk(channel, requestId, null, true);
409
- }
410
- }
411
-
412
- /**
413
- * Send a response body frame as a BINARY data-channel message.
414
- * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
415
- *
416
- * @param {DataChannel} channel
417
- * @param {string} requestId
418
- * @param {Uint8Array | null} bytes - Body bytes, or null/empty for the done frame.
419
- * @param {boolean} done
420
- * @returns {void}
421
- */
422
- /**
423
- * The request id as bytes, prepared once per request rather than per chunk.
424
- *
425
- * A segment is a couple of hundred chunks, and each one was re-encoding the
426
- * same 32-character string. The map is bounded because request ids are
427
- * short-lived and unbounded in number — dropping the whole cache when it
428
- * grows costs one re-encode per live request and cannot leak.
429
- *
430
- * @param {string} requestId
431
- * @returns {Buffer}
432
- */
433
- function requestIdBytes(requestId) {
434
- let bytes = requestIdCache.get(requestId);
435
- if (!bytes) {
436
- if (requestIdCache.size > 64) {
437
- requestIdCache.clear();
438
- }
439
- bytes = Buffer.from(requestId, "ascii");
440
- requestIdCache.set(requestId, bytes);
441
- }
442
- return bytes;
443
- }
444
-
445
- function sendChunk(channel, requestId, bytes, done) {
446
- try {
447
- channel.sendMessageBinary(encodeFrame(requestIdBytes(requestId), bytes, done));
448
- } catch {
449
- // Channel closed between check and send — safe to ignore.
450
- }
451
- }
452
-
453
- /**
454
- * Resolve once the channel's outgoing buffer has drained below the low-water
455
- * mark. No-op (resolves immediately) when the buffer is already small or the
456
- * channel does not expose buffer APIs. A timeout fallback guards against a
457
- * missed low-water event so the send loop can never deadlock.
458
- *
459
- * @param {DataChannel} channel
460
- * @returns {Promise<void>}
461
- */
462
- function waitForBufferDrain(channel) {
463
- return new Promise((resolve) => {
464
- try {
465
- if (typeof channel.bufferedAmount !== "function" || channel.bufferedAmount() <= DC_BUFFER_HIGH_WATER) {
466
- resolve();
467
- return;
468
- }
469
- let settled = false;
470
- const done = () => {
471
- if (settled) return;
472
- settled = true;
473
- resolve();
474
- };
475
- channel.setBufferedAmountLowThreshold(DC_BUFFER_LOW_WATER);
476
- channel.onBufferedAmountLow(done);
477
- // Guard against a race where the buffer drained between the check above
478
- // and registering the callback (the low-water event would never fire).
479
- if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
480
- done();
481
- return;
482
- }
483
- setTimeout(done, DC_BUFFER_DRAIN_TIMEOUT_MS);
484
- } catch {
485
- resolve();
486
- }
487
- });
488
- }
489
-
490
- /**
491
- * Serialise `message` to JSON and send it over the data channel.
492
- * Errors are silently swallowed — the channel may have closed between
493
- * the open check and the actual send.
494
- *
495
- * @param {DataChannel} channel
496
- * @param {object} message
497
- * @returns {void}
498
- */
499
- function send(channel, message) {
500
- try {
501
- channel.sendMessage(JSON.stringify(message));
502
- } catch {
503
- // Channel closed between check and send — safe to ignore.
504
- }
505
- }
506
-
507
- return { handleChannel };
508
- }
509
-
510
- /**
511
- * Allowed path prefixes for data-channel requests.
512
- * Only the known proxy API and streaming routes are accepted.
513
- */
514
- const PATH_ALLOWLIST_RE = /^(?:\/api\/|\/stream(?:$|\?)|\/?transcode\/|\/health(?:z)?(?:$|\?))/;
515
-
516
- /**
517
- * True when `path` is an absolute, traversal-free path on a known proxy route.
518
- * Shared by the single-message and chunked request entry points.
519
- *
520
- * @param {unknown} path
521
- * @returns {boolean}
522
- */
523
- function isValidRequestPath(path) {
524
- return (
525
- typeof path === "string" &&
526
- path.startsWith("/") &&
527
- !path.includes("..") &&
528
- PATH_ALLOWLIST_RE.test(path)
529
- );
530
- }
531
-
532
- /** Max assembled size of a chunked request body (guards proxy memory). */
533
- const PROXY_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024;
534
- /** Drop an incomplete chunked body if no further frame arrives within this window. */
535
- const PARTIAL_REQUEST_TTL_MS = 60_000;
536
-
537
- /** Pause sending body chunks once the channel buffer exceeds this many bytes. */
538
- const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
539
- /** Resume sending once the channel buffer drains to this many bytes. */
540
- const DC_BUFFER_LOW_WATER = 1 * 1024 * 1024;
541
- /** Safety fallback so the send loop cannot deadlock on a missed drain event. */
542
- const DC_BUFFER_DRAIN_TIMEOUT_MS = 5000;
1
+ /**
2
+ * @file WebRTC data channel request handler (proxy side).
3
+ *
4
+ * When a browser opens a data channel to this proxy, this handler wires up
5
+ * message handlers that implement an HTTP-over-DataChannel protocol:
6
+ * each incoming `request` message triggers a local `fetch` to the Fastify
7
+ * server, and the response is streamed back as base64-encoded chunks.
8
+ *
9
+ * ## Wire protocol
10
+ *
11
+ * Browser → Proxy
12
+ * ```
13
+ * { type: "request", requestId, method, path, query, headers, body }
14
+ * { type: "ping", id }
15
+ * ```
16
+ *
17
+ * Proxy → Browser
18
+ * ```
19
+ * { type: "response-start", requestId, status, headers } (JSON string)
20
+ * { type: "response-error", requestId, error: string } (JSON string)
21
+ * { type: "pong", id } (JSON string)
22
+ * ```
23
+ *
24
+ * Response bodies are sent as BINARY data-channel messages (not JSON), to
25
+ * avoid the ~33% base64 overhead and the JSON encode/decode cost. Each binary
26
+ * frame is laid out as:
27
+ * ```
28
+ * byte 0 flags (bit 0: done)
29
+ * byte 1 idLen (length of the requestId in bytes)
30
+ * bytes 2..2+N requestId (ASCII)
31
+ * bytes 2+N.. payload (raw body bytes; empty on the final done frame)
32
+ * ```
33
+ * Control messages stay JSON strings so the browser can distinguish them from
34
+ * body frames by message type (string vs ArrayBuffer).
35
+ *
36
+ * The protocol mirrors the tunnel relay protocol so both transports share
37
+ * the same mental model and the same browser-side `WebRtcProxy` implementation.
38
+ */
39
+
40
+ /** @import { DataChannel } from 'node-datachannel' */
41
+
42
+ /**
43
+ * Configuration for the data channel handler.
44
+ *
45
+ * @typedef {Object} DataChannelHandlerOptions
46
+ * @property {number} proxyPort
47
+ * Local port the proxy's Fastify HTTP server is listening on.
48
+ * Incoming requests are forwarded to `http://127.0.0.1:{proxyPort}`.
49
+ * @property {(message: string) => void} [onLog]
50
+ * Optional log sink.
51
+ */
52
+
53
+ /**
54
+ * An incoming request message received over the data channel.
55
+ *
56
+ * @typedef {Object} DataChannelRequest
57
+ * @property {string} requestId
58
+ * @property {string} method - HTTP method (GET, POST, …).
59
+ * @property {string} path - Request path (e.g. "/api/sources").
60
+ * @property {string} query - Raw query string without the leading "?".
61
+ * @property {Record<string, string>} headers - Headers to forward.
62
+ * @property {string | null} body - Request body string, or null.
63
+ */
64
+
65
+ /**
66
+ * The object returned by {@link createDataChannelHandler}.
67
+ *
68
+ * @typedef {Object} DataChannelHandler
69
+ * @property {(sessionId: string, channel: DataChannel) => void} handleChannel
70
+ * Wire message handlers onto a freshly opened data channel.
71
+ */
72
+
73
+ /**
74
+ * Watch one channel's send queue and, when it stops draining, say WHY.
75
+ *
76
+ * A channel that is open, keeps accepting requests and delivers nothing was
77
+ * seen in the field 2026-08-06: the queue grew from 214 049 to 239 731 bytes in
78
+ * fourteen seconds and never fell, while every layer above reported success —
79
+ * the route answered in 15 ms, the handler sent 378 bytes, the channel was
80
+ * open. The viewer sat in front of a spinner for eleven minutes.
81
+ *
82
+ * `bufferedAmount` alone cannot say why: it only proves the bytes are still
83
+ * OURS. The transport counters can, and this is the table the snapshot is read
84
+ * against — written down in advance so the answer is a reading, not an opinion:
85
+ *
86
+ * bytesSent rising, queue rising → packets leave, nothing acknowledges
87
+ * them: the return path is broken.
88
+ * bytesSent flat, queue rising → SCTP is not transmitting: the peer's
89
+ * receive window is shut or congestion
90
+ * control has collapsed.
91
+ * bytesReceived rising either way → the peer is alive and its packets do
92
+ * reach us; the failure is one-way.
93
+ * both flat → nothing crosses at all.
94
+ *
95
+ * Sampled every second; reported only once the queue has failed to fall for
96
+ * {@link SEND_QUEUE_STUCK_MS}, then every second while it lasts, so the trend
97
+ * of every counter is in the log rather than one snapshot of it.
98
+ *
99
+ * @param {string} sessionId
100
+ * @param {string} tag
101
+ * @param {string} label
102
+ * @param {DataChannel} channel
103
+ * @returns {() => void} Stops the watch.
104
+ */
105
+ function makeSendQueueWatcher({ log, getTransportSnapshot }) {
106
+ return function watchSendQueue(sessionId, tag, label, channel) {
107
+ let lowestSinceDrain = Number.POSITIVE_INFINITY;
108
+ let stuckSince = 0;
109
+ let previous = null;
110
+
111
+ const timer = setInterval(() => {
112
+ let queued = 0;
113
+ try {
114
+ queued = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
115
+ } catch {
116
+ return;
117
+ }
118
+ if (queued === 0 || queued < lowestSinceDrain) {
119
+ lowestSinceDrain = queued;
120
+ stuckSince = 0;
121
+ previous = null;
122
+ return;
123
+ }
124
+ const now = Date.now();
125
+ if (stuckSince === 0) {
126
+ stuckSince = now;
127
+ return;
128
+ }
129
+ if (now - stuckSince < SEND_QUEUE_STUCK_MS) {
130
+ return;
131
+ }
132
+ const snapshot = getTransportSnapshot?.(sessionId) ?? null;
133
+ if (!snapshot) {
134
+ log(`[dc] Session ${tag} "${label}": send queue stuck at ${queued}B for ` +
135
+ `${Math.round((now - stuckSince) / 1000)}s no transport to ask`);
136
+ return;
137
+ }
138
+ const sentDelta = previous ? snapshot.bytesSent - previous.bytesSent : null;
139
+ const recvDelta = previous ? snapshot.bytesReceived - previous.bytesReceived : null;
140
+ previous = snapshot;
141
+ log(
142
+ `[dc] Session ${tag} "${label}": send queue stuck at ${queued}B for ` +
143
+ `${Math.round((now - stuckSince) / 1000)}s — transport ` +
144
+ `sent=${snapshot.bytesSent}${sentDelta === null ? "" : ` (+${sentDelta})`} ` +
145
+ `received=${snapshot.bytesReceived}${recvDelta === null ? "" : ` (+${recvDelta})`} ` +
146
+ `rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
147
+ );
148
+ }, SEND_QUEUE_SAMPLE_MS);
149
+
150
+ if (typeof timer.unref === "function") {
151
+ timer.unref();
152
+ }
153
+ return () => clearInterval(timer);
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Create a handler for incoming WebRTC data channels.
159
+ *
160
+ * @param {DataChannelHandlerOptions} options
161
+ * @returns {DataChannelHandler}
162
+ */
163
+ import { performance } from "node:perf_hooks";
164
+ import { eventLoopDelay, resetEventLoopDelay } from "../utils/perf.js";
165
+
166
+ /**
167
+ * Build one body frame: `[flags(1)][idLen(1)][requestId][payload]`.
168
+ *
169
+ * One allocation and one copy. The previous version made two of each — a copy
170
+ * of the chunk into a `Buffer`, then a `concat` that copied it again into the
171
+ * frame which measured 75.9 ms per 13 MB segment on the field host against
172
+ * 40.0 ms this way, and allocated ~600 extra buffers over a segment's 208
173
+ * chunks. One copy is the floor: chunks arrive from a web stream that allocates
174
+ * them itself, so there is no buffer of ours to read them into.
175
+ *
176
+ * @param {Buffer} idBytes - The request id, already encoded.
177
+ * @param {Uint8Array | null} bytes - Payload, or nothing for the done frame.
178
+ * @param {boolean} done
179
+ * @returns {Buffer}
180
+ */
181
+ export function encodeFrame(idBytes, bytes, done) {
182
+ const payloadLength = bytes?.length ?? 0;
183
+ const frame = Buffer.allocUnsafe(2 + idBytes.length + payloadLength);
184
+ frame[0] = done ? 1 : 0;
185
+ frame[1] = idBytes.length;
186
+ idBytes.copy(frame, 2);
187
+ if (payloadLength > 0) {
188
+ frame.set(bytes, 2 + idBytes.length);
189
+ }
190
+ return frame;
191
+ }
192
+
193
+ export function createDataChannelHandler({ proxyPort, onLog, getTransportSnapshot }) {
194
+ /** Request id its ASCII bytes; see {@link requestIdBytes}. */
195
+ const requestIdCache = new Map();
196
+
197
+ const watchSendQueue = makeSendQueueWatcher({ log: (message) => log(message), getTransportSnapshot });
198
+
199
+ /**
200
+ * @param {string} message
201
+ * @returns {void}
202
+ */
203
+ function log(message) {
204
+ if (typeof onLog === "function") {
205
+ onLog(message);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Wire up the `onMessage`, `onClosed`, and `onError` handlers for a channel.
211
+ *
212
+ * @param {string} sessionId
213
+ * @param {DataChannel} channel
214
+ * @returns {void}
215
+ */
216
+ function handleChannel(sessionId, channel) {
217
+ const tag = sessionId.slice(0, 8);
218
+ const label = typeof channel.getLabel === "function" ? channel.getLabel() : "?";
219
+ log(`[dc] Session ${tag}: channel open`);
220
+ const stopWatchdog = watchSendQueue(sessionId, tag, label, channel);
221
+
222
+ // Partial chunked-request bodies in flight on THIS channel, keyed by
223
+ // requestId. Each entry buffers frames until the done frame, then runs the
224
+ // assembled request through the same path as a single-message request.
225
+ /** @type {Map<string, { meta: object, chunks: Buffer[], receivedBytes: number, bodyBytes: number, timer: ReturnType<typeof setTimeout> }>} */
226
+ const partials = new Map();
227
+
228
+ const dropPartial = (requestId) => {
229
+ const entry = partials.get(requestId);
230
+ if (entry) {
231
+ clearTimeout(entry.timer);
232
+ partials.delete(requestId);
233
+ }
234
+ };
235
+
236
+ /**
237
+ * Begin assembling a chunked request. Validates the path and size up front
238
+ * so an invalid or oversized request never buffers a body.
239
+ *
240
+ * @param {any} message - The `request-start` control message.
241
+ */
242
+ const startPartialRequest = (message) => {
243
+ const { requestId, method, path, query, headers, bodyBytes } = message ?? {};
244
+ if (typeof requestId !== "string" || requestId.length === 0) {
245
+ return;
246
+ }
247
+ if (!isValidRequestPath(path)) {
248
+ send(channel, { type: "response-error", requestId, error: "Invalid request path." });
249
+ return;
250
+ }
251
+ if (!Number.isInteger(bodyBytes) || bodyBytes < 0 || bodyBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
252
+ send(channel, { type: "response-error", requestId, error: "Request body too large." });
253
+ return;
254
+ }
255
+ dropPartial(requestId); // replace any stale entry with the same id
256
+ const timer = setTimeout(() => {
257
+ const entry = partials.get(requestId);
258
+ partials.delete(requestId);
259
+ log(`[dc] Session ${tag}: dropped stale partial request ${requestId.slice(0, 8)} (${entry?.receivedBytes ?? 0}B)`);
260
+ }, PARTIAL_REQUEST_TTL_MS);
261
+ partials.set(requestId, {
262
+ meta: { requestId, method, path, query, headers },
263
+ chunks: [],
264
+ receivedBytes: 0,
265
+ bodyBytes,
266
+ timer
267
+ });
268
+ };
269
+
270
+ /**
271
+ * Handle a binary body frame for a chunked request.
272
+ * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
273
+ *
274
+ * @param {Buffer} buf
275
+ */
276
+ const handleBodyFrame = (buf) => {
277
+ if (buf.length < 2) {
278
+ return;
279
+ }
280
+ const flags = buf[0];
281
+ const idLen = buf[1];
282
+ if (buf.length < 2 + idLen) {
283
+ return;
284
+ }
285
+ const requestId = buf.toString("ascii", 2, 2 + idLen);
286
+ const entry = partials.get(requestId);
287
+ if (!entry) {
288
+ return; // stale / already-dropped / aborted
289
+ }
290
+ if (flags & 2) {
291
+ // Aborted by the browser drop silently, no reply.
292
+ dropPartial(requestId);
293
+ return;
294
+ }
295
+ if (buf.length > 2 + idLen) {
296
+ const payload = buf.subarray(2 + idLen);
297
+ entry.chunks.push(Buffer.from(payload));
298
+ entry.receivedBytes += payload.length;
299
+ }
300
+ if (entry.receivedBytes > entry.bodyBytes || entry.receivedBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
301
+ dropPartial(requestId);
302
+ send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
303
+ return;
304
+ }
305
+ if (flags & 1) {
306
+ // Done frame assemble and execute.
307
+ dropPartial(requestId);
308
+ if (entry.receivedBytes !== entry.bodyBytes) {
309
+ send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
310
+ return;
311
+ }
312
+ const body = Buffer.concat(entry.chunks).toString("utf8");
313
+ void handleRequest(channel, { ...entry.meta, body }, true).catch((error) => {
314
+ log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
315
+ });
316
+ }
317
+ };
318
+
319
+ channel.onMessage((raw) => {
320
+ // Binary messages are chunked-request body frames; the proxy otherwise
321
+ // only ever receives JSON strings, so the type discriminates cleanly.
322
+ if (typeof raw !== "string") {
323
+ handleBodyFrame(Buffer.isBuffer(raw) ? raw : Buffer.from(raw));
324
+ return;
325
+ }
326
+
327
+ /** @type {DataChannelRequest | { type: string, id?: string }} */
328
+ let message;
329
+ try {
330
+ message = JSON.parse(raw);
331
+ } catch {
332
+ return;
333
+ }
334
+
335
+ if (message.type === "request") {
336
+ void handleRequest(channel, message).catch((error) => {
337
+ log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
338
+ });
339
+ return;
340
+ }
341
+
342
+ if (message.type === "request-start") {
343
+ startPartialRequest(message);
344
+ return;
345
+ }
346
+
347
+ if (message.type === "ping") {
348
+ send(channel, { type: "pong", id: message.id });
349
+ }
350
+ });
351
+
352
+ channel.onClosed(() => {
353
+ stopWatchdog();
354
+ for (const entry of partials.values()) {
355
+ clearTimeout(entry.timer);
356
+ }
357
+ partials.clear();
358
+ log(`[dc] Session ${tag}: channel closed`);
359
+ });
360
+
361
+ channel.onError((err) => {
362
+ log(`[dc] Session ${tag}: channel error: ${err}`);
363
+ });
364
+ }
365
+
366
+ /**
367
+ * Fetch a resource from the local proxy HTTP server and stream the response
368
+ * back to the browser over the data channel.
369
+ *
370
+ * The `Host` header is rewritten to `127.0.0.1:{proxyPort}` so that Fastify
371
+ * routes the request correctly regardless of what the browser sent.
372
+ *
373
+ * @param {DataChannel} channel
374
+ * @param {DataChannelRequest} req
375
+ * @returns {Promise<void>}
376
+ */
377
+ async function handleRequest(channel, req, viaChunks = false) {
378
+ const { requestId, method, path, query, headers: forwardedHeaders, body } = req;
379
+
380
+ // Reject paths that are not absolute, contain traversal sequences, or
381
+ // do not start with a known proxy route prefix. All valid browser-side
382
+ // requests use /api/*, /stream, /transcode/*, /health, or /healthz.
383
+ if (!isValidRequestPath(path)) {
384
+ send(channel, { type: "response-error", requestId, error: "Invalid request path." });
385
+ return;
386
+ }
387
+
388
+ const queryInfo = query ? `?${query}` : "";
389
+ const bodyInfo =
390
+ body != null && typeof body === "string" && body.length > 0
391
+ ? ` body=${body.length} bytes${viaChunks ? " (chunked)" : ""}`
392
+ : "";
393
+ log(`[dc] ${method} ${path}${queryInfo}${bodyInfo}`);
394
+
395
+ const targetUrl = `http://127.0.0.1:${proxyPort}${path}${query ? `?${query}` : ""}`;
396
+ const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
397
+
398
+ let response;
399
+ // [net-debug] TEMPORARY: time spent in the local fetch (waiting for the
400
+ // route to return a response e.g. long-polling until an HLS segment is
401
+ // finalized by ffmpeg) vs. the body transfer over the data channel.
402
+ const fetchStartedAt = Date.now();
403
+ try {
404
+ response = await fetch(targetUrl, {
405
+ method,
406
+ headers: requestHeaders,
407
+ body: body != null ? body : undefined,
408
+ redirect: "manual"
409
+ });
410
+ } catch (fetchError) {
411
+ log(`[dc] ${method} ${path}${queryInfo} → error: ${fetchError?.message ?? String(fetchError)}`);
412
+ send(channel, { type: "response-error", requestId, error: fetchError?.message ?? String(fetchError) });
413
+ return;
414
+ }
415
+
416
+ if (response.status !== 200 && response.status !== 206) {
417
+ log(`[dc] ${method} ${path}${queryInfo} → ${response.status}`);
418
+ }
419
+
420
+ /** @type {Record<string, string>} */
421
+ const responseHeaders = {};
422
+ for (const [name, value] of response.headers.entries()) {
423
+ responseHeaders[name] = value;
424
+ }
425
+
426
+ send(channel, { type: "response-start", requestId, status: response.status, headers: responseHeaders });
427
+
428
+ if (!response.body) {
429
+ sendChunk(channel, requestId, null, true);
430
+ return;
431
+ }
432
+
433
+ try {
434
+ const reader = response.body.getReader();
435
+ // [net-debug] TEMPORARY: measure transfer size/time and channel buffering.
436
+ // fetchMs = time waiting for the route (incl. ffmpeg segment finalization).
437
+ // ttfbMs = time from body-read start to the first chunk with data (loopback).
438
+ // sendMs = total body read+send duration over the data channel.
439
+ const fetchMs = Date.now() - fetchStartedAt;
440
+ const sendStartedAt = Date.now();
441
+ let firstByteMs = -1;
442
+ let chunks = 0;
443
+ let totalBytes = 0;
444
+ let maxBuffered = 0;
445
+ // Attribute the transfer to the step that actually consumes the time.
446
+ // Without this split a slow transfer is indistinguishable between "the
447
+ // source is slow", "the channel is slow" and "the event loop is blocked",
448
+ // which is exactly the argument a field seek left unresolved.
449
+ let readMs = 0;
450
+ let sendMs2 = 0;
451
+ let drainMs = 0;
452
+ resetEventLoopDelay();
453
+ while (true) {
454
+ const readStartedAt = performance.now();
455
+ const { done, value } = await reader.read();
456
+ readMs += performance.now() - readStartedAt;
457
+ if (done) {
458
+ sendChunk(channel, requestId, null, true);
459
+ const elapsedMs = Date.now() - sendStartedAt;
460
+ let bufferedNow = 0;
461
+ try { bufferedNow = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0; } catch { /* ignore */ }
462
+ const loop = eventLoopDelay();
463
+ const mbps = elapsedMs > 0 ? (totalBytes * 8) / (elapsedMs * 1000) : 0;
464
+ log(
465
+ `[net-debug] sent ${path}${queryInfo} bytes=${totalBytes} fetchMs=${fetchMs} ` +
466
+ `ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
467
+ `maxBuffered=${maxBuffered} bufferedAtEnd=${bufferedNow} ` +
468
+ // Where the time went: reading the body from the local route,
469
+ // handing chunks to the channel, or waiting for its queue. Plus
470
+ // the event-loop delay over the same window — a large max here
471
+ // means the transfer was blocked by synchronous work, not by the
472
+ // network, and the three figures above will all look inflated.
473
+ `readMs=${readMs.toFixed(0)} chanMs=${sendMs2.toFixed(0)} drainMs=${drainMs.toFixed(0)} ` +
474
+ `loopMean=${loop.meanMs.toFixed(1)} loopP99=${loop.p99Ms.toFixed(1)} loopMax=${loop.maxMs.toFixed(1)} ` +
475
+ `rate=${mbps.toFixed(1)}Mbps`
476
+ );
477
+ break;
478
+ }
479
+ if (firstByteMs < 0) firstByteMs = Date.now() - sendStartedAt;
480
+ chunks += 1;
481
+ totalBytes += value.length;
482
+ try {
483
+ const b = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
484
+ if (b > maxBuffered) maxBuffered = b;
485
+ } catch { /* ignore */ }
486
+ const sendStepAt = performance.now();
487
+ sendChunk(channel, requestId, value, false);
488
+ sendMs2 += performance.now() - sendStepAt;
489
+ // Backpressure: do not keep queuing chunks once the channel's outgoing
490
+ // buffer is large — wait for it to drain. Prevents the SCTP send buffer
491
+ // from ballooning, which stalls throughput.
492
+ const drainStepAt = performance.now();
493
+ await waitForBufferDrain(channel);
494
+ drainMs += performance.now() - drainStepAt;
495
+ }
496
+ } catch {
497
+ sendChunk(channel, requestId, null, true);
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Send a response body frame as a BINARY data-channel message.
503
+ * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
504
+ *
505
+ * @param {DataChannel} channel
506
+ * @param {string} requestId
507
+ * @param {Uint8Array | null} bytes - Body bytes, or null/empty for the done frame.
508
+ * @param {boolean} done
509
+ * @returns {void}
510
+ */
511
+ /**
512
+ * The request id as bytes, prepared once per request rather than per chunk.
513
+ *
514
+ * A segment is a couple of hundred chunks, and each one was re-encoding the
515
+ * same 32-character string. The map is bounded because request ids are
516
+ * short-lived and unbounded in number — dropping the whole cache when it
517
+ * grows costs one re-encode per live request and cannot leak.
518
+ *
519
+ * @param {string} requestId
520
+ * @returns {Buffer}
521
+ */
522
+ function requestIdBytes(requestId) {
523
+ let bytes = requestIdCache.get(requestId);
524
+ if (!bytes) {
525
+ if (requestIdCache.size > 64) {
526
+ requestIdCache.clear();
527
+ }
528
+ bytes = Buffer.from(requestId, "ascii");
529
+ requestIdCache.set(requestId, bytes);
530
+ }
531
+ return bytes;
532
+ }
533
+
534
+ function sendChunk(channel, requestId, bytes, done) {
535
+ try {
536
+ channel.sendMessageBinary(encodeFrame(requestIdBytes(requestId), bytes, done));
537
+ } catch {
538
+ // Channel closed between check and send — safe to ignore.
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Resolve once the channel's outgoing buffer has drained below the low-water
544
+ * mark. No-op (resolves immediately) when the buffer is already small or the
545
+ * channel does not expose buffer APIs. A timeout fallback guards against a
546
+ * missed low-water event so the send loop can never deadlock.
547
+ *
548
+ * @param {DataChannel} channel
549
+ * @returns {Promise<void>}
550
+ */
551
+ function waitForBufferDrain(channel) {
552
+ return new Promise((resolve) => {
553
+ try {
554
+ if (typeof channel.bufferedAmount !== "function" || channel.bufferedAmount() <= DC_BUFFER_HIGH_WATER) {
555
+ resolve();
556
+ return;
557
+ }
558
+ let settled = false;
559
+ const done = () => {
560
+ if (settled) return;
561
+ settled = true;
562
+ resolve();
563
+ };
564
+ channel.setBufferedAmountLowThreshold(DC_BUFFER_LOW_WATER);
565
+ channel.onBufferedAmountLow(done);
566
+ // Guard against a race where the buffer drained between the check above
567
+ // and registering the callback (the low-water event would never fire).
568
+ if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
569
+ done();
570
+ return;
571
+ }
572
+ setTimeout(done, DC_BUFFER_DRAIN_TIMEOUT_MS);
573
+ } catch {
574
+ resolve();
575
+ }
576
+ });
577
+ }
578
+
579
+ /**
580
+ * Serialise `message` to JSON and send it over the data channel.
581
+ * Errors are silently swallowed — the channel may have closed between
582
+ * the open check and the actual send.
583
+ *
584
+ * @param {DataChannel} channel
585
+ * @param {object} message
586
+ * @returns {void}
587
+ */
588
+ function send(channel, message) {
589
+ try {
590
+ channel.sendMessage(JSON.stringify(message));
591
+ } catch {
592
+ // Channel closed between check and send — safe to ignore.
593
+ }
594
+ }
595
+
596
+ return { handleChannel };
597
+ }
598
+
599
+ /**
600
+ * Allowed path prefixes for data-channel requests.
601
+ * Only the known proxy API and streaming routes are accepted.
602
+ */
603
+ const PATH_ALLOWLIST_RE = /^(?:\/api\/|\/stream(?:$|\?)|\/?transcode\/|\/health(?:z)?(?:$|\?))/;
604
+
605
+ /**
606
+ * True when `path` is an absolute, traversal-free path on a known proxy route.
607
+ * Shared by the single-message and chunked request entry points.
608
+ *
609
+ * @param {unknown} path
610
+ * @returns {boolean}
611
+ */
612
+ function isValidRequestPath(path) {
613
+ return (
614
+ typeof path === "string" &&
615
+ path.startsWith("/") &&
616
+ !path.includes("..") &&
617
+ PATH_ALLOWLIST_RE.test(path)
618
+ );
619
+ }
620
+
621
+ /** Max assembled size of a chunked request body (guards proxy memory). */
622
+ const PROXY_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024;
623
+ /** Drop an incomplete chunked body if no further frame arrives within this window. */
624
+ const PARTIAL_REQUEST_TTL_MS = 60_000;
625
+
626
+ /** Pause sending body chunks once the channel buffer exceeds this many bytes. */
627
+ // How often the send queue is sampled, and how long it must fail to fall
628
+ // before the transport is asked what it is doing. Five seconds is far longer
629
+ // than any healthy burst drains in — measured, a 6-11 MB segment leaves in
630
+ // well under a second on the LAN — and short enough that a stuck channel is
631
+ // named while the viewer is still looking at it.
632
+ const SEND_QUEUE_SAMPLE_MS = 1_000;
633
+ const SEND_QUEUE_STUCK_MS = 5_000;
634
+ const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
635
+ /** Resume sending once the channel buffer drains to this many bytes. */
636
+ const DC_BUFFER_LOW_WATER = 1 * 1024 * 1024;
637
+ /** Safety fallback so the send loop cannot deadlock on a missed drain event. */
638
+ const DC_BUFFER_DRAIN_TIMEOUT_MS = 5000;