@torrent-tv/proxy 2.68.0 → 2.69.1

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,533 +1,544 @@
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("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
89
- .option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
90
- .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
91
- .option(
92
- "--state-dir <path>",
93
- "Where to keep what this host has measured about itself (default: beside the installed proxy)"
94
- )
95
- .option(
96
- "--log-file <path>",
97
- "Also write the log to this file, so a crash or an update does not take it with them"
98
- )
99
- .option(
100
- "--segment-format <format>",
101
- `HLS output container: ${SEGMENT_FORMAT_IDS.join(" | ")}`,
102
- DEFAULT_SEGMENT_FORMAT_ID
103
- )
104
- .option("--token <token>", "Registration token", "")
105
- .addHelpText("after", HELP_EXAMPLES);
106
-
107
- program.parse(process.argv);
108
- const options = program.opts();
109
-
110
- const localPort = Number(options.port);
111
- if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
112
- logger.error("Invalid --port value.");
113
- process.exit(1);
114
- }
115
-
116
- const serverUrl = String(options.serverUrl).replace(/\/+$/, "");
117
- const bindHost = String(options.host);
118
- const explicitBaseUrl = options.publicBaseUrl
119
- ? String(options.publicBaseUrl).replace(/\/+$/, "")
120
- : "";
121
- const clientId = options.id ? String(options.id) : crypto.randomUUID();
122
- const clientName = options.name ? String(options.name) : `proxy-${clientId.slice(0, 8)}`;
123
- const token = String(options.token ?? "");
124
- const transcodeAudio = options.transcodeAudio !== false;
125
- const portMappingEnabled = options.portMapping !== false;
126
- // Optional disk cap. undefined → the pool computes its own default; a valid
127
- // non-negative number (0 disables) → passed through.
128
- const maxDiskBytes =
129
- options.maxDiskBytes !== undefined && Number.isFinite(Number(options.maxDiskBytes)) && Number(options.maxDiskBytes) >= 0
130
- ? Number(options.maxDiskBytes)
131
- : undefined;
132
- // Per-torrent memory budget for resident pieces. Pieces past it spill to disk
133
- // rather than being lost, so a small value costs read latency, never data.
134
- const memoryBytes =
135
- options.memoryBytes !== undefined && Number.isFinite(Number(options.memoryBytes)) && Number(options.memoryBytes) > 0
136
- ? Number(options.memoryBytes)
137
- : undefined;
138
- const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
139
- const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
140
-
141
- /**
142
- * Verify that the ffmpeg binary is reachable and exits cleanly.
143
- * Throws with a descriptive message when the check fails.
144
- *
145
- * @returns {void}
146
- */
147
- function assertFfmpegAvailability() {
148
- const probe = spawnSync(ffmpegBin, ["-version"], {
149
- stdio: "ignore",
150
- windowsHide: true,
151
- timeout: 5000
152
- });
153
- if (probe.error) {
154
- const message = probe.error instanceof Error ? probe.error.message : String(probe.error);
155
- throw new Error(`Audio transcode is enabled, but ffmpeg is unavailable (${ffmpegBin}): ${message}`);
156
- }
157
- if (typeof probe.status === "number" && probe.status !== 0) {
158
- throw new Error(
159
- `Audio transcode is enabled, but ffmpeg check failed (${ffmpegBin}, exit code ${probe.status}).`
160
- );
161
- }
162
- }
163
-
164
- /** @type {boolean} */
165
- let registrationInProgress = false;
166
-
167
- /** @type {import("fastify").FastifyInstance | null} */
168
- let app = null;
169
-
170
- /** @type {number} */
171
- let actualPort = localPort;
172
-
173
- /** @type {boolean} */
174
- let shutdownInProgress = false;
175
-
176
- /** @type {ReturnType<typeof createTunnelClient> | null} */
177
- let tunnelClient = null;
178
-
179
- /** @type {ReturnType<typeof createPortMapper> | null} */
180
- let portMapper = null;
181
-
182
- /** @type {ReturnType<typeof createPortMapper> | null} UDP mapping for the WebRTC port. */
183
- let udpPortMapper = null;
184
-
185
- /** @type {ReturnType<typeof createWebRtcManager> | null} */
186
- let webRtcManager = null;
187
- /**
188
- * The packet witness, so shutdown can stop the ring it owns.
189
- *
190
- * @type {ReturnType<typeof createPacketWitness> | null}
191
- */
192
- let packetWitness = null;
193
-
194
- /** @type {ReturnType<typeof createUsrsctpStateReader> | null} */
195
- let usrsctpStateReader = null;
196
-
197
- /** @type {ReturnType<typeof createDataChannelHandler> | null} */
198
- let dataChannelHandler = null;
199
-
200
- /** @type {import("../services/nat-classifier.js").NatClassification | null} Latest NAT classification (for WebRTC port prediction). */
201
- let natInfo = null;
202
-
203
-
204
- /**
205
- * Register this proxy with the registry server.
206
- * Silently skips if a registration is already in flight.
207
- *
208
- * @returns {Promise<void>}
209
- */
210
- async function registerClientSafe() {
211
- if (registrationInProgress) {
212
- return;
213
- }
214
- registrationInProgress = true;
215
- try {
216
- const result = await registerClient({
217
- serverUrl,
218
- id: clientId,
219
- name: clientName,
220
- baseUrl: explicitBaseUrl || `http://${bindHost}:${actualPort}`,
221
- token
222
- });
223
- logger.success(`Registered as "${result.client?.name}" (${result.client?.id?.slice(0, 8)})`);
224
- } finally {
225
- registrationInProgress = false;
226
- }
227
- }
228
-
229
- /**
230
- * Register this proxy with the registry server, retrying indefinitely
231
- * with exponential backoff until it succeeds. This allows the proxy to
232
- * survive temporary server outages (restarts, deployments) without crashing.
233
- *
234
- * @returns {Promise<void>}
235
- */
236
- async function registerWithRetry() {
237
- const MAX_DELAY_MS = 60_000;
238
- let delayMs = 2_000;
239
- let attempt = 0;
240
- while (true) {
241
- attempt++;
242
- try {
243
- await registerClientSafe();
244
- return;
245
- } catch (error) {
246
- const message = error instanceof Error ? error.message : String(error);
247
- logger.warn(`Registration attempt ${attempt} failed: ${message}. Retrying in ${delayMs / 1000}s…`);
248
- await new Promise((resolve) => setTimeout(resolve, delayMs));
249
- delayMs = Math.min(delayMs * 2, MAX_DELAY_MS);
250
- }
251
- }
252
- }
253
-
254
- /**
255
- * Gracefully shut down the tunnel, heartbeat timer, and HTTP server.
256
- *
257
- * @param {string} signal - Signal name (e.g. "SIGINT").
258
- * @returns {Promise<void>}
259
- */
260
- async function shutdown(signal) {
261
- if (shutdownInProgress) {
262
- return;
263
- }
264
- shutdownInProgress = true;
265
- if (tunnelClient) {
266
- tunnelClient.disconnect();
267
- tunnelClient = null;
268
- }
269
- logger.warn(`Received ${signal}, shutting down...`);
270
- try {
271
- // Remove the router port mappings before exiting (lease expiry is the
272
- // backstop if this is skipped on a hard kill).
273
- if (portMapper) {
274
- await portMapper.stop();
275
- portMapper = null;
276
- }
277
- if (udpPortMapper) {
278
- await udpPortMapper.stop();
279
- udpPortMapper = null;
280
- }
281
- // Close WebRTC sessions and release the shared UDP mux listener socket.
282
- if (webRtcManager) {
283
- try { webRtcManager.dispose(); } catch { /* ignore */ }
284
- webRtcManager = null;
285
- }
286
- // Stop the packet ring and remove its scratch files. `process.exit` below
287
- // does not take child processes with it, so without this a proxy restarted
288
- // outside a container leaves one tcpdump running per restart.
289
- if (packetWitness) {
290
- try { await packetWitness.dispose(); } catch { /* ignore */ }
291
- packetWitness = null;
292
- }
293
- if (app) {
294
- await app.close();
295
- }
296
- process.exit(0);
297
- } catch (error) {
298
- const message = error instanceof Error ? error.message : String(error);
299
- logger.error(`Shutdown failed: ${message}`);
300
- process.exit(1);
301
- }
302
- }
303
-
304
- try {
305
- logToFile(options.logFile);
306
- if (transcodeAudio) {
307
- assertFfmpegAvailability();
308
- }
309
- const started = await startProxyServer({
310
- host: bindHost,
311
- port: localPort,
312
- transcodeAudio,
313
- ffmpegBin,
314
- maxDiskBytes,
315
- memoryBytes,
316
- segmentFormat: options.segmentFormat,
317
- stateDir: options.stateDir,
318
- deliverySink: options.deliverySink === true,
319
- // Late-bound the same way `webRtcManager` is below: the torrent pool is
320
- // built inside `startProxyServer`, before `dataChannelHandler` — the
321
- // thing that actually owns a channel to push down — exists.
322
- onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event)
323
- });
324
- app = started.app;
325
- actualPort = started.port;
326
- const sourceRegistry = started.sourceRegistry;
327
- const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
328
-
329
- // A native fault writes the whole address space out — 4.18 GB each on the
330
- // field host, and four of them nearly filled a 235 GB disk. Keep the newest
331
- // two, which are the evidence for the fault still open, and drop the rest.
332
- void pruneCoreDumps(options.stateDir);
333
- // Same policy for the packet captures the send-queue witness writes.
334
- packetWitness = createPacketWitness({
335
- log: (message) => logger.info(message),
336
- dir: options.stateDir || "",
337
- port: actualPort
338
- });
339
- // A process that was KILLED mid-session leaves the ring's last seconds
340
- // behind, and those seconds contain whatever ended it. Keep them under a name
341
- // the pruner recognises BEFORE anything starts a new ring over them.
342
- void adoptOrphanRingFiles(packetWitness.dir).then(() => pruneWitnessCaptures(packetWitness.dir));
343
-
344
- // Reads usrsctp's own association state via gdb the moment a wedge is
345
- // declared (roadmap item 11) — no source rebuild, the module ships
346
- // unstripped. A host without gdb just never gets a reading, the same way a
347
- // host without tcpdump never gets a packet capture.
348
- usrsctpStateReader = createUsrsctpStateReader({ log: (message) => logger.info(message) });
349
-
350
- // What this process holds, once a minute. The kernel killed the proxy on
351
- // 2026-08-28 at 2.4 GB resident and the log had never said a word about
352
- // memory, so the growth that ended in that kill has no shape in any record we
353
- // keep. RSS is the figure the OOM killer reads, so RSS is the figure to say.
354
- // The disk path is where pieces spill and torrent data lands. A budget for
355
- // memory alone is half a budget: a store that behaves about RAM can still
356
- // fill the card an addon host boots from, and neither limit was measured
357
- // before (roadmap item 2).
358
- startMemoryReport({
359
- log: (message) => {
360
- logger.info(message);
361
- // The other half of the piece-buffer question. Shared memory lives until
362
- // BOTH isolates let go, so the worker's own count answers only its side;
363
- // printed here, beside the process figures the growth shows up in.
364
- const fragments = fragmentBufferCollection();
365
- if (fragments.seen > 0) {
366
- logger.info(
367
- `piece buffers handed to this thread: ${fragments.seen} seen, ` +
368
- `${fragments.collected} collected, ${fragments.seen - fragments.collected} still alive`
369
- );
370
- }
371
- },
372
- diskPath: os.tmpdir()
373
- });
374
-
375
- logger.info(`Starting @torrent-tv/proxy v${PROXY_VERSION}`);
376
- logger.info(`Local stream endpoint: http://${bindHost}:${actualPort}/stream`);
377
- logger.info(`Advertised direct URL: ${directBaseUrl}`);
378
- if (transcodeAudio) {
379
- logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
380
- }
381
-
382
- // All WebRTC sessions are multiplexed onto this single UDP port (same number
383
- // as the HTTP port, different protocol) via a persistent ICE UDP mux listener
384
- // in the WebRTC manager. One port → one UPnP mapping → one reachable endpoint.
385
- const webrtcUdpPort = actualPort;
386
-
387
- // Try to open the local port on the home router (UPnP/NAT-PMP) so the proxy
388
- // is reachable from the internet without manual port forwarding. Best-effort
389
- // and fire-and-forget: failure is normal (router without UPnP) and must not
390
- // delay tunnel connect / registration, so we do not await it.
391
- if (portMappingEnabled) {
392
- portMapper = createPortMapper({ port: actualPort, protocol: "TCP" });
393
- void portMapper
394
- .start()
395
- .then(() => {
396
- // Mapping may finish after the tunnel is already connected; report the
397
- // endpoint now. If the tunnel is not open yet, onConnect re-sends it.
398
- const endpoint = portMapper?.getMappedEndpoint();
399
- if (endpoint) {
400
- tunnelClient?.sendEndpoint(endpoint);
401
- }
402
- })
403
- .catch((error) => {
404
- const message = error instanceof Error ? error.message : String(error);
405
- logger.warn(`Port mapping failed to start: ${message}`);
406
- });
407
-
408
- // Also map the single WebRTC UDP port. Not reported to the server: the
409
- // browser discovers the endpoint via ICE (srflx) candidates, not the TCP
410
- // dial-back probe.
411
- udpPortMapper = createPortMapper({
412
- port: webrtcUdpPort,
413
- protocol: "UDP",
414
- description: "torrent-tv proxy (WebRTC)"
415
- });
416
- void udpPortMapper.start().catch((error) => {
417
- const message = error instanceof Error ? error.message : String(error);
418
- logger.warn(`UDP port mapping failed to start: ${message}`);
419
- });
420
- } else {
421
- logger.info("Automatic port mapping is disabled (--no-port-mapping).");
422
- }
423
-
424
- // Classify the home NAT (diagnostic + decides whether WebRTC will need port
425
- // prediction for remote viewers). Best-effort, fire-and-forget — STUN probes
426
- // never block startup.
427
- void classifyNat()
428
- .then((nat) => {
429
- // Stored for WebRTC port prediction (webRtcManager reads it per session).
430
- natInfo = nat;
431
- if (nat.klass === "endpoint-independent") {
432
- logger.info(
433
- `nat: endpoint-independent (cone) — external UDP port stable across STUN servers (${nat.externalIp}); fixed-port WebRTC mapping is sufficient, no port prediction needed`
434
- );
435
- } else if (nat.klass === "symmetric") {
436
- logger.warn(
437
- `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`
438
- );
439
- } else {
440
- logger.info("nat: classification inconclusive (STUN probes failed); continuing");
441
- }
442
- })
443
- .catch((error) => {
444
- const message = error instanceof Error ? error.message : String(error);
445
- logger.warn(`nat classification failed: ${message}`);
446
- });
447
-
448
- // Create tunnel + WebRTC manager.
449
- // The tunnel forwards WebRTC signals between browser (via server) and this proxy.
450
- // The WebRTC manager handles the actual peer connection and data channel.
451
- //
452
- // We use a late-binding ref so both objects can reference each other without
453
- // running into the TDZ (tunnelClient is declared above; webRtcManager is the
454
- // module-scoped `let` above so the closure in createTunnelClient — and the
455
- // shutdown handler can reach it after initialisation).
456
-
457
- tunnelClient = createTunnelClient({
458
- serverUrl,
459
- proxyId: clientId,
460
- token,
461
- proxyPort: actualPort,
462
- onSignal(sessionId, signal) {
463
- webRtcManager?.handleSignal(sessionId, signal);
464
- },
465
- onHealthRequest() {
466
- return collectHealthMetrics();
467
- },
468
- onConnect() {
469
- // Re-register on every tunnel connect/reconnect so the server's
470
- // in-memory store stays consistent after server restarts.
471
- void registerClientSafe().catch((error) => {
472
- const message = error instanceof Error ? error.message : String(error);
473
- logger.error(`Re-registration after tunnel connect failed: ${message}`);
474
- });
475
- // Re-report the mapped endpoint on every (re)connect — the server's
476
- // in-memory reachability state resets on restart, and the mapping may
477
- // have completed before this connection existed.
478
- const endpoint = portMapper?.getMappedEndpoint();
479
- if (endpoint) {
480
- tunnelClient?.sendEndpoint(endpoint);
481
- }
482
- },
483
- onLog: (message) => logger.info(message)
484
- });
485
-
486
- dataChannelHandler = createDataChannelHandler({
487
- proxyPort: actualPort,
488
- onLog: (message) => logger.info(message),
489
- // Resolves a browser's registry sourceKey to the torrent pool's own key
490
- // (the content's infohash) so the subtitle push subscription and the
491
- // pool's own publish agree on what a source is called. See server.js.
492
- sourceRegistry,
493
- // Lets a stuck send queue ask the transport what it is doing. Late-bound:
494
- // the manager is created below, with this handler already in hand.
495
- getTransportSnapshot: (sessionId) => webRtcManager?.getTransportSnapshot(sessionId) ?? null,
496
- // Records the wire when a queue stays wedged — how the rare one-way
497
- // transmit death (roadmap item 10, 2026-08-24) gets its evidence.
498
- witness: packetWitness,
499
- usrsctpState: usrsctpStateReader
500
- });
501
-
502
- webRtcManager = createWebRtcManager({
503
- // Single UDP port (UPnP-mapped above) shared by all sessions via a
504
- // persistent ICE UDP mux listener, so the WebRTC path is reachable from the
505
- // internet on one fixed port.
506
- udpPort: webrtcUdpPort,
507
- // Latest NAT classificationenables symmetric-NAT port prediction.
508
- getNatInfo: () => natInfo,
509
- sendSignal(sessionId, signal) {
510
- tunnelClient?.sendSignal(sessionId, signal);
511
- },
512
- onDataChannel(sessionId, channel) {
513
- dataChannelHandler.handleChannel(sessionId, channel);
514
- },
515
- onLog: (message) => logger.info(message)
516
- });
517
-
518
- tunnelClient.connect();
519
-
520
- await registerWithRetry();
521
- } catch (error) {
522
- const message = error instanceof Error ? error.message : String(error);
523
- logger.error(message);
524
- process.exit(1);
525
- }
526
-
527
- process.on("SIGINT", () => {
528
- void shutdown("SIGINT");
529
- });
530
-
531
- process.on("SIGTERM", () => {
532
- void shutdown("SIGTERM");
533
- });
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("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
89
+ .option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
90
+ .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
91
+ .option(
92
+ "--state-dir <path>",
93
+ "Where to keep what this host has measured about itself (default: beside the installed proxy)"
94
+ )
95
+ .option(
96
+ "--log-file <path>",
97
+ "Also write the log to this file, so a crash or an update does not take it with them"
98
+ )
99
+ .option(
100
+ "--segment-format <format>",
101
+ `HLS output container: ${SEGMENT_FORMAT_IDS.join(" | ")}`,
102
+ DEFAULT_SEGMENT_FORMAT_ID
103
+ )
104
+ .option("--token <token>", "Registration token", "")
105
+ .addHelpText("after", HELP_EXAMPLES);
106
+
107
+ program.parse(process.argv);
108
+ const options = program.opts();
109
+
110
+ const localPort = Number(options.port);
111
+ if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
112
+ logger.error("Invalid --port value.");
113
+ process.exit(1);
114
+ }
115
+
116
+ const serverUrl = String(options.serverUrl).replace(/\/+$/, "");
117
+ const bindHost = String(options.host);
118
+ const explicitBaseUrl = options.publicBaseUrl
119
+ ? String(options.publicBaseUrl).replace(/\/+$/, "")
120
+ : "";
121
+ const clientId = options.id ? String(options.id) : crypto.randomUUID();
122
+ const clientName = options.name ? String(options.name) : `proxy-${clientId.slice(0, 8)}`;
123
+ const token = String(options.token ?? "");
124
+ const transcodeAudio = options.transcodeAudio !== false;
125
+ const portMappingEnabled = options.portMapping !== false;
126
+ // Optional disk cap. undefined → the pool computes its own default; a valid
127
+ // non-negative number (0 disables) → passed through.
128
+ const maxDiskBytes =
129
+ options.maxDiskBytes !== undefined && Number.isFinite(Number(options.maxDiskBytes)) && Number(options.maxDiskBytes) >= 0
130
+ ? Number(options.maxDiskBytes)
131
+ : undefined;
132
+ // Per-torrent memory budget for resident pieces. Pieces past it spill to disk
133
+ // rather than being lost, so a small value costs read latency, never data.
134
+ const memoryBytes =
135
+ options.memoryBytes !== undefined && Number.isFinite(Number(options.memoryBytes)) && Number(options.memoryBytes) > 0
136
+ ? Number(options.memoryBytes)
137
+ : undefined;
138
+ const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
139
+ const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
140
+
141
+ /**
142
+ * Verify that the ffmpeg binary is reachable and exits cleanly.
143
+ * Throws with a descriptive message when the check fails.
144
+ *
145
+ * @returns {void}
146
+ */
147
+ function assertFfmpegAvailability() {
148
+ const probe = spawnSync(ffmpegBin, ["-version"], {
149
+ stdio: "ignore",
150
+ windowsHide: true,
151
+ timeout: 5000
152
+ });
153
+ if (probe.error) {
154
+ const message = probe.error instanceof Error ? probe.error.message : String(probe.error);
155
+ throw new Error(`Audio transcode is enabled, but ffmpeg is unavailable (${ffmpegBin}): ${message}`);
156
+ }
157
+ if (typeof probe.status === "number" && probe.status !== 0) {
158
+ throw new Error(
159
+ `Audio transcode is enabled, but ffmpeg check failed (${ffmpegBin}, exit code ${probe.status}).`
160
+ );
161
+ }
162
+ }
163
+
164
+ /** @type {boolean} */
165
+ let registrationInProgress = false;
166
+
167
+ /** @type {import("fastify").FastifyInstance | null} */
168
+ let app = null;
169
+
170
+ /** @type {number} */
171
+ let actualPort = localPort;
172
+
173
+ /** @type {boolean} */
174
+ let shutdownInProgress = false;
175
+
176
+ /** @type {ReturnType<typeof createTunnelClient> | null} */
177
+ let tunnelClient = null;
178
+
179
+ /** @type {ReturnType<typeof createPortMapper> | null} */
180
+ let portMapper = null;
181
+
182
+ /** @type {ReturnType<typeof createPortMapper> | null} UDP mapping for the WebRTC port. */
183
+ let udpPortMapper = null;
184
+
185
+ /** @type {ReturnType<typeof createWebRtcManager> | null} */
186
+ let webRtcManager = null;
187
+ /**
188
+ * The packet witness, so shutdown can stop the ring it owns.
189
+ *
190
+ * @type {ReturnType<typeof createPacketWitness> | null}
191
+ */
192
+ let packetWitness = null;
193
+
194
+ /** @type {ReturnType<typeof createUsrsctpStateReader> | null} */
195
+ let usrsctpStateReader = null;
196
+
197
+ /** @type {ReturnType<typeof createDataChannelHandler> | null} */
198
+ let dataChannelHandler = null;
199
+
200
+ /** @type {import("../services/nat-classifier.js").NatClassification | null} Latest NAT classification (for WebRTC port prediction). */
201
+ let natInfo = null;
202
+
203
+
204
+ /**
205
+ * Register this proxy with the registry server.
206
+ * Silently skips if a registration is already in flight.
207
+ *
208
+ * @returns {Promise<void>}
209
+ */
210
+ async function registerClientSafe() {
211
+ if (registrationInProgress) {
212
+ return;
213
+ }
214
+ registrationInProgress = true;
215
+ try {
216
+ const result = await registerClient({
217
+ serverUrl,
218
+ id: clientId,
219
+ name: clientName,
220
+ baseUrl: explicitBaseUrl || `http://${bindHost}:${actualPort}`,
221
+ token
222
+ });
223
+ logger.success(`Registered as "${result.client?.name}" (${result.client?.id?.slice(0, 8)})`);
224
+ } finally {
225
+ registrationInProgress = false;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Register this proxy with the registry server, retrying indefinitely
231
+ * with exponential backoff until it succeeds. This allows the proxy to
232
+ * survive temporary server outages (restarts, deployments) without crashing.
233
+ *
234
+ * @returns {Promise<void>}
235
+ */
236
+ async function registerWithRetry() {
237
+ const MAX_DELAY_MS = 60_000;
238
+ let delayMs = 2_000;
239
+ let attempt = 0;
240
+ while (true) {
241
+ attempt++;
242
+ try {
243
+ await registerClientSafe();
244
+ return;
245
+ } catch (error) {
246
+ const message = error instanceof Error ? error.message : String(error);
247
+ logger.warn(`Registration attempt ${attempt} failed: ${message}. Retrying in ${delayMs / 1000}s…`);
248
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
249
+ delayMs = Math.min(delayMs * 2, MAX_DELAY_MS);
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Gracefully shut down the tunnel, heartbeat timer, and HTTP server.
256
+ *
257
+ * @param {string} signal - Signal name (e.g. "SIGINT").
258
+ * @returns {Promise<void>}
259
+ */
260
+ async function shutdown(signal) {
261
+ if (shutdownInProgress) {
262
+ return;
263
+ }
264
+ shutdownInProgress = true;
265
+ if (tunnelClient) {
266
+ tunnelClient.disconnect();
267
+ tunnelClient = null;
268
+ }
269
+ logger.warn(`Received ${signal}, shutting down...`);
270
+ try {
271
+ // Remove the router port mappings before exiting (lease expiry is the
272
+ // backstop if this is skipped on a hard kill).
273
+ if (portMapper) {
274
+ await portMapper.stop();
275
+ portMapper = null;
276
+ }
277
+ if (udpPortMapper) {
278
+ await udpPortMapper.stop();
279
+ udpPortMapper = null;
280
+ }
281
+ // Close WebRTC sessions and release the shared UDP mux listener socket.
282
+ if (webRtcManager) {
283
+ try { webRtcManager.dispose(); } catch { /* ignore */ }
284
+ webRtcManager = null;
285
+ }
286
+ // Stop the packet ring and remove its scratch files. `process.exit` below
287
+ // does not take child processes with it, so without this a proxy restarted
288
+ // outside a container leaves one tcpdump running per restart.
289
+ if (packetWitness) {
290
+ try { await packetWitness.dispose(); } catch { /* ignore */ }
291
+ packetWitness = null;
292
+ }
293
+ if (app) {
294
+ await app.close();
295
+ }
296
+ process.exit(0);
297
+ } catch (error) {
298
+ const message = error instanceof Error ? error.message : String(error);
299
+ logger.error(`Shutdown failed: ${message}`);
300
+ process.exit(1);
301
+ }
302
+ }
303
+
304
+ try {
305
+ logToFile(options.logFile);
306
+ if (transcodeAudio) {
307
+ assertFfmpegAvailability();
308
+ }
309
+ const started = await startProxyServer({
310
+ host: bindHost,
311
+ port: localPort,
312
+ transcodeAudio,
313
+ ffmpegBin,
314
+ maxDiskBytes,
315
+ memoryBytes,
316
+ segmentFormat: options.segmentFormat,
317
+ stateDir: options.stateDir,
318
+ deliverySink: options.deliverySink === true,
319
+ // Late-bound the same way `webRtcManager` is below: the torrent pool is
320
+ // built inside `startProxyServer`, before `dataChannelHandler` — the
321
+ // thing that actually owns a channel to push down — exists.
322
+ onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event)
323
+ });
324
+ app = started.app;
325
+ actualPort = started.port;
326
+ const sourceRegistry = started.sourceRegistry;
327
+ const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
328
+
329
+ // A native fault writes the whole address space out — 4.18 GB each on the
330
+ // field host, and four of them nearly filled a 235 GB disk. Keep the newest
331
+ // two, which are the evidence for the fault still open, and drop the rest.
332
+ void pruneCoreDumps(options.stateDir);
333
+ // Same policy for the packet captures the send-queue witness writes.
334
+ packetWitness = createPacketWitness({
335
+ log: (message) => logger.info(message),
336
+ dir: options.stateDir || "",
337
+ port: actualPort
338
+ });
339
+ // A process that was KILLED mid-session leaves the ring's last seconds
340
+ // behind, and those seconds contain whatever ended it. Keep them under a name
341
+ // the pruner recognises BEFORE anything starts a new ring over them.
342
+ void adoptOrphanRingFiles(packetWitness.dir).then(() => pruneWitnessCaptures(packetWitness.dir));
343
+
344
+ // Reads usrsctp's own association state via gdb the moment a wedge is
345
+ // declared (roadmap item 11) — no source rebuild, the module ships
346
+ // unstripped. A host without gdb just never gets a reading, the same way a
347
+ // host without tcpdump never gets a packet capture.
348
+ usrsctpStateReader = createUsrsctpStateReader({ log: (message) => logger.info(message) });
349
+
350
+ // What this process holds, once a minute. The kernel killed the proxy on
351
+ // 2026-08-28 at 2.4 GB resident and the log had never said a word about
352
+ // memory, so the growth that ended in that kill has no shape in any record we
353
+ // keep. RSS is the figure the OOM killer reads, so RSS is the figure to say.
354
+ // The disk path is where pieces spill and torrent data lands. A budget for
355
+ // memory alone is half a budget: a store that behaves about RAM can still
356
+ // fill the card an addon host boots from, and neither limit was measured
357
+ // before (roadmap item 2).
358
+ startMemoryReport({
359
+ log: (message) => logger.info(message),
360
+ // The other half of the piece-buffer question. Shared memory lives until
361
+ // BOTH isolates let go, so the worker's own count answers only its side;
362
+ // read here, on the same line as the process figures the growth shows up
363
+ // in and at the same instant as them.
364
+ readExtra: () => {
365
+ const fragments = fragmentBufferCollection();
366
+ return fragments.seen === 0
367
+ ? ""
368
+ : `piece buffers handed here ${fragments.seen} seen, ${fragments.collected} collected, ` +
369
+ `${fragments.seen - fragments.collected} still alive`;
370
+ },
371
+ // Once a minute cannot see what kills this process. Both out-of-memory
372
+ // kills of 2026-09-02 happened inside a single gap of the old cadence: the
373
+ // last line before the first said 602 MB and the kernel measured 1.72 GB
374
+ // 34 seconds later, and before the second it said 1171 MB and the kernel
375
+ // measured 1.86 GB nine seconds later. The figure is read every second
376
+ // now and written when it has moved by a step worth a line, so a rise of a
377
+ // gigabyte is a curve instead of one number and then a death. Every /proc
378
+ // read stayed behind the decision to write, so a quiet second costs one
379
+ // call to `process.memoryUsage()`.
380
+ intervalMs: 1_000,
381
+ quietMs: 60_000,
382
+ changeBytes: 25 * 1024 * 1024,
383
+ diskPath: os.tmpdir()
384
+ });
385
+
386
+ logger.info(`Starting @torrent-tv/proxy v${PROXY_VERSION}`);
387
+ logger.info(`Local stream endpoint: http://${bindHost}:${actualPort}/stream`);
388
+ logger.info(`Advertised direct URL: ${directBaseUrl}`);
389
+ if (transcodeAudio) {
390
+ logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
391
+ }
392
+
393
+ // All WebRTC sessions are multiplexed onto this single UDP port (same number
394
+ // as the HTTP port, different protocol) via a persistent ICE UDP mux listener
395
+ // in the WebRTC manager. One port → one UPnP mapping → one reachable endpoint.
396
+ const webrtcUdpPort = actualPort;
397
+
398
+ // Try to open the local port on the home router (UPnP/NAT-PMP) so the proxy
399
+ // is reachable from the internet without manual port forwarding. Best-effort
400
+ // and fire-and-forget: failure is normal (router without UPnP) and must not
401
+ // delay tunnel connect / registration, so we do not await it.
402
+ if (portMappingEnabled) {
403
+ portMapper = createPortMapper({ port: actualPort, protocol: "TCP" });
404
+ void portMapper
405
+ .start()
406
+ .then(() => {
407
+ // Mapping may finish after the tunnel is already connected; report the
408
+ // endpoint now. If the tunnel is not open yet, onConnect re-sends it.
409
+ const endpoint = portMapper?.getMappedEndpoint();
410
+ if (endpoint) {
411
+ tunnelClient?.sendEndpoint(endpoint);
412
+ }
413
+ })
414
+ .catch((error) => {
415
+ const message = error instanceof Error ? error.message : String(error);
416
+ logger.warn(`Port mapping failed to start: ${message}`);
417
+ });
418
+
419
+ // Also map the single WebRTC UDP port. Not reported to the server: the
420
+ // browser discovers the endpoint via ICE (srflx) candidates, not the TCP
421
+ // dial-back probe.
422
+ udpPortMapper = createPortMapper({
423
+ port: webrtcUdpPort,
424
+ protocol: "UDP",
425
+ description: "torrent-tv proxy (WebRTC)"
426
+ });
427
+ void udpPortMapper.start().catch((error) => {
428
+ const message = error instanceof Error ? error.message : String(error);
429
+ logger.warn(`UDP port mapping failed to start: ${message}`);
430
+ });
431
+ } else {
432
+ logger.info("Automatic port mapping is disabled (--no-port-mapping).");
433
+ }
434
+
435
+ // Classify the home NAT (diagnostic + decides whether WebRTC will need port
436
+ // prediction for remote viewers). Best-effort, fire-and-forget — STUN probes
437
+ // never block startup.
438
+ void classifyNat()
439
+ .then((nat) => {
440
+ // Stored for WebRTC port prediction (webRtcManager reads it per session).
441
+ natInfo = nat;
442
+ if (nat.klass === "endpoint-independent") {
443
+ logger.info(
444
+ `nat: endpoint-independent (cone) external UDP port stable across STUN servers (${nat.externalIp}); fixed-port WebRTC mapping is sufficient, no port prediction needed`
445
+ );
446
+ } else if (nat.klass === "symmetric") {
447
+ logger.warn(
448
+ `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`
449
+ );
450
+ } else {
451
+ logger.info("nat: classification inconclusive (STUN probes failed); continuing");
452
+ }
453
+ })
454
+ .catch((error) => {
455
+ const message = error instanceof Error ? error.message : String(error);
456
+ logger.warn(`nat classification failed: ${message}`);
457
+ });
458
+
459
+ // Create tunnel + WebRTC manager.
460
+ // The tunnel forwards WebRTC signals between browser (via server) and this proxy.
461
+ // The WebRTC manager handles the actual peer connection and data channel.
462
+ //
463
+ // We use a late-binding ref so both objects can reference each other without
464
+ // running into the TDZ (tunnelClient is declared above; webRtcManager is the
465
+ // module-scoped `let` above so the closure in createTunnelClient — and the
466
+ // shutdown handler — can reach it after initialisation).
467
+
468
+ tunnelClient = createTunnelClient({
469
+ serverUrl,
470
+ proxyId: clientId,
471
+ token,
472
+ proxyPort: actualPort,
473
+ onSignal(sessionId, signal) {
474
+ webRtcManager?.handleSignal(sessionId, signal);
475
+ },
476
+ onHealthRequest() {
477
+ return collectHealthMetrics();
478
+ },
479
+ onConnect() {
480
+ // Re-register on every tunnel connect/reconnect so the server's
481
+ // in-memory store stays consistent after server restarts.
482
+ void registerClientSafe().catch((error) => {
483
+ const message = error instanceof Error ? error.message : String(error);
484
+ logger.error(`Re-registration after tunnel connect failed: ${message}`);
485
+ });
486
+ // Re-report the mapped endpoint on every (re)connect — the server's
487
+ // in-memory reachability state resets on restart, and the mapping may
488
+ // have completed before this connection existed.
489
+ const endpoint = portMapper?.getMappedEndpoint();
490
+ if (endpoint) {
491
+ tunnelClient?.sendEndpoint(endpoint);
492
+ }
493
+ },
494
+ onLog: (message) => logger.info(message)
495
+ });
496
+
497
+ dataChannelHandler = createDataChannelHandler({
498
+ proxyPort: actualPort,
499
+ onLog: (message) => logger.info(message),
500
+ // Resolves a browser's registry sourceKey to the torrent pool's own key
501
+ // (the content's infohash) so the subtitle push subscription and the
502
+ // pool's own publish agree on what a source is called. See server.js.
503
+ sourceRegistry,
504
+ // Lets a stuck send queue ask the transport what it is doing. Late-bound:
505
+ // the manager is created below, with this handler already in hand.
506
+ getTransportSnapshot: (sessionId) => webRtcManager?.getTransportSnapshot(sessionId) ?? null,
507
+ // Records the wire when a queue stays wedged how the rare one-way
508
+ // transmit death (roadmap item 10, 2026-08-24) gets its evidence.
509
+ witness: packetWitness,
510
+ usrsctpState: usrsctpStateReader
511
+ });
512
+
513
+ webRtcManager = createWebRtcManager({
514
+ // Single UDP port (UPnP-mapped above) shared by all sessions via a
515
+ // persistent ICE UDP mux listener, so the WebRTC path is reachable from the
516
+ // internet on one fixed port.
517
+ udpPort: webrtcUdpPort,
518
+ // Latest NAT classification — enables symmetric-NAT port prediction.
519
+ getNatInfo: () => natInfo,
520
+ sendSignal(sessionId, signal) {
521
+ tunnelClient?.sendSignal(sessionId, signal);
522
+ },
523
+ onDataChannel(sessionId, channel) {
524
+ dataChannelHandler.handleChannel(sessionId, channel);
525
+ },
526
+ onLog: (message) => logger.info(message)
527
+ });
528
+
529
+ tunnelClient.connect();
530
+
531
+ await registerWithRetry();
532
+ } catch (error) {
533
+ const message = error instanceof Error ? error.message : String(error);
534
+ logger.error(message);
535
+ process.exit(1);
536
+ }
537
+
538
+ process.on("SIGINT", () => {
539
+ void shutdown("SIGINT");
540
+ });
541
+
542
+ process.on("SIGTERM", () => {
543
+ void shutdown("SIGTERM");
544
+ });