@torrent-tv/proxy 2.55.14 → 2.57.0

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,949 +1,1133 @@
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
- * { type: "probe-echo", seen: { <label>: seq }, report }
16
- * ```
17
- *
18
- * Proxy → Browser
19
- * ```
20
- * { type: "probe", seq, sentAt } (JSON string)
21
- * { type: "response-start", requestId, status, headers } (JSON string)
22
- * { type: "response-error", requestId, error: string } (JSON string)
23
- * { type: "pong", id } (JSON string)
24
- * { type: "subtitle-cues", fileIndex, trackIndex, cues, language } (JSON string)
25
- * ```
26
- * The last one is unsolicited — sent the moment new cues are read from a
27
- * file's already-downloaded pieces, to whichever channel last asked for that
28
- * file's subtitles over `/api/subtitles`. Not a response to any `requestId`.
29
- *
30
- * Response bodies are sent as BINARY data-channel messages (not JSON), to
31
- * avoid the ~33% base64 overhead and the JSON encode/decode cost. Each binary
32
- * frame is laid out as:
33
- * ```
34
- * byte 0 flags (bit 0: done)
35
- * byte 1 idLen (length of the requestId in bytes)
36
- * bytes 2..2+N requestId (ASCII)
37
- * bytes 2+N.. payload (raw body bytes; empty on the final done frame)
38
- * ```
39
- * Control messages stay JSON strings so the browser can distinguish them from
40
- * body frames by message type (string vs ArrayBuffer).
41
- *
42
- * The protocol mirrors the tunnel relay protocol so both transports share
43
- * the same mental model and the same browser-side `WebRtcProxy` implementation.
44
- */
45
-
46
- /** @import { DataChannel } from 'node-datachannel' */
47
-
48
- import { deriveSourceKey } from "./torrent-source-key.js";
49
- import { createDeliveryProbe } from "./delivery-probe.js";
50
-
51
- /**
52
- * Configuration for the data channel handler.
53
- *
54
- * @typedef {Object} DataChannelHandlerOptions
55
- * @property {number} proxyPort
56
- * Local port the proxy's Fastify HTTP server is listening on.
57
- * Incoming requests are forwarded to `http://127.0.0.1:{proxyPort}`.
58
- * @property {(message: string) => void} [onLog]
59
- * Optional log sink.
60
- * @property {{ maybeCapture: (trigger: {
61
- * sessionId: string, tag: string, label: string,
62
- * remote: { address: string, port: number } | null,
63
- * queuedBytes: number, stuckForMs: number
64
- * }) => boolean }} [witness]
65
- * The packet witness (services/packet-witness.js). When a wedged queue
66
- * crosses {@linkcode SEND_QUEUE_CAPTURE_AFTER_MS} the watcher hands it the
67
- * transport snapshot's remote endpoint so a bounded tcpdump can record what
68
- * the wire actually did. Optional; absent means no captures are taken.
69
- */
70
-
71
- /**
72
- * An incoming request message received over the data channel.
73
- *
74
- * @typedef {Object} DataChannelRequest
75
- * @property {string} requestId
76
- * @property {string} method - HTTP method (GET, POST, …).
77
- * @property {string} path - Request path (e.g. "/api/sources").
78
- * @property {string} query - Raw query string without the leading "?".
79
- * @property {Record<string, string>} headers - Headers to forward.
80
- * @property {string | null} body - Request body string, or null.
81
- */
82
-
83
- /**
84
- * The object returned by {@link createDataChannelHandler}.
85
- *
86
- * @typedef {Object} DataChannelHandler
87
- * @property {(sessionId: string, channel: DataChannel) => void} handleChannel
88
- * Wire message handlers onto a freshly opened data channel.
89
- */
90
-
91
- /**
92
- * Watch one channel's send queue and, when it stops draining, say WHY.
93
- *
94
- * A channel that is open, keeps accepting requests and delivers nothing was
95
- * seen in the field 2026-08-06: the queue grew from 214 049 to 239 731 bytes in
96
- * fourteen seconds and never fell, while every layer above reported success
97
- * the route answered in 15 ms, the handler sent 378 bytes, the channel was
98
- * open. The viewer sat in front of a spinner for eleven minutes.
99
- *
100
- * `bufferedAmount` alone cannot say why: it only proves the bytes are still
101
- * OURS. The transport counters can, and this is the table the snapshot is read
102
- * against written down in advance so the answer is a reading, not an opinion:
103
- *
104
- * bytesSent rising, queue rising → packets leave, nothing acknowledges
105
- * them: the return path is broken.
106
- * bytesSent flat, queue rising → SCTP is not transmitting: the peer's
107
- * receive window is shut or congestion
108
- * control has collapsed.
109
- * bytesReceived rising either way → the peer is alive and its packets do
110
- * reach us; the failure is one-way.
111
- * both flat → nothing crosses at all.
112
- *
113
- * Sampled every second; reported only once the queue has failed to fall for
114
- * {@link SEND_QUEUE_STUCK_MS}, then every second while it lasts, so the trend
115
- * of every counter is in the log rather than one snapshot of it.
116
- *
117
- * @param {string} sessionId
118
- * @param {string} tag
119
- * @param {string} label
120
- * @param {DataChannel} channel
121
- * @returns {() => void} Stops the watch.
122
- */
123
- function makeSendQueueWatcher({ log, getTransportSnapshot, witness }) {
124
- // Every channel of one connection reads the SAME transport counters — the
125
- // snapshot describes the peer connection, not the channel — so the heartbeat
126
- // belongs to the connection and is printed once for it. Printed per channel
127
- // it produced two byte-for-byte identical lines (measured 2026-08-14:
128
- // `sent=5153491` under both "proxy" and "proxy-control"), which read as two
129
- // independent readings agreeing and made the second channel invisible: the
130
- // one thing that IS per channel, its queue depth, was the only real
131
- // difference and it was buried in a line that looked like a duplicate.
132
- //
133
- // sessionId the channels currently open on that connection, and when it was
134
- // last reported. Channels are keyed by the channel OBJECT, not by its label:
135
- // a label is whatever the peer chose and two channels can carry the same one
136
- // (or none, where `getLabel` is missing and both fall back to "?"), and a
137
- // Map keyed on that would let one channel evict the other and then, on
138
- // closing, delete the survivor's entry. `captureStarted` rides on the same
139
- // record: both channels of one wedged connection must ask the witness once,
140
- // not once per channel.
141
- /** @type {Map<string, { channels: Map<DataChannel, string>, at: number, previous: object | null, unknown: number, captureStarted: boolean }>} */
142
- const connections = new Map();
143
-
144
- /**
145
- * What each channel of a connection is holding, right now.
146
- *
147
- * @param {Map<DataChannel, string>} channels
148
- * @returns {string} `label:NB` per channel, in the order they opened.
149
- */
150
- const queueDepths = (channels) => {
151
- const parts = [];
152
- for (const [openChannel, channelLabel] of channels) {
153
- let depth = -1;
154
- try {
155
- depth = typeof openChannel.bufferedAmount === "function" ? openChannel.bufferedAmount() : 0;
156
- } catch {
157
- depth = -1;
158
- }
159
- parts.push(`${channelLabel}:${depth}B`);
160
- }
161
- return parts.join(" ");
162
- };
163
-
164
- return function watchSendQueue(sessionId, tag, label, channel) {
165
- let lowestSinceDrain = Number.POSITIVE_INFINITY;
166
- let stuckSince = 0;
167
- let previous = null;
168
- let connection = connections.get(sessionId);
169
- if (!connection) {
170
- connection = { channels: new Map(), at: 0, previous: null, unknown: 0, captureStarted: false };
171
- connections.set(sessionId, connection);
172
- }
173
- connection.channels.set(channel, label);
174
- /** @type {ReturnType<typeof setInterval> | null} */
175
- let timer = null;
176
- /**
177
- * End this channel's watch and let go of its entry.
178
- *
179
- * @returns {void}
180
- */
181
- const stop = () => {
182
- if (timer) {
183
- clearInterval(timer);
184
- }
185
- connection.channels.delete(channel);
186
- // Only if the map still holds THIS record: a late stop, after the same
187
- // session id has been reused and a new record made for it, must not evict
188
- // the live one.
189
- if (connection.channels.size === 0 && connections.get(sessionId) === connection) {
190
- connections.delete(sessionId);
191
- }
192
- };
193
- // Independent of the queue: the transport's own counters, sampled for as
194
- // long as the channel is open. The queue was the wrong thing to watch —
195
- // field 2026-08-06, a 9.26 MB segment was accepted by the transport with
196
- // `maxBuffered=0 bufferedAtEnd=0`, reported as sent at 274 Mbit/s, and
197
- // never arrived; everything the proxy sent from that moment on was lost the
198
- // same way while requests kept coming the other direction. With nothing
199
- // queued this watcher never woke, so the one question that matters — did
200
- // those bytes leave the machine — has no answer in the log. It does now.
201
- // A connection the transport no longer knows about is gone, whatever the
202
- // channel says. `onClosed` is the ordinary way this watch ends, and it does
203
- // not always come — a peer connection can die without it, leaving the timer
204
- // and this channel's entry behind for the life of the process.
205
- //
206
- // The count is kept on the CONNECTION: exactly one channel enters the
207
- // heartbeat branch per interval, so a per-channel count would advance only
208
- // on that channel's turn and the teardown would take three heartbeats per
209
- // channel rather than three in total.
210
- timer = setInterval(() => {
211
- const sampledAt = Date.now();
212
- // Whichever channel's timer arrives first past the interval reports for
213
- // the whole connection; the others find the timestamp already moved and
214
- // skip. So the line appears once however many channels are open.
215
- if (sampledAt - connection.at >= TRANSPORT_HEARTBEAT_MS) {
216
- connection.at = sampledAt;
217
- const snapshot = getTransportSnapshot?.(sessionId) ?? null;
218
- connection.unknown = snapshot ? 0 : connection.unknown + 1;
219
- if (connection.unknown >= TRANSPORT_UNKNOWN_HEARTBEATS) {
220
- stop();
221
- return;
222
- }
223
- if (snapshot) {
224
- const sent = connection.previous ? snapshot.bytesSent - connection.previous.bytesSent : null;
225
- const received = connection.previous
226
- ? snapshot.bytesReceived - connection.previous.bytesReceived
227
- : null;
228
- connection.previous = snapshot;
229
- log(
230
- `[dc-transport] ${tag} sent=${snapshot.bytesSent}` +
231
- `${sent === null ? "" : ` (+${sent})`} received=${snapshot.bytesReceived}` +
232
- `${received === null ? "" : ` (+${received})`} queued[${queueDepths(connection.channels)}] ` +
233
- `rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
234
- );
235
- }
236
- }
237
- let queued = 0;
238
- try {
239
- queued = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
240
- } catch {
241
- return;
242
- }
243
- if (queued === 0 || queued < lowestSinceDrain) {
244
- lowestSinceDrain = queued;
245
- stuckSince = 0;
246
- previous = null;
247
- return;
248
- }
249
- const now = Date.now();
250
- if (stuckSince === 0) {
251
- stuckSince = now;
252
- return;
253
- }
254
- if (now - stuckSince < SEND_QUEUE_STUCK_MS) {
255
- return;
256
- }
257
- const snapshot = getTransportSnapshot?.(sessionId) ?? null;
258
- if (!snapshot) {
259
- log(`[dc] Session ${tag} "${label}": send queue stuck at ${queued}B for ` +
260
- `${Math.round((now - stuckSince) / 1000)}s — no transport to ask`);
261
- return;
262
- }
263
- const sentDelta = previous ? snapshot.bytesSent - previous.bytesSent : null;
264
- const recvDelta = previous ? snapshot.bytesReceived - previous.bytesReceived : null;
265
- previous = snapshot;
266
- log(
267
- `[dc] Session ${tag} "${label}": send queue stuck at ${queued}B for ` +
268
- `${Math.round((now - stuckSince) / 1000)}s transport ` +
269
- `sent=${snapshot.bytesSent}${sentDelta === null ? "" : ` (+${sentDelta})`} ` +
270
- `received=${snapshot.bytesReceived}${recvDelta === null ? "" : ` (+${recvDelta})`} ` +
271
- `rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
272
- );
273
- // Roadmap item 10, occurrence 2026-08-24: a wedge this old is the moment
274
- // the packet-level truth has to be caught, because no counter above the
275
- // wire can name the cause. One attempt per connection — the witness
276
- // applies its own single-flight and cooldown rules after that.
277
- if (
278
- witness &&
279
- !connection.captureStarted &&
280
- now - stuckSince >= SEND_QUEUE_CAPTURE_AFTER_MS
281
- ) {
282
- connection.captureStarted = true;
283
- const started = witness.maybeCapture({
284
- sessionId,
285
- tag,
286
- label,
287
- remote: snapshot.remote ?? null,
288
- queuedBytes: queued,
289
- stuckForMs: now - stuckSince
290
- });
291
- if (!started) {
292
- // Refused for now (no remote endpoint yet, capture already running
293
- // elsewhere, cooldown): let the next tick try again rather than
294
- // spending the one attempt per wedge on a refusal.
295
- connection.captureStarted = false;
296
- }
297
- }
298
- }, SEND_QUEUE_SAMPLE_MS);
299
-
300
- if (typeof timer.unref === "function") {
301
- timer.unref();
302
- }
303
- return stop;
304
- };
305
- }
306
-
307
- /**
308
- * Create a handler for incoming WebRTC data channels.
309
- *
310
- * @param {DataChannelHandlerOptions} options
311
- * @returns {DataChannelHandler}
312
- */
313
- import { performance } from "node:perf_hooks";
314
- import { eventLoopDelay, resetEventLoopDelay } from "../utils/perf.js";
315
-
316
- /**
317
- * Build one body frame: `[flags(1)][idLen(1)][requestId][payload]`.
318
- *
319
- * One allocation and one copy. The previous version made two of each — a copy
320
- * of the chunk into a `Buffer`, then a `concat` that copied it again into the
321
- * frame which measured 75.9 ms per 13 MB segment on the field host against
322
- * 40.0 ms this way, and allocated ~600 extra buffers over a segment's 208
323
- * chunks. One copy is the floor: chunks arrive from a web stream that allocates
324
- * them itself, so there is no buffer of ours to read them into.
325
- *
326
- * @param {Buffer} idBytes - The request id, already encoded.
327
- * @param {Uint8Array | null} bytes - Payload, or nothing for the done frame.
328
- * @param {boolean} done
329
- * @returns {Buffer}
330
- */
331
- export function encodeFrame(idBytes, bytes, done) {
332
- const payloadLength = bytes?.length ?? 0;
333
- const frame = Buffer.allocUnsafe(2 + idBytes.length + payloadLength);
334
- frame[0] = done ? 1 : 0;
335
- frame[1] = idBytes.length;
336
- idBytes.copy(frame, 2);
337
- if (payloadLength > 0) {
338
- frame.set(bytes, 2 + idBytes.length);
339
- }
340
- return frame;
341
- }
342
-
343
- export function createDataChannelHandler({ proxyPort, onLog, getTransportSnapshot, sourceRegistry, witness }) {
344
- /**
345
- * Channels currently interested in one file's subtitle cues, keyed by
346
- * `sourceKey:fileIndex`. Populated the moment a browser asks for an
347
- * embedded track — there is no separate subscribe message on the wire, the
348
- * existing `/api/subtitles` request already says which file a viewer opened
349
- * subtitles for. Pruned on channel close and, defensively, on a failed send.
350
- *
351
- * @type {Map<string, Set<DataChannel>>}
352
- */
353
- const subtitleSubscribers = new Map();
354
-
355
- /**
356
- * @param {string} sourceKey
357
- * @param {number} fileIndex
358
- * @param {DataChannel} channel
359
- * @returns {void}
360
- */
361
- function subscribeSubtitles(sourceKey, fileIndex, channel) {
362
- const key = `${sourceKey}:${fileIndex}`;
363
- let set = subtitleSubscribers.get(key);
364
- if (!set) {
365
- set = new Set();
366
- subtitleSubscribers.set(key, set);
367
- }
368
- const isNew = !set.has(channel);
369
- set.add(channel);
370
- if (isNew) {
371
- log(`[dc] subtitle push: channel subscribed to ${key} (${set.size} channel(s) now)`);
372
- }
373
- }
374
-
375
- /** @param {DataChannel} channel */
376
- function unsubscribeSubtitlesAll(channel) {
377
- for (const set of subtitleSubscribers.values()) {
378
- set.delete(channel);
379
- }
380
- }
381
-
382
- /**
383
- * Send new cues to every channel watching this file the push side of
384
- * subtitles arriving as they download rather than being polled for. Cues
385
- * are tiny (kilobytes at most for a whole track), so this is one message,
386
- * not a stream.
387
- *
388
- * @param {{ sourceKey: string, fileIndex: number, trackIndex: number, cues: object[], language: string }} event
389
- * @returns {void}
390
- */
391
- function publishSubtitleCues({ sourceKey, fileIndex, trackIndex, cues, language }) {
392
- const set = subtitleSubscribers.get(`${sourceKey}:${fileIndex}`);
393
- if (!set || set.size === 0) {
394
- log(
395
- `[dc] subtitle push: ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
396
- "found no subscribed channel"
397
- );
398
- return;
399
- }
400
- const message = { type: "subtitle-cues", fileIndex, trackIndex, cues, language };
401
- const total = set.size;
402
- let sent = 0;
403
- for (const channel of set) {
404
- try {
405
- channel.sendMessage(JSON.stringify(message));
406
- sent += 1;
407
- } catch {
408
- // Closed between the subscription and this send; onClosed will not
409
- // fire for a channel that is already gone, so drop it here too.
410
- set.delete(channel);
411
- }
412
- }
413
- log(
414
- `[dc] subtitle push: sent ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
415
- `to ${sent}/${total} channel(s)`
416
- );
417
- }
418
-
419
- /** Request id its ASCII bytes; see {@link requestIdBytes}. */
420
- const requestIdCache = new Map();
421
-
422
- const watchSendQueue = makeSendQueueWatcher({ log: (message) => log(message), getTransportSnapshot, witness });
423
- // Numbered probes on every channel, and the browser's echo of what it saw.
424
- // The proxy's own counters cannot say whether bytes it handed to usrsctp were
425
- // ever put on the wire; the far end can, and it keeps answering throughout a
426
- // freeze. See services/delivery-probe.js.
427
- const deliveryProbe = createDeliveryProbe({ log: (message) => log(message) });
428
-
429
- /**
430
- * @param {string} message
431
- * @returns {void}
432
- */
433
- function log(message) {
434
- if (typeof onLog === "function") {
435
- onLog(message);
436
- }
437
- }
438
-
439
- /**
440
- * Wire up the `onMessage`, `onClosed`, and `onError` handlers for a channel.
441
- *
442
- * @param {string} sessionId
443
- * @param {DataChannel} channel
444
- * @returns {void}
445
- */
446
- function handleChannel(sessionId, channel) {
447
- const tag = sessionId.slice(0, 8);
448
- const label = typeof channel.getLabel === "function" ? channel.getLabel() : "?";
449
- log(`[dc] Session ${tag}: channel open`);
450
- const stopWatchdog = watchSendQueue(sessionId, tag, label, channel);
451
- deliveryProbe.attach(sessionId, tag, label, channel);
452
-
453
- // Partial chunked-request bodies in flight on THIS channel, keyed by
454
- // requestId. Each entry buffers frames until the done frame, then runs the
455
- // assembled request through the same path as a single-message request.
456
- /** @type {Map<string, { meta: object, chunks: Buffer[], receivedBytes: number, bodyBytes: number, timer: ReturnType<typeof setTimeout> }>} */
457
- const partials = new Map();
458
-
459
- const dropPartial = (requestId) => {
460
- const entry = partials.get(requestId);
461
- if (entry) {
462
- clearTimeout(entry.timer);
463
- partials.delete(requestId);
464
- }
465
- };
466
-
467
- /**
468
- * Begin assembling a chunked request. Validates the path and size up front
469
- * so an invalid or oversized request never buffers a body.
470
- *
471
- * @param {any} message - The `request-start` control message.
472
- */
473
- const startPartialRequest = (message) => {
474
- const { requestId, method, path, query, headers, bodyBytes } = message ?? {};
475
- if (typeof requestId !== "string" || requestId.length === 0) {
476
- return;
477
- }
478
- if (!isValidRequestPath(path)) {
479
- send(channel, { type: "response-error", requestId, error: "Invalid request path." });
480
- return;
481
- }
482
- if (!Number.isInteger(bodyBytes) || bodyBytes < 0 || bodyBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
483
- send(channel, { type: "response-error", requestId, error: "Request body too large." });
484
- return;
485
- }
486
- dropPartial(requestId); // replace any stale entry with the same id
487
- const timer = setTimeout(() => {
488
- const entry = partials.get(requestId);
489
- partials.delete(requestId);
490
- log(`[dc] Session ${tag}: dropped stale partial request ${requestId.slice(0, 8)} (${entry?.receivedBytes ?? 0}B)`);
491
- }, PARTIAL_REQUEST_TTL_MS);
492
- partials.set(requestId, {
493
- meta: { requestId, method, path, query, headers },
494
- chunks: [],
495
- receivedBytes: 0,
496
- bodyBytes,
497
- timer
498
- });
499
- };
500
-
501
- /**
502
- * Handle a binary body frame for a chunked request.
503
- * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
504
- *
505
- * @param {Buffer} buf
506
- */
507
- const handleBodyFrame = (buf) => {
508
- if (buf.length < 2) {
509
- return;
510
- }
511
- const flags = buf[0];
512
- const idLen = buf[1];
513
- if (buf.length < 2 + idLen) {
514
- return;
515
- }
516
- const requestId = buf.toString("ascii", 2, 2 + idLen);
517
- const entry = partials.get(requestId);
518
- if (!entry) {
519
- return; // stale / already-dropped / aborted
520
- }
521
- if (flags & 2) {
522
- // Aborted by the browser — drop silently, no reply.
523
- dropPartial(requestId);
524
- return;
525
- }
526
- if (buf.length > 2 + idLen) {
527
- const payload = buf.subarray(2 + idLen);
528
- entry.chunks.push(Buffer.from(payload));
529
- entry.receivedBytes += payload.length;
530
- }
531
- if (entry.receivedBytes > entry.bodyBytes || entry.receivedBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
532
- dropPartial(requestId);
533
- send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
534
- return;
535
- }
536
- if (flags & 1) {
537
- // Done frame — assemble and execute.
538
- dropPartial(requestId);
539
- if (entry.receivedBytes !== entry.bodyBytes) {
540
- send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
541
- return;
542
- }
543
- const body = Buffer.concat(entry.chunks).toString("utf8");
544
- void handleRequest(channel, { ...entry.meta, body }, true).catch((error) => {
545
- log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
546
- });
547
- }
548
- };
549
-
550
- channel.onMessage((raw) => {
551
- // Binary messages are chunked-request body frames; the proxy otherwise
552
- // only ever receives JSON strings, so the type discriminates cleanly.
553
- if (typeof raw !== "string") {
554
- handleBodyFrame(Buffer.isBuffer(raw) ? raw : Buffer.from(raw));
555
- return;
556
- }
557
-
558
- /** @type {DataChannelRequest | { type: string, id?: string }} */
559
- let message;
560
- try {
561
- message = JSON.parse(raw);
562
- } catch {
563
- return;
564
- }
565
-
566
- if (message.type === "request") {
567
- void handleRequest(channel, message).catch((error) => {
568
- log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
569
- });
570
- return;
571
- }
572
-
573
- if (message.type === "request-start") {
574
- startPartialRequest(message);
575
- return;
576
- }
577
-
578
- if (message.type === "ping") {
579
- send(channel, { type: "pong", id: message.id });
580
- return;
581
- }
582
-
583
- // The far end's answer to the numbered probes, plus what it can see of
584
- // its own receiving. It travels browser to proxy, the direction that goes
585
- // on working through a freeze, so it arrives when nothing else does.
586
- if (message.type === "probe-echo") {
587
- deliveryProbe.noteEcho(sessionId, message);
588
- if (message.report && typeof message.report === "object") {
589
- const report = message.report;
590
- const channels = report.channels && typeof report.channels === "object"
591
- ? Object.entries(report.channels)
592
- .map(([name, counters]) => `${name}=${counters?.messages ?? "?"}msg/${counters?.bytes ?? "?"}B`)
593
- .join(" ")
594
- : "";
595
- log(
596
- `[dc-far] ${tag} visibility=${report.visibility ?? "?"} ` +
597
- `loopLag=${report.loopLagMs ?? "?"}ms handler=${report.handlerMaxMs ?? "?"}ms ` +
598
- `transportIn=${report.transportBytesReceived ?? "?"} ${channels} ` +
599
- `pending=${report.pending ?? "?"} at=${new Date().toISOString()}`
600
- );
601
- }
602
- return;
603
- }
604
- });
605
-
606
- channel.onClosed(() => {
607
- stopWatchdog();
608
- deliveryProbe.detach(sessionId, channel);
609
- for (const entry of partials.values()) {
610
- clearTimeout(entry.timer);
611
- }
612
- partials.clear();
613
- unsubscribeSubtitlesAll(channel);
614
- log(`[dc] Session ${tag}: channel closed`);
615
- });
616
-
617
- channel.onError((err) => {
618
- log(`[dc] Session ${tag}: channel error: ${err}`);
619
- });
620
- }
621
-
622
- /**
623
- * Fetch a resource from the local proxy HTTP server and stream the response
624
- * back to the browser over the data channel.
625
- *
626
- * The `Host` header is rewritten to `127.0.0.1:{proxyPort}` so that Fastify
627
- * routes the request correctly regardless of what the browser sent.
628
- *
629
- * @param {DataChannel} channel
630
- * @param {DataChannelRequest} req
631
- * @returns {Promise<void>}
632
- */
633
- async function handleRequest(channel, req, viaChunks = false) {
634
- const { requestId, method, path, query, headers: forwardedHeaders, body } = req;
635
-
636
- // Reject paths that are not absolute, contain traversal sequences, or
637
- // do not start with a known proxy route prefix. All valid browser-side
638
- // requests use /api/*, /stream, /transcode/*, /health, or /healthz.
639
- if (!isValidRequestPath(path)) {
640
- send(channel, { type: "response-error", requestId, error: "Invalid request path." });
641
- return;
642
- }
643
-
644
- // Piggy-backs on the browser's own request for an EMBEDDED track no
645
- // separate subscribe message. `trackIndex` is what tells the two request
646
- // shapes apart: an external subtitle FILE (no trackIndex) names a
647
- // different file's own index in `fileIndex` — the subtitle file's, not the
648
- // video's — and subscribing under that would just be a key nothing ever
649
- // publishes to (an external file is one whole-file read, not something
650
- // this walks incrementally). `fileIndex` alone would also scope this to
651
- // the wrong grain for the real case — a torrent can carry several playable
652
- // files — so the pair is what a push is ever addressed to.
653
- //
654
- // The browser's `sourceKey` is a REGISTRY key — a hash of the raw request
655
- // bytes, one per (magnet-or-.torrent, this API session). The torrent pool
656
- // publishes under its OWN key — the content's infohash, deliberately the
657
- // SAME for a magnet and a `.torrent` naming the same film, so the two
658
- // share one swarm (item 10). The two are different strings for the same
659
- // torrent whenever a source was added by its `.torrent` file (a `.torrent`
660
- // and a magnet are different request bytes, same infohash) — subscribing
661
- // under the registry key found no publisher for that reason, not because
662
- // nothing was ever read: field case 2026-08-22, cues were found and
663
- // logged, every push answered "found no subscribed channel". Resolved to
664
- // the pool's key here, the one place both are in hand.
665
- if (path === "/api/subtitles" && typeof query === "string") {
666
- const params = new URLSearchParams(query);
667
- const registrySourceKey = params.get("sourceKey");
668
- const fileIndex = Number(params.get("fileIndex"));
669
- const hasTrackIndex = params.get("trackIndex") !== null && params.get("trackIndex") !== "";
670
- if (registrySourceKey && Number.isInteger(fileIndex) && hasTrackIndex) {
671
- const record = sourceRegistry?.get(registrySourceKey);
672
- if (record) {
673
- try {
674
- const poolSourceKey = await deriveSourceKey(record.sourceType, record.source);
675
- subscribeSubtitles(poolSourceKey, fileIndex, channel);
676
- } catch (error) {
677
- log(`[dc] subtitle push: could not resolve ${registrySourceKey.slice(0, 8)} to a pool key: ` +
678
- `${error instanceof Error ? error.message : error}`);
679
- }
680
- }
681
- }
682
- }
683
-
684
- const queryInfo = query ? `?${query}` : "";
685
- const bodyInfo =
686
- body != null && typeof body === "string" && body.length > 0
687
- ? ` body=${body.length} bytes${viaChunks ? " (chunked)" : ""}`
688
- : "";
689
- log(`[dc] ${method} ${path}${queryInfo}${bodyInfo}`);
690
-
691
- const targetUrl = `http://127.0.0.1:${proxyPort}${path}${query ? `?${query}` : ""}`;
692
- const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
693
-
694
- let response;
695
- // [net-debug] TEMPORARY: time spent in the local fetch (waiting for the
696
- // route to return a response — e.g. long-polling until an HLS segment is
697
- // finalized by ffmpeg) vs. the body transfer over the data channel.
698
- const fetchStartedAt = Date.now();
699
- try {
700
- response = await fetch(targetUrl, {
701
- method,
702
- headers: requestHeaders,
703
- body: body != null ? body : undefined,
704
- redirect: "manual"
705
- });
706
- } catch (fetchError) {
707
- log(`[dc] ${method} ${path}${queryInfo} → error: ${fetchError?.message ?? String(fetchError)}`);
708
- send(channel, { type: "response-error", requestId, error: fetchError?.message ?? String(fetchError) });
709
- return;
710
- }
711
-
712
- if (response.status !== 200 && response.status !== 206) {
713
- log(`[dc] ${method} ${path}${queryInfo} → ${response.status}`);
714
- }
715
-
716
- /** @type {Record<string, string>} */
717
- const responseHeaders = {};
718
- for (const [name, value] of response.headers.entries()) {
719
- responseHeaders[name] = value;
720
- }
721
-
722
- send(channel, { type: "response-start", requestId, status: response.status, headers: responseHeaders });
723
-
724
- if (!response.body) {
725
- sendChunk(channel, requestId, null, true);
726
- return;
727
- }
728
-
729
- try {
730
- const reader = response.body.getReader();
731
- // [net-debug] TEMPORARY: measure transfer size/time and channel buffering.
732
- // fetchMs = time waiting for the route (incl. ffmpeg segment finalization).
733
- // ttfbMs = time from body-read start to the first chunk with data (loopback).
734
- // sendMs = total body read+send duration over the data channel.
735
- const fetchMs = Date.now() - fetchStartedAt;
736
- const sendStartedAt = Date.now();
737
- let firstByteMs = -1;
738
- let chunks = 0;
739
- let totalBytes = 0;
740
- let maxBuffered = 0;
741
- // Attribute the transfer to the step that actually consumes the time.
742
- // Without this split a slow transfer is indistinguishable between "the
743
- // source is slow", "the channel is slow" and "the event loop is blocked",
744
- // which is exactly the argument a field seek left unresolved.
745
- let readMs = 0;
746
- let sendMs2 = 0;
747
- let drainMs = 0;
748
- resetEventLoopDelay();
749
- while (true) {
750
- const readStartedAt = performance.now();
751
- const { done, value } = await reader.read();
752
- readMs += performance.now() - readStartedAt;
753
- if (done) {
754
- sendChunk(channel, requestId, null, true);
755
- const elapsedMs = Date.now() - sendStartedAt;
756
- let bufferedNow = 0;
757
- try { bufferedNow = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0; } catch { /* ignore */ }
758
- const loop = eventLoopDelay();
759
- const mbps = elapsedMs > 0 ? (totalBytes * 8) / (elapsedMs * 1000) : 0;
760
- log(
761
- `[net-debug] sent ${path}${queryInfo} bytes=${totalBytes} fetchMs=${fetchMs} ` +
762
- `ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
763
- `maxBuffered=${maxBuffered} bufferedAtEnd=${bufferedNow} ` +
764
- // Where the time went: reading the body from the local route,
765
- // handing chunks to the channel, or waiting for its queue. Plus
766
- // the event-loop delay over the same window — a large max here
767
- // means the transfer was blocked by synchronous work, not by the
768
- // network, and the three figures above will all look inflated.
769
- `readMs=${readMs.toFixed(0)} chanMs=${sendMs2.toFixed(0)} drainMs=${drainMs.toFixed(0)} ` +
770
- `loopMean=${loop.meanMs.toFixed(1)} loopP99=${loop.p99Ms.toFixed(1)} loopMax=${loop.maxMs.toFixed(1)} ` +
771
- `rate=${mbps.toFixed(1)}Mbps`
772
- );
773
- break;
774
- }
775
- if (firstByteMs < 0) firstByteMs = Date.now() - sendStartedAt;
776
- chunks += 1;
777
- totalBytes += value.length;
778
- try {
779
- const b = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
780
- if (b > maxBuffered) maxBuffered = b;
781
- } catch { /* ignore */ }
782
- const sendStepAt = performance.now();
783
- sendChunk(channel, requestId, value, false);
784
- sendMs2 += performance.now() - sendStepAt;
785
- // Backpressure: do not keep queuing chunks once the channel's outgoing
786
- // buffer is large — wait for it to drain. Prevents the SCTP send buffer
787
- // from ballooning, which stalls throughput.
788
- const drainStepAt = performance.now();
789
- await waitForBufferDrain(channel);
790
- drainMs += performance.now() - drainStepAt;
791
- }
792
- } catch {
793
- sendChunk(channel, requestId, null, true);
794
- }
795
- }
796
-
797
- /**
798
- * Send a response body frame as a BINARY data-channel message.
799
- * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
800
- *
801
- * @param {DataChannel} channel
802
- * @param {string} requestId
803
- * @param {Uint8Array | null} bytes - Body bytes, or null/empty for the done frame.
804
- * @param {boolean} done
805
- * @returns {void}
806
- */
807
- /**
808
- * The request id as bytes, prepared once per request rather than per chunk.
809
- *
810
- * A segment is a couple of hundred chunks, and each one was re-encoding the
811
- * same 32-character string. The map is bounded because request ids are
812
- * short-lived and unbounded in number — dropping the whole cache when it
813
- * grows costs one re-encode per live request and cannot leak.
814
- *
815
- * @param {string} requestId
816
- * @returns {Buffer}
817
- */
818
- function requestIdBytes(requestId) {
819
- let bytes = requestIdCache.get(requestId);
820
- if (!bytes) {
821
- if (requestIdCache.size > 64) {
822
- requestIdCache.clear();
823
- }
824
- bytes = Buffer.from(requestId, "ascii");
825
- requestIdCache.set(requestId, bytes);
826
- }
827
- return bytes;
828
- }
829
-
830
- function sendChunk(channel, requestId, bytes, done) {
831
- try {
832
- channel.sendMessageBinary(encodeFrame(requestIdBytes(requestId), bytes, done));
833
- } catch {
834
- // Channel closed between check and send safe to ignore.
835
- }
836
- }
837
-
838
- /**
839
- * Resolve once the channel's outgoing buffer has drained below the low-water
840
- * mark. No-op (resolves immediately) when the buffer is already small or the
841
- * channel does not expose buffer APIs. A timeout fallback guards against a
842
- * missed low-water event so the send loop can never deadlock.
843
- *
844
- * @param {DataChannel} channel
845
- * @returns {Promise<void>}
846
- */
847
- function waitForBufferDrain(channel) {
848
- return new Promise((resolve) => {
849
- try {
850
- if (typeof channel.bufferedAmount !== "function" || channel.bufferedAmount() <= DC_BUFFER_HIGH_WATER) {
851
- resolve();
852
- return;
853
- }
854
- let settled = false;
855
- const done = () => {
856
- if (settled) return;
857
- settled = true;
858
- resolve();
859
- };
860
- channel.setBufferedAmountLowThreshold(DC_BUFFER_LOW_WATER);
861
- channel.onBufferedAmountLow(done);
862
- // Guard against a race where the buffer drained between the check above
863
- // and registering the callback (the low-water event would never fire).
864
- if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
865
- done();
866
- return;
867
- }
868
- setTimeout(done, DC_BUFFER_DRAIN_TIMEOUT_MS);
869
- } catch {
870
- resolve();
871
- }
872
- });
873
- }
874
-
875
- /**
876
- * Serialise `message` to JSON and send it over the data channel.
877
- * Errors are silently swallowed the channel may have closed between
878
- * the open check and the actual send.
879
- *
880
- * @param {DataChannel} channel
881
- * @param {object} message
882
- * @returns {void}
883
- */
884
- function send(channel, message) {
885
- try {
886
- channel.sendMessage(JSON.stringify(message));
887
- } catch {
888
- // Channel closed between check and send — safe to ignore.
889
- }
890
- }
891
-
892
- return { handleChannel, publishSubtitleCues };
893
- }
894
-
895
- /**
896
- * Allowed path prefixes for data-channel requests.
897
- * Only the known proxy API and streaming routes are accepted.
898
- */
899
- const PATH_ALLOWLIST_RE = /^(?:\/api\/|\/stream(?:$|\?)|\/?transcode\/|\/health(?:z)?(?:$|\?))/;
900
-
901
- /**
902
- * True when `path` is an absolute, traversal-free path on a known proxy route.
903
- * Shared by the single-message and chunked request entry points.
904
- *
905
- * @param {unknown} path
906
- * @returns {boolean}
907
- */
908
- function isValidRequestPath(path) {
909
- return (
910
- typeof path === "string" &&
911
- path.startsWith("/") &&
912
- !path.includes("..") &&
913
- PATH_ALLOWLIST_RE.test(path)
914
- );
915
- }
916
-
917
- /** Max assembled size of a chunked request body (guards proxy memory). */
918
- const PROXY_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024;
919
- /** Drop an incomplete chunked body if no further frame arrives within this window. */
920
- const PARTIAL_REQUEST_TTL_MS = 60_000;
921
-
922
- /** Pause sending body chunks once the channel buffer exceeds this many bytes. */
923
- // How often the send queue is sampled, and how long it must fail to fall
924
- // before the transport is asked what it is doing. Five seconds is far longer
925
- // than any healthy burst drains in — measured, a 6-11 MB segment leaves in
926
- // well under a second on the LAN — and short enough that a stuck channel is
927
- // named while the viewer is still looking at it.
928
- const SEND_QUEUE_SAMPLE_MS = 1_000;
929
- // How often the transport's own counters are written to the log, whatever the
930
- // send queue is doing. Frequent enough to place a loss within a few seconds,
931
- // sparse enough that a two-hour film costs a few hundred lines.
932
- const TRANSPORT_HEARTBEAT_MS = 5_000;
933
-
934
- // How many heartbeats in a row may find no transport for this session before
935
- // the watch gives up. Several rather than one, so a momentary gap in the
936
- // registry does not end a healthy watch.
937
- const TRANSPORT_UNKNOWN_HEARTBEATS = 3;
938
- const SEND_QUEUE_STUCK_MS = 5_000;
939
- // How long a wedged queue waits before the packet witness starts recording the
940
- // wire. Long enough to be certain this is not a slow drain (a 6-11 MB segment
941
- // leaves in well under a second, measured), short enough that the capture is
942
- // still running while whatever broke is breaking — the 2026-08-24 episode
943
- // began minutes before anyone could have asked for a capture by hand.
944
- const SEND_QUEUE_CAPTURE_AFTER_MS = 30_000;
945
- const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
946
- /** Resume sending once the channel buffer drains to this many bytes. */
947
- const DC_BUFFER_LOW_WATER = 1 * 1024 * 1024;
948
- /** Safety fallback so the send loop cannot deadlock on a missed drain event. */
949
- 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
+ * { type: "probe-echo", seen: { <label>: seq }, report }
16
+ * ```
17
+ *
18
+ * Proxy → Browser
19
+ * ```
20
+ * { type: "probe", seq, sentAt } (JSON string)
21
+ * { type: "response-start", requestId, status, headers } (JSON string)
22
+ * { type: "response-error", requestId, error: string } (JSON string)
23
+ * { type: "pong", id } (JSON string)
24
+ * { type: "subtitle-cues", fileIndex, trackIndex, cues, language, cursor } (JSON string)
25
+ * ```
26
+ * The last one is unsolicited — sent the moment new cues are read from a
27
+ * file's already-downloaded pieces, to whichever channel last asked for that
28
+ * file's subtitles over `/api/subtitles`. Not a response to any `requestId`.
29
+ *
30
+ * Response bodies are sent as BINARY data-channel messages (not JSON), to
31
+ * avoid the ~33% base64 overhead and the JSON encode/decode cost. Each binary
32
+ * frame is laid out as:
33
+ * ```
34
+ * byte 0 flags (bit 0: done)
35
+ * byte 1 idLen (length of the requestId in bytes)
36
+ * bytes 2..2+N requestId (ASCII)
37
+ * bytes 2+N.. payload (raw body bytes; empty on the final done frame)
38
+ * ```
39
+ * Control messages stay JSON strings so the browser can distinguish them from
40
+ * body frames by message type (string vs ArrayBuffer).
41
+ *
42
+ * The protocol mirrors the tunnel relay protocol so both transports share
43
+ * the same mental model and the same browser-side `WebRtcProxy` implementation.
44
+ */
45
+
46
+ /** @import { DataChannel } from 'node-datachannel' */
47
+
48
+ import { deriveSourceKey } from "./torrent-source-key.js";
49
+ import { createDeliveryProbe, PROBE_INTERVAL_MS } from "./delivery-probe.js";
50
+
51
+ /**
52
+ * Configuration for the data channel handler.
53
+ *
54
+ * @typedef {Object} DataChannelHandlerOptions
55
+ * @property {number} proxyPort
56
+ * Local port the proxy's Fastify HTTP server is listening on.
57
+ * Incoming requests are forwarded to `http://127.0.0.1:{proxyPort}`.
58
+ * @property {(message: string) => void} [onLog]
59
+ * Optional log sink.
60
+ * @property {{ maybeCapture: (trigger: {
61
+ * sessionId: string, tag: string, label: string,
62
+ * remote: { address: string, port: number } | null,
63
+ * queuedBytes: number, stuckForMs: number
64
+ * }) => boolean }} [witness]
65
+ * The packet witness (services/packet-witness.js). When {@link wedgeIsCertain}
66
+ * says delivery has stopped, the watcher hands it the transport snapshot's
67
+ * remote endpoint: the ring's history is kept and a tail capture records what
68
+ * the wire actually does. Optional; absent means no captures are taken.
69
+ */
70
+
71
+ /**
72
+ * An incoming request message received over the data channel.
73
+ *
74
+ * @typedef {Object} DataChannelRequest
75
+ * @property {string} requestId
76
+ * @property {string} method - HTTP method (GET, POST, …).
77
+ * @property {string} path - Request path (e.g. "/api/sources").
78
+ * @property {string} query - Raw query string without the leading "?".
79
+ * @property {Record<string, string>} headers - Headers to forward.
80
+ * @property {string | null} body - Request body string, or null.
81
+ */
82
+
83
+ /**
84
+ * The object returned by {@link createDataChannelHandler}.
85
+ *
86
+ * @typedef {Object} DataChannelHandler
87
+ * @property {(sessionId: string, channel: DataChannel) => void} handleChannel
88
+ * Wire message handlers onto a freshly opened data channel.
89
+ */
90
+
91
+ /**
92
+ * How long a wedge must hold before the packet witness is asked for evidence.
93
+ *
94
+ * Derived per connection rather than chosen, because a chosen number is what
95
+ * cost the two field captures their onset: the previous rule waited a flat 30 s
96
+ * and the recording therefore began half a minute after the interesting part.
97
+ *
98
+ * Three quantities, all measured on this connection:
99
+ *
100
+ * the queue's own drain time `queuedBytes / bytesPerSecond`, how long a
101
+ * healthy channel would need to clear what is sitting in it, at the best rate
102
+ * this very connection has been seen to move bytes at;
103
+ *
104
+ * the longest this connection has EVER paused while healthy — an ordinary
105
+ * retransmission timeout stops the accepted-byte counter dead for as long as
106
+ * it lasts, because a full send buffer accepts nothing, and a link with loss
107
+ * does that routinely. The longest such pause already observed here is what
108
+ * the link's own behaviour says a legitimate pause looks like;
109
+ *
110
+ * the interval at which we offer bytes at all — the delivery probe hands
111
+ * every channel a message every {@linkcode PROBE_INTERVAL_MS}, so in health
112
+ * the accepted-byte counter cannot stand still for longer than that.
113
+ *
114
+ * A wedge is certain once ALL of them have passed with the counter unmoved.
115
+ * Without a rate there is nothing to divide by, and the function says so
116
+ * instead of guessing.
117
+ *
118
+ * @param {{ queuedBytes: number, bytesPerSecond: number, flatForMs: number, longestHealthyFlatMs?: number }} state
119
+ * @returns {{ certain: boolean, needMs: number | null }}
120
+ */
121
+ export function wedgeIsCertain({ queuedBytes, bytesPerSecond, flatForMs, longestHealthyFlatMs = 0 }) {
122
+ if (!(queuedBytes > 0) || !(bytesPerSecond > 0)) {
123
+ return { certain: false, needMs: null };
124
+ }
125
+ const drainMs = (queuedBytes / bytesPerSecond) * 1000;
126
+ const needMs = Math.max(drainMs, longestHealthyFlatMs, PROBE_INTERVAL_MS);
127
+ return { certain: flatForMs >= needMs, needMs };
128
+ }
129
+
130
+ /**
131
+ * Watch one channel's send queue and, when it stops draining, say WHY.
132
+ *
133
+ * A channel that is open, keeps accepting requests and delivers nothing was
134
+ * seen in the field 2026-08-06: the queue grew from 214 049 to 239 731 bytes in
135
+ * fourteen seconds and never fell, while every layer above reported success
136
+ * the route answered in 15 ms, the handler sent 378 bytes, the channel was
137
+ * open. The viewer sat in front of a spinner for eleven minutes.
138
+ *
139
+ * `bufferedAmount` alone cannot say why: it only proves the bytes are still
140
+ * OURS. The transport counters can, and this is the table the snapshot is read
141
+ * against written down in advance so the answer is a reading, not an opinion:
142
+ *
143
+ * bytesSent rising, queue rising → packets leave, nothing acknowledges
144
+ * them: the return path is broken.
145
+ * bytesSent flat, queue rising → SCTP is not transmitting: the peer's
146
+ * receive window is shut or congestion
147
+ * control has collapsed.
148
+ * bytesReceived rising either way → the peer is alive and its packets do
149
+ * reach us; the failure is one-way.
150
+ * both flat → nothing crosses at all.
151
+ *
152
+ * Sampled every second; reported only once the queue has failed to fall for
153
+ * {@link SEND_QUEUE_STUCK_MS}, then every second while it lasts, so the trend
154
+ * of every counter is in the log rather than one snapshot of it.
155
+ *
156
+ * @param {string} sessionId
157
+ * @param {string} tag
158
+ * @param {string} label
159
+ * @param {DataChannel} channel
160
+ * @returns {() => void} Stops the watch.
161
+ */
162
+ function makeSendQueueWatcher({ log, getTransportSnapshot, witness }) {
163
+ // Every channel of one connection reads the SAME transport counters — the
164
+ // snapshot describes the peer connection, not the channel — so the heartbeat
165
+ // belongs to the connection and is printed once for it. Printed per channel
166
+ // it produced two byte-for-byte identical lines (measured 2026-08-14:
167
+ // `sent=5153491` under both "proxy" and "proxy-control"), which read as two
168
+ // independent readings agreeing and made the second channel invisible: the
169
+ // one thing that IS per channel, its queue depth, was the only real
170
+ // difference and it was buried in a line that looked like a duplicate.
171
+ //
172
+ // sessionId → the channels currently open on that connection, and when it was
173
+ // last reported. Channels are keyed by the channel OBJECT, not by its label:
174
+ // a label is whatever the peer chose and two channels can carry the same one
175
+ // (or none, where `getLabel` is missing and both fall back to "?"), and a
176
+ // Map keyed on that would let one channel evict the other and then, on
177
+ // closing, delete the survivor's entry. `captureStarted` rides on the same
178
+ // record: both channels of one wedged connection must ask the witness once,
179
+ // not once per channel.
180
+ /** @type {Map<string, { channels: Map<DataChannel, string>, at: number, previous: object | null, unknown: number, captureStarted: boolean }>} */
181
+ const connections = new Map();
182
+
183
+ /**
184
+ * What each channel of a connection is holding, right now.
185
+ *
186
+ * @param {Map<DataChannel, string>} channels
187
+ * @returns {string} `label:NB` per channel, in the order they opened.
188
+ */
189
+ const queueDepths = (channels) => {
190
+ const parts = [];
191
+ for (const [openChannel, channelLabel] of channels) {
192
+ let depth = -1;
193
+ try {
194
+ depth = typeof openChannel.bufferedAmount === "function" ? openChannel.bufferedAmount() : 0;
195
+ } catch {
196
+ depth = -1;
197
+ }
198
+ parts.push(`${channelLabel}:${depth}B`);
199
+ }
200
+ return parts.join(" ");
201
+ };
202
+
203
+ /**
204
+ * What this connection is getting away, for whoever else needs it.
205
+ *
206
+ * The delivery probe judges a late probe against the queue ahead of it, and
207
+ * the queue's drain time needs a rate. It is measured here already, once a
208
+ * second, so it is read from here rather than measured twice.
209
+ *
210
+ * @param {string} sessionId
211
+ * @returns {{ bytesPerSecond: number, rttMs: number } | null}
212
+ */
213
+ const readDelivery = (sessionId) => {
214
+ const connection = connections.get(sessionId);
215
+ if (!connection) {
216
+ return null;
217
+ }
218
+ return {
219
+ bytesPerSecond: connection.bytesPerSecond,
220
+ rttMs: Number(connection.previous?.rtt) || 0
221
+ };
222
+ };
223
+
224
+ /**
225
+ * @param {string} sessionId
226
+ * @param {string} tag
227
+ * @param {string} label
228
+ * @param {DataChannel} channel
229
+ * @returns {() => void} Stops the watch.
230
+ */
231
+ const watchSendQueue = (sessionId, tag, label, channel) => {
232
+ let lowestSinceDrain = Number.POSITIVE_INFINITY;
233
+ let stuckSince = 0;
234
+ let previous = null;
235
+ // The peer's byte count when this queue stopped falling. What separates a
236
+ // wedge from an ordinary dead connection is that the far end keeps sending
237
+ // throughout measured across the whole wedge window rather than sampled
238
+ // in a one-second slice, because the browser polls every 1.5 s and plenty
239
+ // of individual seconds are legitimately empty.
240
+ let receivedWhenStuck = -1;
241
+ // Per CHANNEL, not per connection: `proxy-control` and `proxy-fast` hold an
242
+ // empty queue in health and tick every second, so a flag shared with them
243
+ // would be cleared a second after the wedged channel set it and the line
244
+ // would print for every second of a 54-minute episode.
245
+ let wedgeSaid = false;
246
+ let connection = connections.get(sessionId);
247
+ if (!connection) {
248
+ connection = {
249
+ channels: new Map(),
250
+ at: 0,
251
+ previous: null,
252
+ unknown: 0,
253
+ captureStarted: false,
254
+ // The accepted-byte counter and when it last moved, plus the rate it
255
+ // was moving at. `wedgeIsCertain` divides the queue by that rate.
256
+ rateAt: 0,
257
+ sentAt: 0,
258
+ sentBytes: 0,
259
+ bytesPerSecond: 0,
260
+ longestHealthyFlatMs: 0
261
+ };
262
+ connections.set(sessionId, connection);
263
+ }
264
+ connection.channels.set(channel, label);
265
+ /** @type {ReturnType<typeof setInterval> | null} */
266
+ let timer = null;
267
+ let stopped = false;
268
+ // Record the wire for as long as this channel is open. Held here rather
269
+ // than beside `onClosed`, because `onClosed` does not always come a peer
270
+ // connection can die without it and the watch below already ends itself
271
+ // when the transport stops answering. A hold that outlives its channel
272
+ // would leave tcpdump writing on an idle proxy for the life of the process.
273
+ witness?.holdRing?.();
274
+ /**
275
+ * End this channel's watch and let go of its entry.
276
+ *
277
+ * @returns {void}
278
+ */
279
+ const stop = () => {
280
+ if (stopped) {
281
+ return;
282
+ }
283
+ stopped = true;
284
+ witness?.releaseRing?.();
285
+ if (timer) {
286
+ clearInterval(timer);
287
+ }
288
+ connection.channels.delete(channel);
289
+ // Only if the map still holds THIS record: a late stop, after the same
290
+ // session id has been reused and a new record made for it, must not evict
291
+ // the live one.
292
+ if (connection.channels.size === 0 && connections.get(sessionId) === connection) {
293
+ connections.delete(sessionId);
294
+ }
295
+ };
296
+ // Independent of the queue: the transport's own counters, sampled for as
297
+ // long as the channel is open. The queue was the wrong thing to watch —
298
+ // field 2026-08-06, a 9.26 MB segment was accepted by the transport with
299
+ // `maxBuffered=0 bufferedAtEnd=0`, reported as sent at 274 Mbit/s, and
300
+ // never arrived; everything the proxy sent from that moment on was lost the
301
+ // same way while requests kept coming the other direction. With nothing
302
+ // queued this watcher never woke, so the one question that matters — did
303
+ // those bytes leave the machine — has no answer in the log. It does now.
304
+ // A connection the transport no longer knows about is gone, whatever the
305
+ // channel says. `onClosed` is the ordinary way this watch ends, and it does
306
+ // not always come — a peer connection can die without it, leaving the timer
307
+ // and this channel's entry behind for the life of the process.
308
+ //
309
+ // The count is kept on the CONNECTION: exactly one channel enters the
310
+ // heartbeat branch per interval, so a per-channel count would advance only
311
+ // on that channel's turn and the teardown would take three heartbeats per
312
+ // channel rather than three in total.
313
+ timer = setInterval(() => {
314
+ const sampledAt = Date.now();
315
+ // Whichever channel's timer arrives first past the interval reports for
316
+ // the whole connection; the others find the timestamp already moved and
317
+ // skip. So the line appears once however many channels are open.
318
+ if (sampledAt - connection.at >= TRANSPORT_HEARTBEAT_MS) {
319
+ connection.at = sampledAt;
320
+ const snapshot = getTransportSnapshot?.(sessionId) ?? null;
321
+ connection.unknown = snapshot ? 0 : connection.unknown + 1;
322
+ if (connection.unknown >= TRANSPORT_UNKNOWN_HEARTBEATS) {
323
+ stop();
324
+ return;
325
+ }
326
+ if (snapshot) {
327
+ const sent = connection.previous ? snapshot.bytesSent - connection.previous.bytesSent : null;
328
+ const received = connection.previous
329
+ ? snapshot.bytesReceived - connection.previous.bytesReceived
330
+ : null;
331
+ connection.previous = snapshot;
332
+ log(
333
+ `[dc-transport] ${tag} sent=${snapshot.bytesSent}` +
334
+ `${sent === null ? "" : ` (+${sent})`} received=${snapshot.bytesReceived}` +
335
+ `${received === null ? "" : ` (+${received})`} queued[${queueDepths(connection.channels)}] ` +
336
+ `rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
337
+ );
338
+ }
339
+ }
340
+ // The rate this connection accepts bytes at, and how long that counter
341
+ // has stood still — both measured every second, whatever the queue is
342
+ // doing, because the rate has to come from the HEALTHY stretch that
343
+ // precedes a wedge. One channel updates it for the whole connection.
344
+ if (sampledAt - connection.rateAt >= SEND_QUEUE_SAMPLE_MS) {
345
+ connection.rateAt = sampledAt;
346
+ const snapshot = getTransportSnapshot?.(sessionId) ?? null;
347
+ const sentNow = Number(snapshot?.bytesSent);
348
+ if (Number.isFinite(sentNow) && sentNow >= 0) {
349
+ if (connection.sentAt === 0 || sentNow < connection.sentBytes) {
350
+ // First reading, or the counter went backwards — a fresh peer
351
+ // connection reusing this session id. Either way the old baseline
352
+ // describes a transport that no longer exists, so start over
353
+ // rather than measure a pause against it for ever.
354
+ connection.sentBytes = sentNow;
355
+ connection.sentAt = sampledAt;
356
+ } else if (sentNow > connection.sentBytes) {
357
+ const seconds = (sampledAt - connection.sentAt) / 1000;
358
+ if (seconds > 0) {
359
+ // The BEST rate this connection has shown, not the latest one.
360
+ // The latest is usually the quietest: with the browser's buffer
361
+ // full nothing is requested for tens of seconds and the only
362
+ // traffic is the probe, a few hundred bytes a second. Dividing a
363
+ // queue by that gives hours, and the wedge would never be called.
364
+ const rate = (sentNow - connection.sentBytes) / seconds;
365
+ if (rate > connection.bytesPerSecond) {
366
+ connection.bytesPerSecond = rate;
367
+ }
368
+ }
369
+ // How long the counter stood still before this advance. While the
370
+ // queue is draining that pause was legitimate, so it is the link's
371
+ // own answer to "how long may a healthy pause be".
372
+ const pausedMs = sampledAt - connection.sentAt;
373
+ if (stuckSince === 0 && pausedMs > connection.longestHealthyFlatMs) {
374
+ connection.longestHealthyFlatMs = pausedMs;
375
+ }
376
+ connection.sentBytes = sentNow;
377
+ connection.sentAt = sampledAt;
378
+ }
379
+ }
380
+ }
381
+ let queued = 0;
382
+ try {
383
+ queued = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
384
+ } catch {
385
+ return;
386
+ }
387
+ if (queued === 0 || queued < lowestSinceDrain) {
388
+ lowestSinceDrain = queued;
389
+ stuckSince = 0;
390
+ previous = null;
391
+ receivedWhenStuck = -1;
392
+ wedgeSaid = false;
393
+ // The queue moved, so whatever was called a wedge has cleared. Let a
394
+ // later one be recorded too: one mistaken call must not spend the
395
+ // session's only capture.
396
+ connection.captureStarted = false;
397
+ return;
398
+ }
399
+ const now = Date.now();
400
+ if (stuckSince === 0) {
401
+ stuckSince = now;
402
+ return;
403
+ }
404
+ const snapshot = getTransportSnapshot?.(sessionId) ?? null;
405
+ if (!snapshot) {
406
+ if (now - stuckSince >= SEND_QUEUE_STUCK_MS) {
407
+ log(`[dc] Session ${tag} "${label}": send queue stuck at ${queued}B for ` +
408
+ `${Math.round((now - stuckSince) / 1000)}s no transport to ask`);
409
+ }
410
+ return;
411
+ }
412
+ const sentDelta = previous ? snapshot.bytesSent - previous.bytesSent : null;
413
+ const recvDelta = previous ? snapshot.bytesReceived - previous.bytesReceived : null;
414
+ previous = snapshot;
415
+ if (receivedWhenStuck < 0) {
416
+ receivedWhenStuck = snapshot.bytesReceived;
417
+ }
418
+ // The periodic line waits for {@link SEND_QUEUE_STUCK_MS}, because a
419
+ // queue that has merely not fallen for a second is ordinary and a line a
420
+ // second for it is noise. The WEDGE below does not wait for it: its own
421
+ // condition already says how long this queue may legitimately take, and
422
+ // on a fast link that is under a second. Holding the evidence back for a
423
+ // fixed five seconds would repeat, in miniature, the mistake that left
424
+ // both field captures without an onset in them.
425
+ if (now - stuckSince >= SEND_QUEUE_STUCK_MS) {
426
+ log(
427
+ `[dc] Session ${tag} "${label}": send queue stuck at ${queued}B for ` +
428
+ `${Math.round((now - stuckSince) / 1000)}s — transport ` +
429
+ `sent=${snapshot.bytesSent}${sentDelta === null ? "" : ` (+${sentDelta})`} ` +
430
+ `received=${snapshot.bytesReceived}${recvDelta === null ? "" : ` (+${recvDelta})`} ` +
431
+ `rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
432
+ );
433
+ }
434
+ // Roadmap item 11: ask for the packet-level truth the moment the wedge is
435
+ // CERTAIN, not after a chosen delay. The three facts below are not
436
+ // ambiguous together — the queue has not fallen, the accepted-byte
437
+ // counter has not moved for longer than the queue's own drain time at
438
+ // this link's own speed, and the peer is still sending. The previous
439
+ // rule's flat 30 s is what left both field captures with no onset in
440
+ // them. One attempt per connection the witness applies its own
441
+ // single-flight and cooldown rules after that.
442
+ const flatForMs = connection.sentAt === 0 ? 0 : now - connection.sentAt;
443
+ const verdict = wedgeIsCertain({
444
+ queuedBytes: queued,
445
+ bytesPerSecond: connection.bytesPerSecond,
446
+ flatForMs,
447
+ longestHealthyFlatMs: connection.longestHealthyFlatMs
448
+ });
449
+ const peerStillSending = snapshot.bytesReceived > receivedWhenStuck;
450
+ if (verdict.certain && peerStillSending && !wedgeSaid) {
451
+ wedgeSaid = true;
452
+ log(
453
+ `[dc] Session ${tag} "${label}": delivery has stopped ${queued}B queued, ` +
454
+ `accepted-byte counter unmoved for ${Math.round(flatForMs / 1000)}s against the ` +
455
+ `${(verdict.needMs / 1000).toFixed(1)}s this queue needs at the ` +
456
+ `${(connection.bytesPerSecond / 1024).toFixed(0)} KB/s last measured here, ` +
457
+ "and the peer is still sending"
458
+ );
459
+ }
460
+ if (
461
+ witness &&
462
+ !connection.captureStarted &&
463
+ verdict.certain &&
464
+ peerStillSending
465
+ ) {
466
+ connection.captureStarted = true;
467
+ const started = witness.maybeCapture({
468
+ sessionId,
469
+ tag,
470
+ label,
471
+ remote: snapshot.remote ?? null,
472
+ queuedBytes: queued,
473
+ stuckForMs: now - stuckSince
474
+ });
475
+ if (!started) {
476
+ // Refused for now (no remote endpoint yet, capture already running
477
+ // elsewhere, cooldown): let the next tick try again rather than
478
+ // spending the one attempt per wedge on a refusal.
479
+ connection.captureStarted = false;
480
+ }
481
+ }
482
+ }, SEND_QUEUE_SAMPLE_MS);
483
+
484
+ if (typeof timer.unref === "function") {
485
+ timer.unref();
486
+ }
487
+ return stop;
488
+ };
489
+
490
+ return { watchSendQueue, readDelivery };
491
+ }
492
+
493
+ /**
494
+ * Create a handler for incoming WebRTC data channels.
495
+ *
496
+ * @param {DataChannelHandlerOptions} options
497
+ * @returns {DataChannelHandler}
498
+ */
499
+ import { performance } from "node:perf_hooks";
500
+ import { eventLoopDelay, resetEventLoopDelay } from "../utils/perf.js";
501
+
502
+ /**
503
+ * Build one body frame: `[flags(1)][idLen(1)][requestId][payload]`.
504
+ *
505
+ * One allocation and one copy. The previous version made two of each — a copy
506
+ * of the chunk into a `Buffer`, then a `concat` that copied it again into the
507
+ * frame which measured 75.9 ms per 13 MB segment on the field host against
508
+ * 40.0 ms this way, and allocated ~600 extra buffers over a segment's 208
509
+ * chunks. One copy is the floor: chunks arrive from a web stream that allocates
510
+ * them itself, so there is no buffer of ours to read them into.
511
+ *
512
+ * @param {Buffer} idBytes - The request id, already encoded.
513
+ * @param {Uint8Array | null} bytes - Payload, or nothing for the done frame.
514
+ * @param {boolean} done
515
+ * @returns {Buffer}
516
+ */
517
+ export function encodeFrame(idBytes, bytes, done) {
518
+ const payloadLength = bytes?.length ?? 0;
519
+ const frame = Buffer.allocUnsafe(2 + idBytes.length + payloadLength);
520
+ frame[0] = done ? 1 : 0;
521
+ frame[1] = idBytes.length;
522
+ idBytes.copy(frame, 2);
523
+ if (payloadLength > 0) {
524
+ frame.set(bytes, 2 + idBytes.length);
525
+ }
526
+ return frame;
527
+ }
528
+
529
+ export function createDataChannelHandler({ proxyPort, onLog, getTransportSnapshot, sourceRegistry, witness }) {
530
+ /**
531
+ * Channels currently interested in one file's subtitle cues, keyed by
532
+ * `sourceKey:fileIndex`. Populated the moment a browser asks for an
533
+ * embedded track there is no separate subscribe message on the wire, the
534
+ * existing `/api/subtitles` request already says which file a viewer opened
535
+ * subtitles for. Pruned on channel close and, defensively, on a failed send.
536
+ *
537
+ * @type {Map<string, Set<DataChannel>>}
538
+ */
539
+ const subtitleSubscribers = new Map();
540
+
541
+ /**
542
+ * @param {string} sourceKey
543
+ * @param {number} fileIndex
544
+ * @param {DataChannel} channel
545
+ * @returns {void}
546
+ */
547
+ function subscribeSubtitles(sourceKey, fileIndex, channel) {
548
+ const key = `${sourceKey}:${fileIndex}`;
549
+ let set = subtitleSubscribers.get(key);
550
+ if (!set) {
551
+ set = new Set();
552
+ subtitleSubscribers.set(key, set);
553
+ }
554
+ const isNew = !set.has(channel);
555
+ set.add(channel);
556
+ if (isNew) {
557
+ log(`[dc] subtitle push: channel subscribed to ${key} (${set.size} channel(s) now)`);
558
+ }
559
+ }
560
+
561
+ /** @param {DataChannel} channel */
562
+ function unsubscribeSubtitlesAll(channel) {
563
+ for (const set of subtitleSubscribers.values()) {
564
+ set.delete(channel);
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Send new cues to every channel watching this file — the push side of
570
+ * subtitles arriving as they download rather than being polled for. Cues
571
+ * are tiny (kilobytes at most for a whole track), so this is one message,
572
+ * not a stream.
573
+ *
574
+ * @param {{ sourceKey: string, fileIndex: number, trackIndex: number, cues: object[], language: string, cursor: number }} event
575
+ * @returns {void}
576
+ */
577
+ function publishSubtitleCues({ sourceKey, fileIndex, trackIndex, cues, language, cursor }) {
578
+ const set = subtitleSubscribers.get(`${sourceKey}:${fileIndex}`);
579
+ if (!set || set.size === 0) {
580
+ log(
581
+ `[dc] subtitle push: ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
582
+ "found no subscribed channel"
583
+ );
584
+ return;
585
+ }
586
+ const message = { type: "subtitle-cues", fileIndex, trackIndex, cues, language, cursor };
587
+ const total = set.size;
588
+ let sent = 0;
589
+ for (const channel of set) {
590
+ try {
591
+ channel.sendMessage(JSON.stringify(message));
592
+ sent += 1;
593
+ } catch {
594
+ // Closed between the subscription and this send; onClosed will not
595
+ // fire for a channel that is already gone, so drop it here too.
596
+ set.delete(channel);
597
+ }
598
+ }
599
+ log(
600
+ `[dc] subtitle push: sent ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
601
+ `to ${sent}/${total} channel(s)`
602
+ );
603
+ }
604
+
605
+ /** Request id → its ASCII bytes; see {@link requestIdBytes}. */
606
+ const requestIdCache = new Map();
607
+
608
+ const { watchSendQueue, readDelivery } = makeSendQueueWatcher({
609
+ log: (message) => log(message),
610
+ getTransportSnapshot,
611
+ witness
612
+ });
613
+ // Numbered probes on every channel, and the browser's echo of what it saw.
614
+ // The proxy's own counters cannot say whether bytes it handed to usrsctp were
615
+ // ever put on the wire; the far end can, and it keeps answering throughout a
616
+ // freeze. See services/delivery-probe.js.
617
+ const deliveryProbe = createDeliveryProbe({ log: (message) => log(message), readDelivery });
618
+
619
+ /**
620
+ * @param {string} message
621
+ * @returns {void}
622
+ */
623
+ function log(message) {
624
+ if (typeof onLog === "function") {
625
+ onLog(message);
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Wire up the `onMessage`, `onClosed`, and `onError` handlers for a channel.
631
+ *
632
+ * @param {string} sessionId
633
+ * @param {DataChannel} channel
634
+ * @returns {void}
635
+ */
636
+ function handleChannel(sessionId, channel) {
637
+ const tag = sessionId.slice(0, 8);
638
+ const label = typeof channel.getLabel === "function" ? channel.getLabel() : "?";
639
+ log(`[dc] Session ${tag}: channel open`);
640
+ const stopWatchdog = watchSendQueue(sessionId, tag, label, channel);
641
+ deliveryProbe.attach(sessionId, tag, label, channel);
642
+
643
+ // Partial chunked-request bodies in flight on THIS channel, keyed by
644
+ // requestId. Each entry buffers frames until the done frame, then runs the
645
+ // assembled request through the same path as a single-message request.
646
+ /** @type {Map<string, { meta: object, chunks: Buffer[], receivedBytes: number, bodyBytes: number, timer: ReturnType<typeof setTimeout> }>} */
647
+ const partials = new Map();
648
+
649
+ const dropPartial = (requestId) => {
650
+ const entry = partials.get(requestId);
651
+ if (entry) {
652
+ clearTimeout(entry.timer);
653
+ partials.delete(requestId);
654
+ }
655
+ };
656
+
657
+ /**
658
+ * Begin assembling a chunked request. Validates the path and size up front
659
+ * so an invalid or oversized request never buffers a body.
660
+ *
661
+ * @param {any} message - The `request-start` control message.
662
+ */
663
+ const startPartialRequest = (message) => {
664
+ const { requestId, method, path, query, headers, bodyBytes } = message ?? {};
665
+ if (typeof requestId !== "string" || requestId.length === 0) {
666
+ return;
667
+ }
668
+ if (!isValidRequestPath(path)) {
669
+ send(channel, { type: "response-error", requestId, error: "Invalid request path." });
670
+ return;
671
+ }
672
+ if (!Number.isInteger(bodyBytes) || bodyBytes < 0 || bodyBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
673
+ send(channel, { type: "response-error", requestId, error: "Request body too large." });
674
+ return;
675
+ }
676
+ dropPartial(requestId); // replace any stale entry with the same id
677
+ const timer = setTimeout(() => {
678
+ const entry = partials.get(requestId);
679
+ partials.delete(requestId);
680
+ log(`[dc] Session ${tag}: dropped stale partial request ${requestId.slice(0, 8)} (${entry?.receivedBytes ?? 0}B)`);
681
+ }, PARTIAL_REQUEST_TTL_MS);
682
+ partials.set(requestId, {
683
+ meta: { requestId, method, path, query, headers },
684
+ chunks: [],
685
+ receivedBytes: 0,
686
+ bodyBytes,
687
+ timer
688
+ });
689
+ };
690
+
691
+ /**
692
+ * Handle a binary body frame for a chunked request.
693
+ * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
694
+ *
695
+ * @param {Buffer} buf
696
+ */
697
+ const handleBodyFrame = (buf) => {
698
+ if (buf.length < 2) {
699
+ return;
700
+ }
701
+ const flags = buf[0];
702
+ const idLen = buf[1];
703
+ if (buf.length < 2 + idLen) {
704
+ return;
705
+ }
706
+ const requestId = buf.toString("ascii", 2, 2 + idLen);
707
+ const entry = partials.get(requestId);
708
+ if (!entry) {
709
+ return; // stale / already-dropped / aborted
710
+ }
711
+ if (flags & 2) {
712
+ // Aborted by the browser drop silently, no reply.
713
+ dropPartial(requestId);
714
+ return;
715
+ }
716
+ if (buf.length > 2 + idLen) {
717
+ const payload = buf.subarray(2 + idLen);
718
+ entry.chunks.push(Buffer.from(payload));
719
+ entry.receivedBytes += payload.length;
720
+ }
721
+ if (entry.receivedBytes > entry.bodyBytes || entry.receivedBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
722
+ dropPartial(requestId);
723
+ send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
724
+ return;
725
+ }
726
+ if (flags & 1) {
727
+ // Done frame — assemble and execute.
728
+ dropPartial(requestId);
729
+ if (entry.receivedBytes !== entry.bodyBytes) {
730
+ send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
731
+ return;
732
+ }
733
+ const body = Buffer.concat(entry.chunks).toString("utf8");
734
+ void handleRequest(channel, { ...entry.meta, body }, true).catch((error) => {
735
+ log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
736
+ });
737
+ }
738
+ };
739
+
740
+ channel.onMessage((raw) => {
741
+ // Binary messages are chunked-request body frames; the proxy otherwise
742
+ // only ever receives JSON strings, so the type discriminates cleanly.
743
+ if (typeof raw !== "string") {
744
+ handleBodyFrame(Buffer.isBuffer(raw) ? raw : Buffer.from(raw));
745
+ return;
746
+ }
747
+
748
+ /** @type {DataChannelRequest | { type: string, id?: string }} */
749
+ let message;
750
+ try {
751
+ message = JSON.parse(raw);
752
+ } catch {
753
+ return;
754
+ }
755
+
756
+ if (message.type === "request") {
757
+ void handleRequest(channel, message).catch((error) => {
758
+ log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
759
+ });
760
+ return;
761
+ }
762
+
763
+ if (message.type === "request-start") {
764
+ startPartialRequest(message);
765
+ return;
766
+ }
767
+
768
+ if (message.type === "ping") {
769
+ send(channel, { type: "pong", id: message.id });
770
+ return;
771
+ }
772
+
773
+ // The far end's answer to the numbered probes, plus what it can see of
774
+ // its own receiving. It travels browser to proxy, the direction that goes
775
+ // on working through a freeze, so it arrives when nothing else does.
776
+ if (message.type === "probe-echo") {
777
+ deliveryProbe.noteEcho(sessionId, message);
778
+ if (message.report && typeof message.report === "object") {
779
+ const report = message.report;
780
+ const channels = report.channels && typeof report.channels === "object"
781
+ ? Object.entries(report.channels)
782
+ .map(([name, counters]) => `${name}=${counters?.messages ?? "?"}msg/${counters?.bytes ?? "?"}B`)
783
+ .join(" ")
784
+ : "";
785
+ log(
786
+ `[dc-far] ${tag} visibility=${report.visibility ?? "?"} ` +
787
+ `loopLag=${report.loopLagMs ?? "?"}ms handler=${report.handlerMaxMs ?? "?"}ms ` +
788
+ `transportIn=${report.transportBytesReceived ?? "?"} ${channels} ` +
789
+ `pending=${report.pending ?? "?"} at=${new Date().toISOString()}`
790
+ );
791
+ }
792
+ return;
793
+ }
794
+ });
795
+
796
+ channel.onClosed(() => {
797
+ stopWatchdog();
798
+ deliveryProbe.detach(sessionId, channel);
799
+ for (const entry of partials.values()) {
800
+ clearTimeout(entry.timer);
801
+ }
802
+ partials.clear();
803
+ unsubscribeSubtitlesAll(channel);
804
+ log(`[dc] Session ${tag}: channel closed`);
805
+ });
806
+
807
+ channel.onError((err) => {
808
+ log(`[dc] Session ${tag}: channel error: ${err}`);
809
+ });
810
+ }
811
+
812
+ /**
813
+ * Fetch a resource from the local proxy HTTP server and stream the response
814
+ * back to the browser over the data channel.
815
+ *
816
+ * The `Host` header is rewritten to `127.0.0.1:{proxyPort}` so that Fastify
817
+ * routes the request correctly regardless of what the browser sent.
818
+ *
819
+ * @param {DataChannel} channel
820
+ * @param {DataChannelRequest} req
821
+ * @returns {Promise<void>}
822
+ */
823
+ async function handleRequest(channel, req, viaChunks = false) {
824
+ const { requestId, method, path, query, headers: forwardedHeaders, body } = req;
825
+
826
+ // Reject paths that are not absolute, contain traversal sequences, or
827
+ // do not start with a known proxy route prefix. All valid browser-side
828
+ // requests use /api/*, /stream, /transcode/*, /health, or /healthz.
829
+ if (!isValidRequestPath(path)) {
830
+ send(channel, { type: "response-error", requestId, error: "Invalid request path." });
831
+ return;
832
+ }
833
+
834
+ // Piggy-backs on the browser's own request for an EMBEDDED track — no
835
+ // separate subscribe message. `trackIndex` is what tells the two request
836
+ // shapes apart: an external subtitle FILE (no trackIndex) names a
837
+ // different file's own index in `fileIndex` — the subtitle file's, not the
838
+ // video's — and subscribing under that would just be a key nothing ever
839
+ // publishes to (an external file is one whole-file read, not something
840
+ // this walks incrementally). `fileIndex` alone would also scope this to
841
+ // the wrong grain for the real case a torrent can carry several playable
842
+ // files so the pair is what a push is ever addressed to.
843
+ //
844
+ // The browser's `sourceKey` is a REGISTRY key — a hash of the raw request
845
+ // bytes, one per (magnet-or-.torrent, this API session). The torrent pool
846
+ // publishes under its OWN key — the content's infohash, deliberately the
847
+ // SAME for a magnet and a `.torrent` naming the same film, so the two
848
+ // share one swarm (item 10). The two are different strings for the same
849
+ // torrent whenever a source was added by its `.torrent` file (a `.torrent`
850
+ // and a magnet are different request bytes, same infohash) — subscribing
851
+ // under the registry key found no publisher for that reason, not because
852
+ // nothing was ever read: field case 2026-08-22, cues were found and
853
+ // logged, every push answered "found no subscribed channel". Resolved to
854
+ // the pool's key here, the one place both are in hand.
855
+ if (path === "/api/subtitles" && typeof query === "string") {
856
+ const params = new URLSearchParams(query);
857
+ const registrySourceKey = params.get("sourceKey");
858
+ const fileIndex = Number(params.get("fileIndex"));
859
+ const hasTrackIndex = params.get("trackIndex") !== null && params.get("trackIndex") !== "";
860
+ if (registrySourceKey && Number.isInteger(fileIndex) && hasTrackIndex) {
861
+ const record = sourceRegistry?.get(registrySourceKey);
862
+ if (record) {
863
+ try {
864
+ const poolSourceKey = await deriveSourceKey(record.sourceType, record.source);
865
+ subscribeSubtitles(poolSourceKey, fileIndex, channel);
866
+ } catch (error) {
867
+ log(`[dc] subtitle push: could not resolve ${registrySourceKey.slice(0, 8)} to a pool key: ` +
868
+ `${error instanceof Error ? error.message : error}`);
869
+ }
870
+ }
871
+ }
872
+ }
873
+
874
+ const queryInfo = query ? `?${query}` : "";
875
+ const bodyInfo =
876
+ body != null && typeof body === "string" && body.length > 0
877
+ ? ` body=${body.length} bytes${viaChunks ? " (chunked)" : ""}`
878
+ : "";
879
+ log(`[dc] ${method} ${path}${queryInfo}${bodyInfo}`);
880
+
881
+ const targetUrl = `http://127.0.0.1:${proxyPort}${path}${query ? `?${query}` : ""}`;
882
+ const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
883
+
884
+ let response;
885
+ // [net-debug] TEMPORARY: time spent in the local fetch (waiting for the
886
+ // route to return a response — e.g. long-polling until an HLS segment is
887
+ // finalized by ffmpeg) vs. the body transfer over the data channel.
888
+ const fetchStartedAt = Date.now();
889
+ try {
890
+ response = await fetch(targetUrl, {
891
+ method,
892
+ headers: requestHeaders,
893
+ body: body != null ? body : undefined,
894
+ redirect: "manual"
895
+ });
896
+ } catch (fetchError) {
897
+ log(`[dc] ${method} ${path}${queryInfo} error: ${fetchError?.message ?? String(fetchError)}`);
898
+ send(channel, { type: "response-error", requestId, error: fetchError?.message ?? String(fetchError) });
899
+ return;
900
+ }
901
+
902
+ if (response.status !== 200 && response.status !== 206) {
903
+ log(`[dc] ${method} ${path}${queryInfo} ${response.status}`);
904
+ }
905
+
906
+ /** @type {Record<string, string>} */
907
+ const responseHeaders = {};
908
+ for (const [name, value] of response.headers.entries()) {
909
+ responseHeaders[name] = value;
910
+ }
911
+
912
+ send(channel, { type: "response-start", requestId, status: response.status, headers: responseHeaders });
913
+
914
+ if (!response.body) {
915
+ sendChunk(channel, requestId, null, true);
916
+ return;
917
+ }
918
+
919
+ try {
920
+ const reader = response.body.getReader();
921
+ // [net-debug] TEMPORARY: measure transfer size/time and channel buffering.
922
+ // fetchMs = time waiting for the route (incl. ffmpeg segment finalization).
923
+ // ttfbMs = time from body-read start to the first chunk with data (loopback).
924
+ // sendMs = total body read+send duration over the data channel.
925
+ const fetchMs = Date.now() - fetchStartedAt;
926
+ const sendStartedAt = Date.now();
927
+ let firstByteMs = -1;
928
+ let chunks = 0;
929
+ let totalBytes = 0;
930
+ let maxBuffered = 0;
931
+ // Attribute the transfer to the step that actually consumes the time.
932
+ // Without this split a slow transfer is indistinguishable between "the
933
+ // source is slow", "the channel is slow" and "the event loop is blocked",
934
+ // which is exactly the argument a field seek left unresolved.
935
+ let readMs = 0;
936
+ let sendMs2 = 0;
937
+ let drainMs = 0;
938
+ resetEventLoopDelay();
939
+ while (true) {
940
+ const readStartedAt = performance.now();
941
+ const { done, value } = await reader.read();
942
+ readMs += performance.now() - readStartedAt;
943
+ if (done) {
944
+ sendChunk(channel, requestId, null, true);
945
+ const elapsedMs = Date.now() - sendStartedAt;
946
+ let bufferedNow = 0;
947
+ try { bufferedNow = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0; } catch { /* ignore */ }
948
+ const loop = eventLoopDelay();
949
+ const mbps = elapsedMs > 0 ? (totalBytes * 8) / (elapsedMs * 1000) : 0;
950
+ log(
951
+ `[net-debug] sent ${path}${queryInfo} bytes=${totalBytes} fetchMs=${fetchMs} ` +
952
+ `ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
953
+ `maxBuffered=${maxBuffered} bufferedAtEnd=${bufferedNow} ` +
954
+ // Where the time went: reading the body from the local route,
955
+ // handing chunks to the channel, or waiting for its queue. Plus
956
+ // the event-loop delay over the same window — a large max here
957
+ // means the transfer was blocked by synchronous work, not by the
958
+ // network, and the three figures above will all look inflated.
959
+ `readMs=${readMs.toFixed(0)} chanMs=${sendMs2.toFixed(0)} drainMs=${drainMs.toFixed(0)} ` +
960
+ `loopMean=${loop.meanMs.toFixed(1)} loopP99=${loop.p99Ms.toFixed(1)} loopMax=${loop.maxMs.toFixed(1)} ` +
961
+ `rate=${mbps.toFixed(1)}Mbps`
962
+ );
963
+ break;
964
+ }
965
+ if (firstByteMs < 0) firstByteMs = Date.now() - sendStartedAt;
966
+ chunks += 1;
967
+ totalBytes += value.length;
968
+ try {
969
+ const b = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
970
+ if (b > maxBuffered) maxBuffered = b;
971
+ } catch { /* ignore */ }
972
+ const sendStepAt = performance.now();
973
+ sendChunk(channel, requestId, value, false);
974
+ sendMs2 += performance.now() - sendStepAt;
975
+ // Backpressure: do not keep queuing chunks once the channel's outgoing
976
+ // buffer is large — wait for it to drain. Prevents the SCTP send buffer
977
+ // from ballooning, which stalls throughput.
978
+ const drainStepAt = performance.now();
979
+ await waitForBufferDrain(channel);
980
+ drainMs += performance.now() - drainStepAt;
981
+ }
982
+ } catch {
983
+ sendChunk(channel, requestId, null, true);
984
+ }
985
+ }
986
+
987
+ /**
988
+ * Send a response body frame as a BINARY data-channel message.
989
+ * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
990
+ *
991
+ * @param {DataChannel} channel
992
+ * @param {string} requestId
993
+ * @param {Uint8Array | null} bytes - Body bytes, or null/empty for the done frame.
994
+ * @param {boolean} done
995
+ * @returns {void}
996
+ */
997
+ /**
998
+ * The request id as bytes, prepared once per request rather than per chunk.
999
+ *
1000
+ * A segment is a couple of hundred chunks, and each one was re-encoding the
1001
+ * same 32-character string. The map is bounded because request ids are
1002
+ * short-lived and unbounded in number — dropping the whole cache when it
1003
+ * grows costs one re-encode per live request and cannot leak.
1004
+ *
1005
+ * @param {string} requestId
1006
+ * @returns {Buffer}
1007
+ */
1008
+ function requestIdBytes(requestId) {
1009
+ let bytes = requestIdCache.get(requestId);
1010
+ if (!bytes) {
1011
+ if (requestIdCache.size > 64) {
1012
+ requestIdCache.clear();
1013
+ }
1014
+ bytes = Buffer.from(requestId, "ascii");
1015
+ requestIdCache.set(requestId, bytes);
1016
+ }
1017
+ return bytes;
1018
+ }
1019
+
1020
+ function sendChunk(channel, requestId, bytes, done) {
1021
+ try {
1022
+ channel.sendMessageBinary(encodeFrame(requestIdBytes(requestId), bytes, done));
1023
+ } catch {
1024
+ // Channel closed between check and send — safe to ignore.
1025
+ }
1026
+ }
1027
+
1028
+ /**
1029
+ * Resolve once the channel's outgoing buffer has drained below the low-water
1030
+ * mark. No-op (resolves immediately) when the buffer is already small or the
1031
+ * channel does not expose buffer APIs. A timeout fallback guards against a
1032
+ * missed low-water event so the send loop can never deadlock.
1033
+ *
1034
+ * @param {DataChannel} channel
1035
+ * @returns {Promise<void>}
1036
+ */
1037
+ function waitForBufferDrain(channel) {
1038
+ return new Promise((resolve) => {
1039
+ try {
1040
+ if (typeof channel.bufferedAmount !== "function" || channel.bufferedAmount() <= DC_BUFFER_HIGH_WATER) {
1041
+ resolve();
1042
+ return;
1043
+ }
1044
+ let settled = false;
1045
+ const done = () => {
1046
+ if (settled) return;
1047
+ settled = true;
1048
+ resolve();
1049
+ };
1050
+ channel.setBufferedAmountLowThreshold(DC_BUFFER_LOW_WATER);
1051
+ channel.onBufferedAmountLow(done);
1052
+ // Guard against a race where the buffer drained between the check above
1053
+ // and registering the callback (the low-water event would never fire).
1054
+ if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
1055
+ done();
1056
+ return;
1057
+ }
1058
+ setTimeout(done, DC_BUFFER_DRAIN_TIMEOUT_MS);
1059
+ } catch {
1060
+ resolve();
1061
+ }
1062
+ });
1063
+ }
1064
+
1065
+ /**
1066
+ * Serialise `message` to JSON and send it over the data channel.
1067
+ * Errors are silently swallowed — the channel may have closed between
1068
+ * the open check and the actual send.
1069
+ *
1070
+ * @param {DataChannel} channel
1071
+ * @param {object} message
1072
+ * @returns {void}
1073
+ */
1074
+ function send(channel, message) {
1075
+ try {
1076
+ channel.sendMessage(JSON.stringify(message));
1077
+ } catch {
1078
+ // Channel closed between check and send — safe to ignore.
1079
+ }
1080
+ }
1081
+
1082
+ return { handleChannel, publishSubtitleCues };
1083
+ }
1084
+
1085
+ /**
1086
+ * Allowed path prefixes for data-channel requests.
1087
+ * Only the known proxy API and streaming routes are accepted.
1088
+ */
1089
+ const PATH_ALLOWLIST_RE = /^(?:\/api\/|\/stream(?:$|\?)|\/?transcode\/|\/health(?:z)?(?:$|\?))/;
1090
+
1091
+ /**
1092
+ * True when `path` is an absolute, traversal-free path on a known proxy route.
1093
+ * Shared by the single-message and chunked request entry points.
1094
+ *
1095
+ * @param {unknown} path
1096
+ * @returns {boolean}
1097
+ */
1098
+ function isValidRequestPath(path) {
1099
+ return (
1100
+ typeof path === "string" &&
1101
+ path.startsWith("/") &&
1102
+ !path.includes("..") &&
1103
+ PATH_ALLOWLIST_RE.test(path)
1104
+ );
1105
+ }
1106
+
1107
+ /** Max assembled size of a chunked request body (guards proxy memory). */
1108
+ const PROXY_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024;
1109
+ /** Drop an incomplete chunked body if no further frame arrives within this window. */
1110
+ const PARTIAL_REQUEST_TTL_MS = 60_000;
1111
+
1112
+ /** Pause sending body chunks once the channel buffer exceeds this many bytes. */
1113
+ // How often the send queue is sampled, and how long it must fail to fall
1114
+ // before the transport is asked what it is doing. Five seconds is far longer
1115
+ // than any healthy burst drains in — measured, a 6-11 MB segment leaves in
1116
+ // well under a second on the LAN — and short enough that a stuck channel is
1117
+ // named while the viewer is still looking at it.
1118
+ const SEND_QUEUE_SAMPLE_MS = 1_000;
1119
+ // How often the transport's own counters are written to the log, whatever the
1120
+ // send queue is doing. Frequent enough to place a loss within a few seconds,
1121
+ // sparse enough that a two-hour film costs a few hundred lines.
1122
+ const TRANSPORT_HEARTBEAT_MS = 5_000;
1123
+
1124
+ // How many heartbeats in a row may find no transport for this session before
1125
+ // the watch gives up. Several rather than one, so a momentary gap in the
1126
+ // registry does not end a healthy watch.
1127
+ const TRANSPORT_UNKNOWN_HEARTBEATS = 3;
1128
+ const SEND_QUEUE_STUCK_MS = 5_000;
1129
+ const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
1130
+ /** Resume sending once the channel buffer drains to this many bytes. */
1131
+ const DC_BUFFER_LOW_WATER = 1 * 1024 * 1024;
1132
+ /** Safety fallback so the send loop cannot deadlock on a missed drain event. */
1133
+ const DC_BUFFER_DRAIN_TIMEOUT_MS = 5000;