@torrent-tv/proxy 2.80.7 → 2.80.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -1,596 +1,606 @@
1
- #!/usr/bin/env node
2
- /**
3
- * @file CLI entry point for the torrent-tv proxy.
4
- *
5
- * Parses command-line arguments, starts the local HTTP server, opens a
6
- * persistent WebSocket tunnel to the registry server, and registers this
7
- * proxy. Liveness is tracked via the tunnel connection — the proxy re-registers
8
- * automatically on reconnect so the server's in-memory store stays consistent.
9
- */
10
-
11
- // FIRST, and it must stay first: it sets how many blocking calls this process
12
- // can have in flight, and a module's imports are evaluated before its body, so
13
- // anything imported above it would get the default pool. See the file itself
14
- // for the measurement that made it necessary.
15
- import "../services/thread-pool.js";
16
- import { Command } from "commander";
17
- import crypto from "node:crypto";
18
- import os from "node:os";
19
- import { spawnSync } from "node:child_process";
20
- import { createRequire } from "node:module";
21
- import ffmpegStatic from "ffmpeg-static";
22
- import { startProxyServer } from "../server.js";
23
- import { registerClient } from "../services/registry-api.js";
24
- import { createTunnelClient } from "../services/tunnel-client.js";
25
- import { createWebRtcManager } from "../services/webrtc-manager.js";
26
- import { createDataChannelHandler } from "../services/data-channel-handler.js";
27
- import { pruneCoreDumps } from "../services/core-dumps.js";
28
- import { adoptOrphanRingFiles, createPacketWitness, pruneWitnessCaptures } from "../services/packet-witness.js";
29
- import { createUsrsctpStateReader } from "../services/usrsctp-state.js";
30
- import { startMemoryReport } from "../services/memory-report.js";
31
- import { fragmentBufferCollection } from "../services/torrent-worker/client.js";
32
- import { collectHealthMetrics } from "../services/health-collector.js";
33
- import { createPortMapper } from "../services/port-mapper.js";
34
- import { classifyNat } from "../services/nat-classifier.js";
35
- import { DEFAULT_SEGMENT_FORMAT_ID, SEGMENT_FORMAT_IDS } from "../services/segment-formats/index.js";
36
- import { logToFile, logger } from "../utils/logger.js";
37
-
38
- const require = createRequire(import.meta.url);
39
- const { version: PROXY_VERSION } = require("../package.json");
40
-
41
- // Last-resort process guard. This proxy runs UNTRUSTED torrents through
42
- // WebTorrent, which can throw ASYNCHRONOUSLY on a malformed source — e.g. a
43
- // v2-only / hybrid magnet crashes `arr2hex(parsedTorrent.infoHash)` deep inside
44
- // `Torrent._onTorrentId` (undefined v1 infohash), in a microtask that bypasses
45
- // the client "error" event. Without this, one bad torrent takes down the whole
46
- // node and every viewer on it, and the addon restarts in a crash loop. Log the
47
- // full stack and keep serving: the offending session fails on its own; everyone
48
- // else is unaffected. (The add path also pre-validates the infohash, so this is
49
- // a backstop for anything not caught there.)
50
- process.on("uncaughtException", (error) => {
51
- logger.error(`uncaughtException (kept alive): ${error?.stack ?? error}`);
52
- });
53
- process.on("unhandledRejection", (reason) => {
54
- logger.error(`unhandledRejection (kept alive): ${reason instanceof Error ? reason.stack : String(reason)}`);
55
- });
56
-
57
- const program = new Command();
58
-
59
- const HELP_EXAMPLES = `
60
- Examples:
61
- torrent-tv-proxy --server-url http://localhost:8080
62
- torrent-tv-proxy --server-url http://localhost:8080 --host 0.0.0.0 --port 9090
63
- torrent-tv-proxy --server-url http://localhost:8080 --public-base-url https://proxy.example.com
64
- torrent-tv-proxy --server-url http://localhost:8080 --ffmpeg-bin /usr/local/bin/ffmpeg
65
- torrent-tv-proxy --server-url http://localhost:8080 --no-transcode-audio
66
-
67
- Notes:
68
- - --help prints this message and exits with code 0.
69
- - Video transcode is available automatically for per-session fallback when requested by client API.
70
- `;
71
-
72
- if (process.argv.includes("help")) {
73
- process.argv = [process.argv[0], process.argv[1], "--help"];
74
- }
75
-
76
- program
77
- .name("torrent-proxy-client")
78
- .description("Expose torrent files over HTTP stream endpoints for browser playback.")
79
- .requiredOption("--server-url <url>", "Registry server base URL")
80
- .option("--public-base-url <url>", "Direct URL advertised to browser clients")
81
- .option("--host <host>", "Local bind host", "127.0.0.1")
82
- .option("--port <port>", "Local HTTP port", "9090")
83
- .option("--id <id>", "Stable client id")
84
- .option("--name <name>", "Display name")
85
- .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
86
- .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
87
- .option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
88
- .option(
89
- "--usrsctp-state",
90
- "Read usrsctp's association state with gdb when a wedge is declared. OFF by default: gdb attaches to THIS process and stops every thread of it while it works."
91
- )
92
- .option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
93
- .option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
94
- .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
95
- .option(
96
- "--state-dir <path>",
97
- "Where to keep what this host has measured about itself (default: beside the installed proxy)"
98
- )
99
- .option(
100
- "--log-file <path>",
101
- "Also write the log to this file, so a crash or an update does not take it with them"
102
- )
103
- .option(
104
- "--segment-format <format>",
105
- `HLS output container: ${SEGMENT_FORMAT_IDS.join(" | ")}`,
106
- DEFAULT_SEGMENT_FORMAT_ID
107
- )
108
- .option("--token <token>", "Registration token", "")
109
- .addHelpText("after", HELP_EXAMPLES);
110
-
111
- program.parse(process.argv);
112
- const options = program.opts();
113
-
114
- const localPort = Number(options.port);
115
- if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
116
- logger.error("Invalid --port value.");
117
- process.exit(1);
118
- }
119
-
120
- const serverUrl = String(options.serverUrl).replace(/\/+$/, "");
121
- const bindHost = String(options.host);
122
- const explicitBaseUrl = options.publicBaseUrl
123
- ? String(options.publicBaseUrl).replace(/\/+$/, "")
124
- : "";
125
- const clientId = options.id ? String(options.id) : crypto.randomUUID();
126
- const clientName = options.name ? String(options.name) : `proxy-${clientId.slice(0, 8)}`;
127
- const token = String(options.token ?? "");
128
- const transcodeAudio = options.transcodeAudio !== false;
129
- const portMappingEnabled = options.portMapping !== false;
130
- // Optional disk cap. undefined → the pool computes its own default; a valid
131
- // non-negative number (0 disables) → passed through.
132
- const maxDiskBytes =
133
- options.maxDiskBytes !== undefined && Number.isFinite(Number(options.maxDiskBytes)) && Number(options.maxDiskBytes) >= 0
134
- ? Number(options.maxDiskBytes)
135
- : undefined;
136
- // Per-torrent memory budget for resident pieces. Pieces past it spill to disk
137
- // rather than being lost, so a small value costs read latency, never data.
138
- const memoryBytes =
139
- options.memoryBytes !== undefined && Number.isFinite(Number(options.memoryBytes)) && Number(options.memoryBytes) > 0
140
- ? Number(options.memoryBytes)
141
- : undefined;
142
- const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
143
- const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
144
-
145
- /**
146
- * Verify that the ffmpeg binary is reachable and exits cleanly.
147
- * Throws with a descriptive message when the check fails.
148
- *
149
- * @returns {void}
150
- */
151
- function assertFfmpegAvailability() {
152
- const probe = spawnSync(ffmpegBin, ["-version"], {
153
- stdio: "ignore",
154
- windowsHide: true,
155
- timeout: 5000
156
- });
157
- if (probe.error) {
158
- const message = probe.error instanceof Error ? probe.error.message : String(probe.error);
159
- throw new Error(`Audio transcode is enabled, but ffmpeg is unavailable (${ffmpegBin}): ${message}`);
160
- }
161
- if (typeof probe.status === "number" && probe.status !== 0) {
162
- throw new Error(
163
- `Audio transcode is enabled, but ffmpeg check failed (${ffmpegBin}, exit code ${probe.status}).`
164
- );
165
- }
166
- }
167
-
168
- /** @type {boolean} */
169
- let registrationInProgress = false;
170
-
171
- /** @type {import("fastify").FastifyInstance | null} */
172
- let app = null;
173
-
174
- /** @type {number} */
175
- let actualPort = localPort;
176
-
177
- /** @type {boolean} */
178
- let shutdownInProgress = false;
179
-
180
- /** @type {ReturnType<typeof createTunnelClient> | null} */
181
- let tunnelClient = null;
182
-
183
- /** @type {ReturnType<typeof createPortMapper> | null} */
184
- let portMapper = null;
185
-
186
- /** @type {ReturnType<typeof createPortMapper> | null} UDP mapping for the WebRTC port. */
187
- let udpPortMapper = null;
188
-
189
- /** @type {ReturnType<typeof createWebRtcManager> | null} */
190
- let webRtcManager = null;
191
- /**
192
- * The packet witness, so shutdown can stop the ring it owns.
193
- *
194
- * @type {ReturnType<typeof createPacketWitness> | null}
195
- */
196
- let packetWitness = null;
197
-
198
- /** @type {ReturnType<typeof createUsrsctpStateReader> | null} */
199
- let usrsctpStateReader = null;
200
-
201
- /** @type {ReturnType<typeof createDataChannelHandler> | null} */
202
- let dataChannelHandler = null;
203
-
204
- /** @type {import("../services/nat-classifier.js").NatClassification | null} Latest NAT classification (for WebRTC port prediction). */
205
- let natInfo = null;
206
-
207
-
208
- /**
209
- * Register this proxy with the registry server.
210
- * Silently skips if a registration is already in flight.
211
- *
212
- * @returns {Promise<void>}
213
- */
214
- async function registerClientSafe() {
215
- if (registrationInProgress) {
216
- return;
217
- }
218
- registrationInProgress = true;
219
- try {
220
- const result = await registerClient({
221
- serverUrl,
222
- id: clientId,
223
- name: clientName,
224
- baseUrl: explicitBaseUrl || `http://${bindHost}:${actualPort}`,
225
- token
226
- });
227
- logger.success(`Registered as "${result.client?.name}" (${result.client?.id?.slice(0, 8)})`);
228
- } finally {
229
- registrationInProgress = false;
230
- }
231
- }
232
-
233
- /**
234
- * Register this proxy with the registry server, retrying indefinitely
235
- * with exponential backoff until it succeeds. This allows the proxy to
236
- * survive temporary server outages (restarts, deployments) without crashing.
237
- *
238
- * @returns {Promise<void>}
239
- */
240
- async function registerWithRetry() {
241
- const MAX_DELAY_MS = 60_000;
242
- let delayMs = 2_000;
243
- let attempt = 0;
244
- while (true) {
245
- attempt++;
246
- try {
247
- await registerClientSafe();
248
- return;
249
- } catch (error) {
250
- const message = error instanceof Error ? error.message : String(error);
251
- logger.warn(`Registration attempt ${attempt} failed: ${message}. Retrying in ${delayMs / 1000}s…`);
252
- await new Promise((resolve) => setTimeout(resolve, delayMs));
253
- delayMs = Math.min(delayMs * 2, MAX_DELAY_MS);
254
- }
255
- }
256
- }
257
-
258
- /**
259
- * Gracefully shut down the tunnel, heartbeat timer, and HTTP server.
260
- *
261
- * @param {string} signal - Signal name (e.g. "SIGINT").
262
- * @returns {Promise<void>}
263
- */
264
- async function shutdown(signal) {
265
- if (shutdownInProgress) {
266
- return;
267
- }
268
- shutdownInProgress = true;
269
- if (tunnelClient) {
270
- tunnelClient.disconnect();
271
- tunnelClient = null;
272
- }
273
- logger.warn(`Received ${signal}, shutting down...`);
274
- try {
275
- // Remove the router port mappings before exiting (lease expiry is the
276
- // backstop if this is skipped on a hard kill).
277
- if (portMapper) {
278
- await portMapper.stop();
279
- portMapper = null;
280
- }
281
- if (udpPortMapper) {
282
- await udpPortMapper.stop();
283
- udpPortMapper = null;
284
- }
285
- // Close WebRTC sessions and release the shared UDP mux listener socket.
286
- if (webRtcManager) {
287
- try { webRtcManager.dispose(); } catch { /* ignore */ }
288
- webRtcManager = null;
289
- }
290
- // Stop the packet ring and remove its scratch files. `process.exit` below
291
- // does not take child processes with it, so without this a proxy restarted
292
- // outside a container leaves one tcpdump running per restart.
293
- if (packetWitness) {
294
- try { await packetWitness.dispose(); } catch { /* ignore */ }
295
- packetWitness = null;
296
- }
297
- if (app) {
298
- await app.close();
299
- }
300
- process.exit(0);
301
- } catch (error) {
302
- const message = error instanceof Error ? error.message : String(error);
303
- logger.error(`Shutdown failed: ${message}`);
304
- process.exit(1);
305
- }
306
- }
307
-
308
- try {
309
- logToFile(options.logFile);
310
- if (transcodeAudio) {
311
- assertFfmpegAvailability();
312
- }
313
- const started = await startProxyServer({
314
- host: bindHost,
315
- port: localPort,
316
- transcodeAudio,
317
- ffmpegBin,
318
- maxDiskBytes,
319
- memoryBytes,
320
- segmentFormat: options.segmentFormat,
321
- stateDir: options.stateDir,
322
- deliverySink: options.deliverySink === true,
323
- // Late-bound the same way `webRtcManager` is below: the torrent pool is
324
- // built inside `startProxyServer`, before `dataChannelHandler` — the
325
- // thing that actually owns a channel to push down — exists.
326
- onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event)
327
- });
328
- app = started.app;
329
- actualPort = started.port;
330
- const sourceRegistry = started.sourceRegistry;
331
- const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
332
-
333
- // A native fault writes the whole address space out — 4.18 GB each on the
334
- // field host, and four of them nearly filled a 235 GB disk. Keep the newest
335
- // two, which are the evidence for the fault still open, and drop the rest.
336
- void pruneCoreDumps(options.stateDir);
337
- // Same policy for the packet captures the send-queue witness writes.
338
- packetWitness = createPacketWitness({
339
- log: (message) => logger.info(message),
340
- dir: options.stateDir || "",
341
- port: actualPort
342
- });
343
- // A process that was KILLED mid-session leaves the ring's last seconds
344
- // behind, and those seconds contain whatever ended it. Keep them under a name
345
- // the pruner recognises BEFORE anything starts a new ring over them.
346
- void adoptOrphanRingFiles(packetWitness.dir).then(() => pruneWitnessCaptures(packetWitness.dir));
347
-
348
- // Reads usrsctp's own association state via gdb the moment a wedge is
349
- // declared (roadmap item 11) — no source rebuild, the module ships
350
- // unstripped. A host without gdb just never gets a reading, the same way a
351
- // host without tcpdump never gets a packet capture.
352
- // OFF UNLESS ASKED FOR, and the reason is a field incident rather than
353
- // caution. On 2026-09-05 a delivery probe declared a wedge that lasted half a
354
- // second and cleared itself; the reading it triggered attached gdb to this
355
- // process, which stopped all eighty of its threads. The log ended mid-second,
356
- // /healthz stopped answering, and the viewer waited a minute and was told the
357
- // proxy had sent no video. The fifteen-second guard could not fire — it is a
358
- // timer inside the process gdb had stopped — and killing gdb left the main
359
- // thread deadlocked for good, so only restarting the addon recovered it.
360
- //
361
- // A means of diagnosis may not stop the product. This one attaches to a live
362
- // process and is triggered by a verdict with a known history of false
363
- // positives, so it is a thing to switch on deliberately while watching, not
364
- // something that arms itself.
365
- usrsctpStateReader = options.usrsctpState === true
366
- ? createUsrsctpStateReader({ log: (message) => logger.info(message) })
367
- : null;
368
-
369
- // What this process holds, once a minute. The kernel killed the proxy on
370
- // 2026-08-28 at 2.4 GB resident and the log had never said a word about
371
- // memory, so the growth that ended in that kill has no shape in any record we
372
- // keep. RSS is the figure the OOM killer reads, so RSS is the figure to say.
373
- // The disk path is where pieces spill and torrent data lands. A budget for
374
- // memory alone is half a budget: a store that behaves about RAM can still
375
- // fill the card an addon host boots from, and neither limit was measured
376
- // before (roadmap item 2).
377
- startMemoryReport({
378
- log: (message) => logger.info(message),
379
- // The other half of the piece-buffer question. Shared memory lives until
380
- // BOTH isolates let go, so the worker's own count answers only its side;
381
- // read here, on the same line as the process figures the growth shows up
382
- // in and at the same instant as them.
383
- readExtra: () => {
384
- const fragments = fragmentBufferCollection();
385
- return fragments.seen === 0
386
- ? ""
387
- : `piece buffers handed here ${fragments.seen} seen, ${fragments.collected} collected, ` +
388
- `${fragments.seen - fragments.collected} still alive`;
389
- },
390
- // Once a minute cannot see what kills this process. Both out-of-memory
391
- // kills of 2026-09-02 happened inside a single gap of the old cadence: the
392
- // last line before the first said 602 MB and the kernel measured 1.72 GB
393
- // 34 seconds later, and before the second it said 1171 MB and the kernel
394
- // measured 1.86 GB nine seconds later. The figure is read every second
395
- // now and written when it has moved by a step worth a line, so a rise of a
396
- // gigabyte is a curve instead of one number and then a death. Every /proc
397
- // read stayed behind the decision to write, so a quiet second costs one
398
- // call to `process.memoryUsage()`.
399
- intervalMs: 1_000,
400
- quietMs: 60_000,
401
- changeBytes: 25 * 1024 * 1024,
402
- diskPath: os.tmpdir()
403
- });
404
-
405
- logger.info(`Starting @torrent-tv/proxy v${PROXY_VERSION}`);
406
- logger.info(`Local stream endpoint: http://${bindHost}:${actualPort}/stream`);
407
- logger.info(`Advertised direct URL: ${directBaseUrl}`);
408
- if (transcodeAudio) {
409
- logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
410
- }
411
-
412
- // All WebRTC sessions are multiplexed onto this single UDP port (same number
413
- // as the HTTP port, different protocol) via a persistent ICE UDP mux listener
414
- // in the WebRTC manager. One port → one UPnP mapping → one reachable endpoint.
415
- const webrtcUdpPort = actualPort;
416
-
417
- // Try to open the local port on the home router (UPnP/NAT-PMP) so the proxy
418
- // is reachable from the internet without manual port forwarding. Best-effort
419
- // and fire-and-forget: failure is normal (router without UPnP) and must not
420
- // delay tunnel connect / registration, so we do not await it.
421
- if (portMappingEnabled) {
422
- portMapper = createPortMapper({ port: actualPort, protocol: "TCP" });
423
- void portMapper
424
- .start()
425
- .then(() => {
426
- // Mapping may finish after the tunnel is already connected; report the
427
- // endpoint now. If the tunnel is not open yet, onConnect re-sends it.
428
- const endpoint = portMapper?.getMappedEndpoint();
429
- if (endpoint) {
430
- tunnelClient?.sendEndpoint(endpoint);
431
- }
432
- })
433
- .catch((error) => {
434
- const message = error instanceof Error ? error.message : String(error);
435
- logger.warn(`Port mapping failed to start: ${message}`);
436
- });
437
-
438
- // Also map the single WebRTC UDP port. Not reported to the server: the
439
- // browser discovers the endpoint via ICE (srflx) candidates, not the TCP
440
- // dial-back probe.
441
- udpPortMapper = createPortMapper({
442
- port: webrtcUdpPort,
443
- protocol: "UDP",
444
- description: "torrent-tv proxy (WebRTC)"
445
- });
446
- void udpPortMapper.start().catch((error) => {
447
- const message = error instanceof Error ? error.message : String(error);
448
- logger.warn(`UDP port mapping failed to start: ${message}`);
449
- });
450
- } else {
451
- logger.info("Automatic port mapping is disabled (--no-port-mapping).");
452
- }
453
-
454
- // Classify the home NAT (diagnostic + decides whether WebRTC will need port
455
- // prediction for remote viewers). Best-effort, fire-and-forget — STUN probes
456
- // never block startup.
457
- void classifyNat()
458
- .then((nat) => {
459
- // Stored for WebRTC port prediction (webRtcManager reads it per session).
460
- natInfo = nat;
461
- if (nat.klass === "endpoint-independent") {
462
- logger.info(
463
- `nat: endpoint-independent (cone) — external UDP port stable across STUN servers (${nat.externalIp}); fixed-port WebRTC mapping is sufficient, no port prediction needed`
464
- );
465
- } else if (nat.klass === "symmetric") {
466
- logger.warn(
467
- `nat: SYMMETRIC — external UDP port varies per destination (delta ${nat.portDelta}); WebRTC offers predicted ports (base+delta*k) — covers sequential/predictable symmetric NAT, not fully random`
468
- );
469
- } else {
470
- logger.info("nat: classification inconclusive (STUN probes failed); continuing");
471
- }
472
- })
473
- .catch((error) => {
474
- const message = error instanceof Error ? error.message : String(error);
475
- logger.warn(`nat classification failed: ${message}`);
476
- });
477
-
478
- // Create tunnel + WebRTC manager.
479
- // The tunnel forwards WebRTC signals between browser (via server) and this proxy.
480
- // The WebRTC manager handles the actual peer connection and data channel.
481
- //
482
- // We use a late-binding ref so both objects can reference each other without
483
- // running into the TDZ (tunnelClient is declared above; webRtcManager is the
484
- // module-scoped `let` above so the closure in createTunnelClient — and the
485
- // shutdown handler — can reach it after initialisation).
486
-
487
- tunnelClient = createTunnelClient({
488
- serverUrl,
489
- proxyId: clientId,
490
- token,
491
- proxyPort: actualPort,
492
- onSignal(sessionId, signal) {
493
- webRtcManager?.handleSignal(sessionId, signal);
494
- },
495
- async onHealthRequest() {
496
- // Which films this proxy holds travels with the health poll the browser
497
- // already makes before it picks one: a viewer sent to a proxy that is
498
- // downloading their film costs it the encode and nothing else, while the
499
- // same viewer sent anywhere else starts the download from nothing. Asked
500
- // of the worker, so a film this proxy let go of is not claimed.
501
- let holds = [];
502
- try {
503
- holds = (await started?.torrentPool?.heldTorrents?.()) ?? [];
504
- } catch {
505
- // silent-ok: a proxy that cannot say what it holds is scored on its
506
- // machine alone, which is what every proxy was scored on until now.
507
- }
508
- return { metrics: collectHealthMetrics(), holds };
509
- },
510
- // Whether this host could sustain a file it has only been told about. The
511
- // same arithmetic the first offer uses, against this host's own startup
512
- // benchmarks — no torrent, no bytes, no ffmpeg — so the browser can ask
513
- // every proxy in the pool and be sent to one that will work instead of
514
- // being shown an error on the one it happened to land on.
515
- onCanServeRequest(mediaInfo) {
516
- return started?.hlsSessionManager?.predictOfferedHeights?.(mediaInfo) ?? null;
517
- },
518
- onConnect() {
519
- // Re-register on every tunnel connect/reconnect so the server's
520
- // in-memory store stays consistent after server restarts.
521
- void registerClientSafe().catch((error) => {
522
- const message = error instanceof Error ? error.message : String(error);
523
- logger.error(`Re-registration after tunnel connect failed: ${message}`);
524
- });
525
- // Re-report the mapped endpoint on every (re)connect — the server's
526
- // in-memory reachability state resets on restart, and the mapping may
527
- // have completed before this connection existed.
528
- const endpoint = portMapper?.getMappedEndpoint();
529
- if (endpoint) {
530
- tunnelClient?.sendEndpoint(endpoint);
531
- }
532
- },
533
- onLog: (message) => logger.info(message)
534
- });
535
-
536
- dataChannelHandler = createDataChannelHandler({
537
- proxyPort: actualPort,
538
- onLog: (message) => logger.info(message),
539
- // Resolves a browser's registry sourceKey to the torrent pool's own key
540
- // (the content's infohash) so the subtitle push subscription and the
541
- // pool's own publish agree on what a source is called. See server.js.
542
- sourceRegistry,
543
- // Lets a stuck send queue ask the transport what it is doing. Late-bound:
544
- // the manager is created below, with this handler already in hand.
545
- getTransportSnapshot: (sessionId) => webRtcManager?.getTransportSnapshot(sessionId) ?? null,
546
- // Records the wire when a queue stays wedged — how the rare one-way
547
- // transmit death (roadmap item 10, 2026-08-24) gets its evidence.
548
- witness: packetWitness,
549
- usrsctpState: usrsctpStateReader,
550
- // Presence, from the one thing that knows it. Late-bound for the same
551
- // reason as the transport snapshot above: the manager is built inside
552
- // `startProxyServer`, with this handler already in hand.
553
- onViewerPresent: (consumerId) => {
554
- started?.hlsSessionManager?.viewers?.seen?.(consumerId);
555
- },
556
- onViewerGone: (consumerId, because) => {
557
- void started?.hlsSessionManager?.viewerHasGone?.(consumerId, because)
558
- ?.catch?.((error) => {
559
- const message = error instanceof Error ? error.message : String(error);
560
- logger.warn(`could not let go of viewer ${consumerId}: ${message}`);
561
- });
562
- }
563
- });
564
-
565
- webRtcManager = createWebRtcManager({
566
- // Single UDP port (UPnP-mapped above) shared by all sessions via a
567
- // persistent ICE UDP mux listener, so the WebRTC path is reachable from the
568
- // internet on one fixed port.
569
- udpPort: webrtcUdpPort,
570
- // Latest NAT classification enables symmetric-NAT port prediction.
571
- getNatInfo: () => natInfo,
572
- sendSignal(sessionId, signal) {
573
- tunnelClient?.sendSignal(sessionId, signal);
574
- },
575
- onDataChannel(sessionId, channel) {
576
- dataChannelHandler.handleChannel(sessionId, channel);
577
- },
578
- onLog: (message) => logger.info(message)
579
- });
580
-
581
- tunnelClient.connect();
582
-
583
- await registerWithRetry();
584
- } catch (error) {
585
- const message = error instanceof Error ? error.message : String(error);
586
- logger.error(message);
587
- process.exit(1);
588
- }
589
-
590
- process.on("SIGINT", () => {
591
- void shutdown("SIGINT");
592
- });
593
-
594
- process.on("SIGTERM", () => {
595
- void shutdown("SIGTERM");
596
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file CLI entry point for the torrent-tv proxy.
4
+ *
5
+ * Parses command-line arguments, starts the local HTTP server, opens a
6
+ * persistent WebSocket tunnel to the registry server, and registers this
7
+ * proxy. Liveness is tracked via the tunnel connection — the proxy re-registers
8
+ * automatically on reconnect so the server's in-memory store stays consistent.
9
+ */
10
+
11
+ // FIRST, and it must stay first: it sets how many blocking calls this process
12
+ // can have in flight, and a module's imports are evaluated before its body, so
13
+ // anything imported above it would get the default pool. See the file itself
14
+ // for the measurement that made it necessary.
15
+ import "../services/thread-pool.js";
16
+ import { Command } from "commander";
17
+ import crypto from "node:crypto";
18
+ import os from "node:os";
19
+ import { spawnSync } from "node:child_process";
20
+ import { createRequire } from "node:module";
21
+ import ffmpegStatic from "ffmpeg-static";
22
+ import { startProxyServer } from "../server.js";
23
+ import { registerClient } from "../services/registry-api.js";
24
+ import { createTunnelClient } from "../services/tunnel-client.js";
25
+ import { createWebRtcManager } from "../services/webrtc-manager.js";
26
+ import { createDataChannelHandler } from "../services/data-channel-handler.js";
27
+ import { pruneCoreDumps } from "../services/core-dumps.js";
28
+ import { adoptOrphanRingFiles, createPacketWitness, pruneWitnessCaptures } from "../services/packet-witness.js";
29
+ import { createUsrsctpStateReader } from "../services/usrsctp-state.js";
30
+ import { startMemoryReport } from "../services/memory-report.js";
31
+ import { fragmentBufferCollection } from "../services/torrent-worker/client.js";
32
+ import { collectHealthMetrics } from "../services/health-collector.js";
33
+ import { createPortMapper } from "../services/port-mapper.js";
34
+ import { classifyNat } from "../services/nat-classifier.js";
35
+ import { DEFAULT_SEGMENT_FORMAT_ID, SEGMENT_FORMAT_IDS } from "../services/segment-formats/index.js";
36
+ import { logToFile, logger } from "../utils/logger.js";
37
+
38
+ const require = createRequire(import.meta.url);
39
+ const { version: PROXY_VERSION } = require("../package.json");
40
+
41
+ // Last-resort process guard. This proxy runs UNTRUSTED torrents through
42
+ // WebTorrent, which can throw ASYNCHRONOUSLY on a malformed source — e.g. a
43
+ // v2-only / hybrid magnet crashes `arr2hex(parsedTorrent.infoHash)` deep inside
44
+ // `Torrent._onTorrentId` (undefined v1 infohash), in a microtask that bypasses
45
+ // the client "error" event. Without this, one bad torrent takes down the whole
46
+ // node and every viewer on it, and the addon restarts in a crash loop. Log the
47
+ // full stack and keep serving: the offending session fails on its own; everyone
48
+ // else is unaffected. (The add path also pre-validates the infohash, so this is
49
+ // a backstop for anything not caught there.)
50
+ process.on("uncaughtException", (error) => {
51
+ logger.error(`uncaughtException (kept alive): ${error?.stack ?? error}`);
52
+ });
53
+ process.on("unhandledRejection", (reason) => {
54
+ logger.error(`unhandledRejection (kept alive): ${reason instanceof Error ? reason.stack : String(reason)}`);
55
+ });
56
+
57
+ const program = new Command();
58
+
59
+ const HELP_EXAMPLES = `
60
+ Examples:
61
+ torrent-tv-proxy --server-url http://localhost:8080
62
+ torrent-tv-proxy --server-url http://localhost:8080 --host 0.0.0.0 --port 9090
63
+ torrent-tv-proxy --server-url http://localhost:8080 --public-base-url https://proxy.example.com
64
+ torrent-tv-proxy --server-url http://localhost:8080 --ffmpeg-bin /usr/local/bin/ffmpeg
65
+ torrent-tv-proxy --server-url http://localhost:8080 --no-transcode-audio
66
+
67
+ Notes:
68
+ - --help prints this message and exits with code 0.
69
+ - Video transcode is available automatically for per-session fallback when requested by client API.
70
+ `;
71
+
72
+ if (process.argv.includes("help")) {
73
+ process.argv = [process.argv[0], process.argv[1], "--help"];
74
+ }
75
+
76
+ program
77
+ .name("torrent-proxy-client")
78
+ .description("Expose torrent files over HTTP stream endpoints for browser playback.")
79
+ .requiredOption("--server-url <url>", "Registry server base URL")
80
+ .option("--public-base-url <url>", "Direct URL advertised to browser clients")
81
+ .option("--host <host>", "Local bind host", "127.0.0.1")
82
+ .option("--port <port>", "Local HTTP port", "9090")
83
+ .option("--id <id>", "Stable client id")
84
+ .option("--name <name>", "Display name")
85
+ .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
86
+ .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
87
+ .option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
88
+ .option(
89
+ "--usrsctp-state",
90
+ "Read usrsctp's association state with gdb when a wedge is declared. OFF by default: gdb attaches to THIS process and stops every thread of it while it works."
91
+ )
92
+ .option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
93
+ .option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
94
+ .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
95
+ .option(
96
+ "--state-dir <path>",
97
+ "Where to keep what this host has measured about itself (default: beside the installed proxy)"
98
+ )
99
+ .option(
100
+ "--log-file <path>",
101
+ "Also write the log to this file, so a crash or an update does not take it with them"
102
+ )
103
+ .option(
104
+ "--segment-format <format>",
105
+ `HLS output container: ${SEGMENT_FORMAT_IDS.join(" | ")}`,
106
+ DEFAULT_SEGMENT_FORMAT_ID
107
+ )
108
+ .option("--token <token>", "Registration token", "")
109
+ .addHelpText("after", HELP_EXAMPLES);
110
+
111
+ program.parse(process.argv);
112
+ const options = program.opts();
113
+
114
+ const localPort = Number(options.port);
115
+ if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
116
+ logger.error("Invalid --port value.");
117
+ process.exit(1);
118
+ }
119
+
120
+ const serverUrl = String(options.serverUrl).replace(/\/+$/, "");
121
+ const bindHost = String(options.host);
122
+ const explicitBaseUrl = options.publicBaseUrl
123
+ ? String(options.publicBaseUrl).replace(/\/+$/, "")
124
+ : "";
125
+ const clientId = options.id ? String(options.id) : crypto.randomUUID();
126
+ const clientName = options.name ? String(options.name) : `proxy-${clientId.slice(0, 8)}`;
127
+ const token = String(options.token ?? "");
128
+ const transcodeAudio = options.transcodeAudio !== false;
129
+ const portMappingEnabled = options.portMapping !== false;
130
+ // Optional disk cap. undefined → the pool computes its own default; a valid
131
+ // non-negative number (0 disables) → passed through.
132
+ const maxDiskBytes =
133
+ options.maxDiskBytes !== undefined && Number.isFinite(Number(options.maxDiskBytes)) && Number(options.maxDiskBytes) >= 0
134
+ ? Number(options.maxDiskBytes)
135
+ : undefined;
136
+ // Per-torrent memory budget for resident pieces. Pieces past it spill to disk
137
+ // rather than being lost, so a small value costs read latency, never data.
138
+ const memoryBytes =
139
+ options.memoryBytes !== undefined && Number.isFinite(Number(options.memoryBytes)) && Number(options.memoryBytes) > 0
140
+ ? Number(options.memoryBytes)
141
+ : undefined;
142
+ const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
143
+ const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
144
+
145
+ /**
146
+ * Verify that the ffmpeg binary is reachable and exits cleanly.
147
+ * Throws with a descriptive message when the check fails.
148
+ *
149
+ * @returns {void}
150
+ */
151
+ function assertFfmpegAvailability() {
152
+ const probe = spawnSync(ffmpegBin, ["-version"], {
153
+ stdio: "ignore",
154
+ windowsHide: true,
155
+ timeout: 5000
156
+ });
157
+ if (probe.error) {
158
+ const message = probe.error instanceof Error ? probe.error.message : String(probe.error);
159
+ throw new Error(`Audio transcode is enabled, but ffmpeg is unavailable (${ffmpegBin}): ${message}`);
160
+ }
161
+ if (typeof probe.status === "number" && probe.status !== 0) {
162
+ throw new Error(
163
+ `Audio transcode is enabled, but ffmpeg check failed (${ffmpegBin}, exit code ${probe.status}).`
164
+ );
165
+ }
166
+ }
167
+
168
+ /** @type {boolean} */
169
+ let registrationInProgress = false;
170
+
171
+ /** @type {import("fastify").FastifyInstance | null} */
172
+ let app = null;
173
+
174
+ /** @type {number} */
175
+ let actualPort = localPort;
176
+
177
+ /** @type {boolean} */
178
+ let shutdownInProgress = false;
179
+
180
+ /** @type {ReturnType<typeof createTunnelClient> | null} */
181
+ let tunnelClient = null;
182
+
183
+ /** @type {ReturnType<typeof createPortMapper> | null} */
184
+ let portMapper = null;
185
+
186
+ /** @type {ReturnType<typeof createPortMapper> | null} UDP mapping for the WebRTC port. */
187
+ let udpPortMapper = null;
188
+
189
+ /** @type {ReturnType<typeof createWebRtcManager> | null} */
190
+ let webRtcManager = null;
191
+ /**
192
+ * The packet witness, so shutdown can stop the ring it owns.
193
+ *
194
+ * @type {ReturnType<typeof createPacketWitness> | null}
195
+ */
196
+ let packetWitness = null;
197
+
198
+ /** @type {ReturnType<typeof createUsrsctpStateReader> | null} */
199
+ let usrsctpStateReader = null;
200
+
201
+ /** @type {ReturnType<typeof createDataChannelHandler> | null} */
202
+ let dataChannelHandler = null;
203
+
204
+ /** @type {import("../services/nat-classifier.js").NatClassification | null} Latest NAT classification (for WebRTC port prediction). */
205
+ let natInfo = null;
206
+
207
+
208
+ /**
209
+ * Register this proxy with the registry server.
210
+ * Silently skips if a registration is already in flight.
211
+ *
212
+ * @returns {Promise<void>}
213
+ */
214
+ async function registerClientSafe() {
215
+ if (registrationInProgress) {
216
+ return;
217
+ }
218
+ registrationInProgress = true;
219
+ try {
220
+ const result = await registerClient({
221
+ serverUrl,
222
+ id: clientId,
223
+ name: clientName,
224
+ baseUrl: explicitBaseUrl || `http://${bindHost}:${actualPort}`,
225
+ token
226
+ });
227
+ logger.success(`Registered as "${result.client?.name}" (${result.client?.id?.slice(0, 8)})`);
228
+ } finally {
229
+ registrationInProgress = false;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Register this proxy with the registry server, retrying indefinitely
235
+ * with exponential backoff until it succeeds. This allows the proxy to
236
+ * survive temporary server outages (restarts, deployments) without crashing.
237
+ *
238
+ * @returns {Promise<void>}
239
+ */
240
+ async function registerWithRetry() {
241
+ const MAX_DELAY_MS = 60_000;
242
+ let delayMs = 2_000;
243
+ let attempt = 0;
244
+ while (true) {
245
+ attempt++;
246
+ try {
247
+ await registerClientSafe();
248
+ return;
249
+ } catch (error) {
250
+ const message = error instanceof Error ? error.message : String(error);
251
+ logger.warn(`Registration attempt ${attempt} failed: ${message}. Retrying in ${delayMs / 1000}s…`);
252
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
253
+ delayMs = Math.min(delayMs * 2, MAX_DELAY_MS);
254
+ }
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Gracefully shut down the tunnel, heartbeat timer, and HTTP server.
260
+ *
261
+ * @param {string} signal - Signal name (e.g. "SIGINT").
262
+ * @returns {Promise<void>}
263
+ */
264
+ async function shutdown(signal) {
265
+ if (shutdownInProgress) {
266
+ return;
267
+ }
268
+ shutdownInProgress = true;
269
+ if (tunnelClient) {
270
+ tunnelClient.disconnect();
271
+ tunnelClient = null;
272
+ }
273
+ logger.warn(`Received ${signal}, shutting down...`);
274
+ try {
275
+ // Remove the router port mappings before exiting (lease expiry is the
276
+ // backstop if this is skipped on a hard kill).
277
+ if (portMapper) {
278
+ await portMapper.stop();
279
+ portMapper = null;
280
+ }
281
+ if (udpPortMapper) {
282
+ await udpPortMapper.stop();
283
+ udpPortMapper = null;
284
+ }
285
+ // Close WebRTC sessions and release the shared UDP mux listener socket.
286
+ if (webRtcManager) {
287
+ try { webRtcManager.dispose(); } catch { /* ignore */ }
288
+ webRtcManager = null;
289
+ }
290
+ // Stop the packet ring and remove its scratch files. `process.exit` below
291
+ // does not take child processes with it, so without this a proxy restarted
292
+ // outside a container leaves one tcpdump running per restart.
293
+ if (packetWitness) {
294
+ try { await packetWitness.dispose(); } catch { /* ignore */ }
295
+ packetWitness = null;
296
+ }
297
+ if (app) {
298
+ await app.close();
299
+ }
300
+ process.exit(0);
301
+ } catch (error) {
302
+ const message = error instanceof Error ? error.message : String(error);
303
+ logger.error(`Shutdown failed: ${message}`);
304
+ process.exit(1);
305
+ }
306
+ }
307
+
308
+ try {
309
+ logToFile(options.logFile);
310
+ if (transcodeAudio) {
311
+ assertFfmpegAvailability();
312
+ }
313
+ const started = await startProxyServer({
314
+ host: bindHost,
315
+ port: localPort,
316
+ transcodeAudio,
317
+ ffmpegBin,
318
+ maxDiskBytes,
319
+ memoryBytes,
320
+ segmentFormat: options.segmentFormat,
321
+ stateDir: options.stateDir,
322
+ deliverySink: options.deliverySink === true,
323
+ // Late-bound the same way `webRtcManager` is below: the torrent pool is
324
+ // built inside `startProxyServer`, before `dataChannelHandler` — the
325
+ // thing that actually owns a channel to push down — exists.
326
+ onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event)
327
+ });
328
+ app = started.app;
329
+ actualPort = started.port;
330
+ const sourceRegistry = started.sourceRegistry;
331
+ const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
332
+
333
+ // A native fault writes the whole address space out — 4.18 GB each on the
334
+ // field host, and four of them nearly filled a 235 GB disk. Keep the newest
335
+ // two, which are the evidence for the fault still open, and drop the rest.
336
+ void pruneCoreDumps(options.stateDir);
337
+ // Same policy for the packet captures the send-queue witness writes.
338
+ packetWitness = createPacketWitness({
339
+ log: (message) => logger.info(message),
340
+ dir: options.stateDir || "",
341
+ port: actualPort
342
+ });
343
+ // A process that was KILLED mid-session leaves the ring's last seconds
344
+ // behind, and those seconds contain whatever ended it. Keep them under a name
345
+ // the pruner recognises BEFORE anything starts a new ring over them.
346
+ void adoptOrphanRingFiles(packetWitness.dir).then(() => pruneWitnessCaptures(packetWitness.dir));
347
+
348
+ // Reads usrsctp's own association state via gdb the moment a wedge is
349
+ // declared (roadmap item 11) — no source rebuild, the module ships
350
+ // unstripped. A host without gdb just never gets a reading, the same way a
351
+ // host without tcpdump never gets a packet capture.
352
+ // OFF UNLESS ASKED FOR, and the reason is a field incident rather than
353
+ // caution. On 2026-09-05 a delivery probe declared a wedge that lasted half a
354
+ // second and cleared itself; the reading it triggered attached gdb to this
355
+ // process, which stopped all eighty of its threads. The log ended mid-second,
356
+ // /healthz stopped answering, and the viewer waited a minute and was told the
357
+ // proxy had sent no video. The fifteen-second guard could not fire — it is a
358
+ // timer inside the process gdb had stopped — and killing gdb left the main
359
+ // thread deadlocked for good, so only restarting the addon recovered it.
360
+ //
361
+ // A means of diagnosis may not stop the product. This one attaches to a live
362
+ // process and is triggered by a verdict with a known history of false
363
+ // positives, so it is a thing to switch on deliberately while watching, not
364
+ // something that arms itself.
365
+ usrsctpStateReader = options.usrsctpState === true
366
+ ? createUsrsctpStateReader({ log: (message) => logger.info(message) })
367
+ : null;
368
+
369
+ // What this process holds, once a minute. The kernel killed the proxy on
370
+ // 2026-08-28 at 2.4 GB resident and the log had never said a word about
371
+ // memory, so the growth that ended in that kill has no shape in any record we
372
+ // keep. RSS is the figure the OOM killer reads, so RSS is the figure to say.
373
+ // The disk path is where pieces spill and torrent data lands. A budget for
374
+ // memory alone is half a budget: a store that behaves about RAM can still
375
+ // fill the card an addon host boots from, and neither limit was measured
376
+ // before (roadmap item 2).
377
+ startMemoryReport({
378
+ log: (message) => logger.info(message),
379
+ // The other half of the piece-buffer question. Shared memory lives until
380
+ // BOTH isolates let go, so the worker's own count answers only its side;
381
+ // read here, on the same line as the process figures the growth shows up
382
+ // in and at the same instant as them.
383
+ readExtra: () => {
384
+ const fragments = fragmentBufferCollection();
385
+ return fragments.seen === 0
386
+ ? ""
387
+ : `piece buffers handed here ${fragments.seen} seen, ${fragments.collected} collected, ` +
388
+ `${fragments.seen - fragments.collected} still alive`;
389
+ },
390
+ // Once a minute cannot see what kills this process. Both out-of-memory
391
+ // kills of 2026-09-02 happened inside a single gap of the old cadence: the
392
+ // last line before the first said 602 MB and the kernel measured 1.72 GB
393
+ // 34 seconds later, and before the second it said 1171 MB and the kernel
394
+ // measured 1.86 GB nine seconds later. The figure is read every second
395
+ // now and written when it has moved by a step worth a line, so a rise of a
396
+ // gigabyte is a curve instead of one number and then a death. Every /proc
397
+ // read stayed behind the decision to write, so a quiet second costs one
398
+ // call to `process.memoryUsage()`.
399
+ intervalMs: 1_000,
400
+ quietMs: 60_000,
401
+ changeBytes: 25 * 1024 * 1024,
402
+ diskPath: os.tmpdir(),
403
+ // WHERE A SNAPSHOT SURVIVES THE PROCESS IT IS ABOUT, and how many are kept
404
+ // — the same answer the torrent worker already had. This scope had neither:
405
+ // snapshots went to the temporary directory, which the container recreates
406
+ // on every restart, and all of them were kept. Fifty-four were written on
407
+ // 2026-09-07 and every one was gone by the time the process that wrote them
408
+ // died of a heap it had never been possible to look at.
409
+ snapshotDir: options.stateDir || undefined,
410
+ snapshotFloorBytes: 400 * 1024 * 1024,
411
+ snapshotGrowthBytes: 400 * 1024 * 1024,
412
+ keepSnapshots: 3
413
+ });
414
+
415
+ logger.info(`Starting @torrent-tv/proxy v${PROXY_VERSION}`);
416
+ logger.info(`Local stream endpoint: http://${bindHost}:${actualPort}/stream`);
417
+ logger.info(`Advertised direct URL: ${directBaseUrl}`);
418
+ if (transcodeAudio) {
419
+ logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
420
+ }
421
+
422
+ // All WebRTC sessions are multiplexed onto this single UDP port (same number
423
+ // as the HTTP port, different protocol) via a persistent ICE UDP mux listener
424
+ // in the WebRTC manager. One port → one UPnP mapping → one reachable endpoint.
425
+ const webrtcUdpPort = actualPort;
426
+
427
+ // Try to open the local port on the home router (UPnP/NAT-PMP) so the proxy
428
+ // is reachable from the internet without manual port forwarding. Best-effort
429
+ // and fire-and-forget: failure is normal (router without UPnP) and must not
430
+ // delay tunnel connect / registration, so we do not await it.
431
+ if (portMappingEnabled) {
432
+ portMapper = createPortMapper({ port: actualPort, protocol: "TCP" });
433
+ void portMapper
434
+ .start()
435
+ .then(() => {
436
+ // Mapping may finish after the tunnel is already connected; report the
437
+ // endpoint now. If the tunnel is not open yet, onConnect re-sends it.
438
+ const endpoint = portMapper?.getMappedEndpoint();
439
+ if (endpoint) {
440
+ tunnelClient?.sendEndpoint(endpoint);
441
+ }
442
+ })
443
+ .catch((error) => {
444
+ const message = error instanceof Error ? error.message : String(error);
445
+ logger.warn(`Port mapping failed to start: ${message}`);
446
+ });
447
+
448
+ // Also map the single WebRTC UDP port. Not reported to the server: the
449
+ // browser discovers the endpoint via ICE (srflx) candidates, not the TCP
450
+ // dial-back probe.
451
+ udpPortMapper = createPortMapper({
452
+ port: webrtcUdpPort,
453
+ protocol: "UDP",
454
+ description: "torrent-tv proxy (WebRTC)"
455
+ });
456
+ void udpPortMapper.start().catch((error) => {
457
+ const message = error instanceof Error ? error.message : String(error);
458
+ logger.warn(`UDP port mapping failed to start: ${message}`);
459
+ });
460
+ } else {
461
+ logger.info("Automatic port mapping is disabled (--no-port-mapping).");
462
+ }
463
+
464
+ // Classify the home NAT (diagnostic + decides whether WebRTC will need port
465
+ // prediction for remote viewers). Best-effort, fire-and-forget — STUN probes
466
+ // never block startup.
467
+ void classifyNat()
468
+ .then((nat) => {
469
+ // Stored for WebRTC port prediction (webRtcManager reads it per session).
470
+ natInfo = nat;
471
+ if (nat.klass === "endpoint-independent") {
472
+ logger.info(
473
+ `nat: endpoint-independent (cone) — external UDP port stable across STUN servers (${nat.externalIp}); fixed-port WebRTC mapping is sufficient, no port prediction needed`
474
+ );
475
+ } else if (nat.klass === "symmetric") {
476
+ logger.warn(
477
+ `nat: SYMMETRIC — external UDP port varies per destination (delta ${nat.portDelta}); WebRTC offers predicted ports (base+delta*k) — covers sequential/predictable symmetric NAT, not fully random`
478
+ );
479
+ } else {
480
+ logger.info("nat: classification inconclusive (STUN probes failed); continuing");
481
+ }
482
+ })
483
+ .catch((error) => {
484
+ const message = error instanceof Error ? error.message : String(error);
485
+ logger.warn(`nat classification failed: ${message}`);
486
+ });
487
+
488
+ // Create tunnel + WebRTC manager.
489
+ // The tunnel forwards WebRTC signals between browser (via server) and this proxy.
490
+ // The WebRTC manager handles the actual peer connection and data channel.
491
+ //
492
+ // We use a late-binding ref so both objects can reference each other without
493
+ // running into the TDZ (tunnelClient is declared above; webRtcManager is the
494
+ // module-scoped `let` above so the closure in createTunnelClient — and the
495
+ // shutdown handler — can reach it after initialisation).
496
+
497
+ tunnelClient = createTunnelClient({
498
+ serverUrl,
499
+ proxyId: clientId,
500
+ token,
501
+ proxyPort: actualPort,
502
+ onSignal(sessionId, signal) {
503
+ webRtcManager?.handleSignal(sessionId, signal);
504
+ },
505
+ async onHealthRequest() {
506
+ // Which films this proxy holds travels with the health poll the browser
507
+ // already makes before it picks one: a viewer sent to a proxy that is
508
+ // downloading their film costs it the encode and nothing else, while the
509
+ // same viewer sent anywhere else starts the download from nothing. Asked
510
+ // of the worker, so a film this proxy let go of is not claimed.
511
+ let holds = [];
512
+ try {
513
+ holds = (await started?.torrentPool?.heldTorrents?.()) ?? [];
514
+ } catch {
515
+ // silent-ok: a proxy that cannot say what it holds is scored on its
516
+ // machine alone, which is what every proxy was scored on until now.
517
+ }
518
+ return { metrics: collectHealthMetrics(), holds };
519
+ },
520
+ // Whether this host could sustain a file it has only been told about. The
521
+ // same arithmetic the first offer uses, against this host's own startup
522
+ // benchmarks no torrent, no bytes, no ffmpeg — so the browser can ask
523
+ // every proxy in the pool and be sent to one that will work instead of
524
+ // being shown an error on the one it happened to land on.
525
+ onCanServeRequest(mediaInfo) {
526
+ return started?.hlsSessionManager?.predictOfferedHeights?.(mediaInfo) ?? null;
527
+ },
528
+ onConnect() {
529
+ // Re-register on every tunnel connect/reconnect so the server's
530
+ // in-memory store stays consistent after server restarts.
531
+ void registerClientSafe().catch((error) => {
532
+ const message = error instanceof Error ? error.message : String(error);
533
+ logger.error(`Re-registration after tunnel connect failed: ${message}`);
534
+ });
535
+ // Re-report the mapped endpoint on every (re)connect — the server's
536
+ // in-memory reachability state resets on restart, and the mapping may
537
+ // have completed before this connection existed.
538
+ const endpoint = portMapper?.getMappedEndpoint();
539
+ if (endpoint) {
540
+ tunnelClient?.sendEndpoint(endpoint);
541
+ }
542
+ },
543
+ onLog: (message) => logger.info(message)
544
+ });
545
+
546
+ dataChannelHandler = createDataChannelHandler({
547
+ proxyPort: actualPort,
548
+ onLog: (message) => logger.info(message),
549
+ // Resolves a browser's registry sourceKey to the torrent pool's own key
550
+ // (the content's infohash) so the subtitle push subscription and the
551
+ // pool's own publish agree on what a source is called. See server.js.
552
+ sourceRegistry,
553
+ // Lets a stuck send queue ask the transport what it is doing. Late-bound:
554
+ // the manager is created below, with this handler already in hand.
555
+ getTransportSnapshot: (sessionId) => webRtcManager?.getTransportSnapshot(sessionId) ?? null,
556
+ // Records the wire when a queue stays wedged — how the rare one-way
557
+ // transmit death (roadmap item 10, 2026-08-24) gets its evidence.
558
+ witness: packetWitness,
559
+ usrsctpState: usrsctpStateReader,
560
+ // Presence, from the one thing that knows it. Late-bound for the same
561
+ // reason as the transport snapshot above: the manager is built inside
562
+ // `startProxyServer`, with this handler already in hand.
563
+ onViewerPresent: (consumerId) => {
564
+ started?.hlsSessionManager?.viewers?.seen?.(consumerId);
565
+ },
566
+ onViewerGone: (consumerId, because) => {
567
+ void started?.hlsSessionManager?.viewerHasGone?.(consumerId, because)
568
+ ?.catch?.((error) => {
569
+ const message = error instanceof Error ? error.message : String(error);
570
+ logger.warn(`could not let go of viewer ${consumerId}: ${message}`);
571
+ });
572
+ }
573
+ });
574
+
575
+ webRtcManager = createWebRtcManager({
576
+ // Single UDP port (UPnP-mapped above) shared by all sessions via a
577
+ // persistent ICE UDP mux listener, so the WebRTC path is reachable from the
578
+ // internet on one fixed port.
579
+ udpPort: webrtcUdpPort,
580
+ // Latest NAT classification — enables symmetric-NAT port prediction.
581
+ getNatInfo: () => natInfo,
582
+ sendSignal(sessionId, signal) {
583
+ tunnelClient?.sendSignal(sessionId, signal);
584
+ },
585
+ onDataChannel(sessionId, channel) {
586
+ dataChannelHandler.handleChannel(sessionId, channel);
587
+ },
588
+ onLog: (message) => logger.info(message)
589
+ });
590
+
591
+ tunnelClient.connect();
592
+
593
+ await registerWithRetry();
594
+ } catch (error) {
595
+ const message = error instanceof Error ? error.message : String(error);
596
+ logger.error(message);
597
+ process.exit(1);
598
+ }
599
+
600
+ process.on("SIGINT", () => {
601
+ void shutdown("SIGINT");
602
+ });
603
+
604
+ process.on("SIGTERM", () => {
605
+ void shutdown("SIGTERM");
606
+ });