@torrent-tv/proxy 2.2.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Dockerfile CHANGED
@@ -1,5 +1,5 @@
1
1
  # syntax=docker/dockerfile:1.7
2
- FROM node:22-alpine
2
+ FROM node:24-alpine
3
3
 
4
4
  ENV NODE_ENV=production
5
5
  ENV PORT=9090
package/README.md CHANGED
@@ -1,230 +1,335 @@
1
- # Torrent Proxy Client
1
+ # @torrent-tv/proxy
2
2
 
3
- `@torrent-tv/proxy` is a lightweight Node.js service that turns torrent content into HTTP endpoints that are easy to consume from web players and backend services.
4
-
5
- It is designed for setups where a central registry/UI needs a simple direct media URL, while the actual torrent fetching happens on a separate edge/client machine.
3
+ A lightweight Node.js service that streams torrent content to browsers via a direct WebRTC P2P data channel or, when needed, HTTP. It handles torrent fetching, codec detection, and on-demand HLS transcoding with ffmpeg.
6
4
 
7
5
  ## Why this exists
8
6
 
9
- - Browsers and many media players cannot consume torrents directly.
10
- - This service exposes torrent files through regular HTTP (`/stream`) with range support.
11
- - It can optionally create HLS sessions with AAC audio when direct playback is not suitable.
12
- - It can also create HLS sessions with video transcoding when browser-side decode still fails.
13
- - It registers itself in an external registry service and sends heartbeats, so other services can discover and use it.
7
+ - Browsers cannot consume torrents directly.
8
+ - This service exposes torrent files through HTTP (`/stream`) with Range support, and through a WebRTC data channel for NAT-traversed streaming.
9
+ - It can transcode audio (or video + audio) to HLS on demand so the browser can always play the content regardless of codec support.
10
+ - It registers itself in an external registry server and maintains a persistent tunnel WebSocket so the server can route browser requests and WebRTC signals to it.
14
11
 
15
- ## What it does
12
+ ## Architecture Overview
16
13
 
17
- - Runs a Fastify server with health and media endpoints.
18
- - Accepts torrent sources (`magnet` or base64 `.torrent`) and returns a stable `sourceKey`.
19
- - Streams a selected file from a torrent by `fileIndex`.
20
- - Builds a playback plan (`direct` vs `hls`) based on detected audio codec and returns both audio/video codecs.
21
- - Starts ffmpeg-based HLS transcoding sessions (`audio-only` or `video+audio`) and serves generated playlist/segments.
22
- - Tracks multi-client consumers per transcode session and stops ffmpeg when the last consumer releases.
14
+ ```mermaid
15
+ graph TB
16
+ subgraph Browser
17
+ WP[WebRtcProxy]
18
+ HLS[HLS.js + WebRtcHlsLoader]
19
+ end
23
20
 
24
- ## Requirements
21
+ subgraph Server["Registry Server"]
22
+ API[REST API]
23
+ TS[ProxyTunnelServer]
24
+ SH[SignalHub /ws/browser-signal]
25
+ end
25
26
 
26
- - Node.js 18+ (ESM and built-in `fetch` are required).
27
- - npm.
28
- - ffmpeg is required only when audio transcoding is enabled.
29
- - By default, the package uses `ffmpeg-static`.
30
- - You can override binary path with `--ffmpeg-bin`.
31
- - You can disable transcoding with `--no-transcode-audio`.
27
+ subgraph Proxy["@torrent-tv/proxy (Fastify)"]
28
+ TC[TunnelClient]
29
+ WM[WebRtcManager]
30
+ HC[HealthCollector]
31
+ DCH[DataChannelHandler]
32
+ PP[PlaybackPlanner]
33
+ HLM[HlsSessionManager]
34
+ TP[TorrentPool]
35
+ FF[ffmpeg]
36
+ end
32
37
 
33
- ## Install
38
+ TC -->|"persistent WebSocket /ws/proxy-tunnel"| TS
39
+ TC -->|re-register on reconnect| API
40
+ HC -->|metrics: cpu, mem, load| TC
34
41
 
35
- ```bash
36
- npm install
42
+ TS <-->|signal forward| SH
43
+ SH <-->|WebSocket| WP
44
+
45
+ WP <-->|"P2P data channel (STUN)"| WM
46
+ WM --> DCH
47
+ DCH --> TP
48
+ DCH --> HLM
49
+ HLM --> FF
50
+
51
+ HLS -->|segment/manifest fetches| WP
37
52
  ```
38
53
 
39
- ## Run
54
+ ## Service Internals
40
55
 
41
- ```bash
42
- npm start -- --server-url http://localhost:3000
56
+ ### TunnelClient
57
+
58
+ Opens one persistent WebSocket to the registry server's `/ws/proxy-tunnel` endpoint on startup.
59
+ Reconnects automatically with back-off on unexpected close.
60
+
61
+ Handles three inbound message types from the server:
62
+
63
+ | Message type | What the proxy does |
64
+ |---|---|
65
+ | `health-request` | Calls `HealthCollector`, sends `health-response` back through tunnel |
66
+ | `signal` | Forwards SDP offer or ICE candidate to `WebRtcManager` |
67
+ | `relay-request` | Fetches the path from local Fastify, streams `relay-response` back |
68
+
69
+ ### WebRtcManager
70
+
71
+ Manages RTCPeerConnection sessions keyed by `sessionId`. On receiving an SDP offer from the server it creates a peer connection using [`node-datachannel`](https://github.com/murat-dogan/node-datachannel), generates an answer, and exchanges ICE candidates through the tunnel. When the data channel opens it hands it off to `DataChannelHandler`.
72
+
73
+ ```mermaid
74
+ sequenceDiagram
75
+ participant S as Server (via TunnelClient)
76
+ participant WM as WebRtcManager
77
+ participant DC as DataChannelHandler
78
+
79
+ S->>WM: { type:"offer", sessionId, sdp }
80
+ WM->>WM: createPeerConnection(sessionId)
81
+ WM->>WM: setRemoteDescription(offer)
82
+ WM->>WM: createAnswer()
83
+ WM->>S: { type:"answer", sdp }
84
+
85
+ loop ICE candidates
86
+ WM->>S: { type:"candidate", candidate, mid }
87
+ S->>WM: { type:"candidate", … }
88
+ end
89
+
90
+ Note over WM: data channel opens
91
+ WM->>DC: handleChannel(dataChannel)
43
92
  ```
44
93
 
45
- Minimal required argument:
94
+ ### DataChannelHandler
95
+
96
+ Receives JSON `request` messages over the data channel and dispatches them to the proxy's local Fastify server. Responses are streamed back as base64 `response-chunk` messages.
97
+
98
+ **Wire protocol (browser ↔ proxy):**
46
99
 
47
- - `--server-url <url>`: base URL of your registry server.
100
+ | Direction | Type | Key fields |
101
+ |---|---|---|
102
+ | Browser → Proxy | `request` | `requestId`, `method`, `path`, `query`, `headers`, `body` |
103
+ | Proxy → Browser | `response-start` | `requestId`, `status`, `headers` |
104
+ | Proxy → Browser | `response-chunk` | `requestId`, `data` (base64), `done: true\|false` |
105
+ | Proxy → Browser | `response-error` | `requestId`, `error` |
106
+ | Browser → Proxy | `ping` | `id` |
107
+ | Proxy → Browser | `pong` | `id` |
48
108
 
49
- Useful optional arguments:
109
+ ### HealthCollector
110
+
111
+ Collects system-level health metrics on every request from the server:
112
+
113
+ | Metric | Range | Description |
114
+ |---|---|---|
115
+ | `cpuLoad` | 0 – ∞ | 1-minute load average divided by CPU count (>1 = overloaded) |
116
+ | `memFree` | 0 – 1 | Free memory fraction |
117
+ | `activeSessions` | 0 – ∞ | Number of active HLS transcode sessions |
118
+
119
+ The browser uses these metrics together with tunnel RTT to score proxies:
120
+
121
+ ```
122
+ score = memFree × 0.4 + (1 - clamp(cpuLoad, 0, 1)) × 0.4 − (rttMs / 2000) × 0.2
123
+ ```
124
+
125
+ ### HlsSessionManager & ffmpeg
126
+
127
+ Creates and manages ffmpeg-based HLS transcode sessions. Sessions are keyed by `sourceKey:fileIndex:mode` and shared across consumers.
128
+
129
+ ```mermaid
130
+ sequenceDiagram
131
+ participant B as Browser (via DataChannel)
132
+ participant H as HlsSessionManager
133
+ participant F as ffmpeg
134
+
135
+ B->>H: POST /api/transcode-sessions (consumerId, mode)
136
+ H->>F: start (or reuse) ffmpeg process
137
+ H-->>B: { sessionId, playlistPath }
138
+
139
+ loop segment requests
140
+ B->>H: GET /transcode/:sessionId/seg000.ts
141
+ H-->>B: MPEG-TS segment (via data channel chunk)
142
+ end
50
143
 
51
- - `--host <host>`: bind host (default `127.0.0.1`).
52
- - `--port <port>`: preferred local port (default `9090`; first free port in range is selected).
53
- - `--public-base-url <url>`: externally reachable base URL advertised to registry.
54
- - `--id <id>`: stable proxy client id.
55
- - `--name <name>`: display name for registry.
56
- - `--token <token>`: token sent to register/heartbeat endpoints.
57
- - `--ffmpeg-bin <path>`: custom ffmpeg binary path.
58
- - `--no-transcode-audio`: disable HLS audio transcoding.
59
- - `--help`: print all options with descriptions and examples, then exit.
144
+ B->>H: GET /api/transcode-sessions/:id/progress
145
+ H-->>B: { percent, speed, remainingSeconds, }
146
+
147
+ B->>H: POST /api/transcode-sessions/:id/release (consumerId)
148
+ H->>H: remove consumer
149
+ alt last consumer released
150
+ H->>F: kill process + cleanup segments
151
+ end
152
+ ```
60
153
 
61
154
  ## HTTP API
62
155
 
63
- Base URL examples below use `http://127.0.0.1:9090`.
156
+ Base URL examples use `http://127.0.0.1:9090`.
64
157
 
65
158
  ### Health
66
159
 
67
160
  ```bash
68
- curl http://127.0.0.1:9090/health
69
- curl http://127.0.0.1:9090/healthz
161
+ GET /health
162
+ GET /healthz
70
163
  ```
71
164
 
72
- ### 1) Register a source
165
+ ### Register a source
73
166
 
74
167
  ```bash
75
- curl -X POST http://127.0.0.1:9090/api/sources \
76
- -H "Content-Type: application/json" \
77
- -d '{
78
- "sourceType": "magnet",
79
- "source": "magnet:?xt=urn:btih:..."
80
- }'
81
- ```
82
-
83
- Response:
168
+ POST /api/sources
169
+ Content-Type: application/json
84
170
 
85
- ```json
86
- { "sourceKey": "..." }
171
+ {
172
+ "sourceType": "magnet", # or "torrent" (base64-encoded bytes)
173
+ "source": "magnet:?xt=urn:btih:…"
174
+ }
87
175
  ```
88
176
 
89
- Supported `sourceType` values:
90
-
91
- - `magnet`: magnet URI string.
92
- - `torrent`: base64-encoded raw `.torrent` file bytes.
177
+ Response: `{ "sourceKey": "…" }`
93
178
 
94
- ### 2) Build playback plan
179
+ ### Build playback plan
95
180
 
96
181
  ```bash
97
- curl -X POST http://127.0.0.1:9090/api/playback-plan \
98
- -H "Content-Type: application/json" \
99
- -d '{
100
- "sourceKey": "<sourceKey>",
101
- "fileIndex": 0,
102
- "userAgent": "Mozilla/5.0"
103
- }'
182
+ POST /api/playback-plan
183
+ Content-Type: application/json
184
+
185
+ {
186
+ "sourceKey": "<sourceKey>",
187
+ "fileIndex": 0,
188
+ "userAgent": "Mozilla/5.0 …"
189
+ }
104
190
  ```
105
191
 
106
- Typical response:
192
+ Response:
107
193
 
108
194
  ```json
109
195
  {
110
196
  "mode": "direct",
111
- "directUrl": "http://127.0.0.1:9090/stream?sourceKey=...&fileIndex=0",
197
+ "directUrl": "http://127.0.0.1:9090/stream?sourceKey=…&fileIndex=0",
112
198
  "reason": "audio-codec-supported",
113
199
  "audioCodec": "aac",
114
200
  "videoCodec": "h264"
115
201
  }
116
202
  ```
117
203
 
118
- `mode` can be:
119
-
120
- - `direct`: play `directUrl` directly.
121
- - `hls`: create an HLS session, then use playlist URL.
204
+ `mode` is `"direct"` or `"hls"`.
122
205
 
123
- ### 3) Direct stream endpoint
206
+ ### Direct stream
124
207
 
125
208
  ```bash
126
- curl -v "http://127.0.0.1:9090/stream?sourceKey=<sourceKey>&fileIndex=0"
209
+ GET /stream?sourceKey=<key>&fileIndex=0
127
210
  ```
128
211
 
129
- Or without pre-registering source:
212
+ Supports HTTP Range requests.
213
+
214
+ ### Create HLS transcode session
130
215
 
131
216
  ```bash
132
- curl -v "http://127.0.0.1:9090/stream?sourceType=magnet&source=magnet:?xt=...&fileIndex=0"
217
+ POST /api/transcode-sessions
218
+ Content-Type: application/json
219
+
220
+ {
221
+ "sourceKey": "<key>",
222
+ "fileIndex": 0,
223
+ "transcodeVideo": false,
224
+ "consumerId": "uuid",
225
+ "fileName": "Episode01.mkv"
226
+ }
133
227
  ```
134
228
 
135
- The endpoint supports HTTP Range requests.
229
+ Response: `{ "sessionId": "…", "playlistPath": "/transcode/<id>/index.m3u8" }`
136
230
 
137
- ### 4) Create HLS transcode session (optional)
231
+ ### Poll transcode progress
138
232
 
139
233
  ```bash
140
- curl -X POST http://127.0.0.1:9090/api/transcode-sessions \
141
- -H "Content-Type: application/json" \
142
- -d '{
143
- "sourceKey": "<sourceKey>",
144
- "fileIndex": 0,
145
- "transcodeVideo": false,
146
- "consumerId": "browser-session-uuid",
147
- "fileName": "Episode01.mkv"
148
- }'
234
+ GET /api/transcode-sessions/:sessionId/progress
149
235
  ```
150
236
 
151
- Set `"transcodeVideo": true` to force video transcoding (for browser decode fallback cases).
237
+ Returns: `percent`, `processedSeconds`, `totalSeconds`, `remainingSeconds`, `speed`, `warmupPercent`, `warmupRemainingSeconds`.
152
238
 
153
- Response:
239
+ ### Release consumer
154
240
 
155
- ```json
156
- {
157
- "sessionId": "...",
158
- "playlistPath": "/transcode/<sessionId>/index.m3u8"
159
- }
241
+ ```bash
242
+ POST /api/transcode-sessions/:sessionId/release
243
+ Content-Type: application/json
244
+
245
+ { "consumerId": "uuid", "reason": "pagehide" }
160
246
  ```
161
247
 
162
- Open playlist as:
248
+ When the last consumer is released, the transcode session stops and temp files are cleaned up.
163
249
 
164
- `http://127.0.0.1:9090/transcode/<sessionId>/index.m3u8`
250
+ ## Requirements
165
251
 
166
- ### 5) Poll transcode progress
252
+ - Node.js 18+ (ESM, built-in `fetch`).
253
+ - ffmpeg is required only when transcoding is enabled (bundled via `ffmpeg-static` by default).
254
+
255
+ ## Run
167
256
 
168
257
  ```bash
169
- curl "http://127.0.0.1:9090/api/transcode-sessions/<sessionId>/progress"
258
+ npm install
259
+ npm start -- --server-url http://localhost:3000
170
260
  ```
171
261
 
172
- Response includes transcode and warmup metrics:
173
- - `percent`, `processedSeconds`, `totalSeconds`, `remainingSeconds`, `speed`
174
- - `warmupPercent`, `warmupRemainingSeconds`
262
+ ### CLI Options
263
+
264
+ | Option | Default | Description |
265
+ |--------|---------|-------------|
266
+ | `--server-url` | — | **(Required)** Base URL of the registry server |
267
+ | `--host` | `127.0.0.1` | Bind host |
268
+ | `--port` | `9090` | Preferred local port (auto-increments if taken) |
269
+ | `--public-base-url` | — | Externally reachable base URL advertised to registry |
270
+ | `--id` | auto | Stable proxy client ID |
271
+ | `--name` | hostname | Display name in registry |
272
+ | `--token` | — | Auth token for register/heartbeat |
273
+ | `--ffmpeg-bin` | bundled | Path to custom ffmpeg binary |
274
+ | `--no-transcode-audio` | — | Disable HLS audio transcoding |
275
+ | `--help` | — | Print all options and exit |
175
276
 
176
- ### 6) Release transcode consumer
277
+ ## Docker
177
278
 
178
279
  ```bash
179
- curl -X POST http://127.0.0.1:9090/api/transcode-sessions/<sessionId>/release \
180
- -H "Content-Type: application/json" \
181
- -d '{
182
- "consumerId": "browser-session-uuid",
183
- "reason": "pagehide"
184
- }'
280
+ docker build -t torrent-tv-proxy .
281
+ docker run torrent-tv-proxy --server-url http://my-server:8080
185
282
  ```
186
283
 
187
- When the last consumer is released, proxy disposes the session and stops ffmpeg.
188
-
189
- ## End-to-end flow
190
-
191
- 1. Start proxy client with `--server-url`.
192
- 2. Register torrent source via `/api/sources` and get `sourceKey`.
193
- 3. Request `/api/playback-plan`.
194
- 4. If plan is `direct`, use `directUrl`.
195
- 5. If plan is `hls`, create session and play generated playlist.
196
- 6. Poll `/progress` for UI updates, then release session on client stop/close.
197
-
198
- ## Transcode Session Lifecycle
284
+ ## Full End-to-End Flow
199
285
 
200
286
  ```mermaid
201
287
  sequenceDiagram
202
- participant C as Client
288
+ participant B as Browser
289
+ participant S as Registry Server
203
290
  participant P as Proxy
204
- participant F as FFmpeg
205
-
206
- C->>P: POST /api/transcode-sessions (consumerId, mode, fileName)
207
- P->>F: start/reuse transcode session
208
- C->>P: GET /transcode/:sessionId/index.m3u8
209
- C->>P: GET /api/transcode-sessions/:sessionId/progress
210
- C->>P: POST /api/transcode-sessions/:sessionId/release (reason)
211
- P->>P: remove consumer
212
- alt no consumers left
213
- P->>F: stop process and cleanup
214
- end
291
+
292
+ Note over P,S: Startup
293
+ P->>S: POST /api/proxy-clients/register
294
+ P->>S: WebSocket /ws/proxy-tunnel (persistent)
295
+
296
+ Note over B,P: Playback start
297
+ B->>S: GET /api/proxy-clients/health
298
+ S->>P: health-request via tunnel
299
+ P-->>S: health-response (cpu, mem, activeSessions)
300
+ S-->>B: scored proxy list
301
+
302
+ Note over B,P: WebRTC setup
303
+ B->>S: WebSocket /ws/browser-signal
304
+ B->>B: RTCPeerConnection + DataChannel + createOffer
305
+ B->>S: { type:"offer", proxyId, sdp }
306
+ S->>P: forward via tunnel
307
+ P->>P: createAnswer
308
+ P->>S: { type:"answer", sdp }
309
+ S->>B: forward
310
+ Note over B,P: ICE candidates exchanged same way
311
+ Note over B,P: Data channel opens (P2P, STUN-assisted)
312
+
313
+ Note over B,P: Streaming
314
+ B->>P: request via data channel: POST /api/sources
315
+ P-->>B: response-chunk: { sourceKey }
316
+ B->>P: request via data channel: POST /api/playback-plan
317
+ P-->>B: response-chunk: { mode, audioCodec, videoCodec, … }
318
+ B->>P: request via data channel: POST /api/transcode-sessions
319
+ P-->>B: response-chunk: { sessionId, playlistPath }
320
+ B->>P: HLS.js fetches: GET /transcode/:id/index.m3u8
321
+ B->>P: HLS.js fetches: GET /transcode/:id/seg000.ts …
322
+ Note over B,P: All via data channel — no server relay
215
323
  ```
216
324
 
217
325
  ## Notes
218
326
 
219
- - HLS session files are stored in OS temp directory and cleaned up automatically.
220
- - Transcode sessions are cached by `sourceKey:fileIndex:mode`.
221
- - ffmpeg is bundled via `ffmpeg-static` for out-of-the-box availability.
222
- - Source registry is in-memory and bounded (old entries are evicted).
327
+ - HLS session temp files are in the OS temp directory and cleaned up automatically.
328
+ - Transcode sessions are cached by `sourceKey:fileIndex:mode` and shared across consumers.
329
+ - The source registry is in-memory and bounded (old entries evicted).
330
+ - The proxy reconnects to the server automatically on tunnel disconnect.
223
331
 
224
332
  ## License
225
333
 
226
- This project is distributed under GPL-3.0-or-later (see `LICENSE`).
227
-
228
- Third-party dependencies keep their own licenses. In particular, bundled ffmpeg binaries
229
- provided by `ffmpeg-static` are GPL-compatible.
230
-
334
+ GPL-3.0-or-later (see `LICENSE`). Third-party dependencies keep their own licenses.
335
+ Bundled ffmpeg binaries (`ffmpeg-static`) are GPL-compatible.
package/bin/cli.js CHANGED
@@ -2,9 +2,10 @@
2
2
  /**
3
3
  * @file CLI entry point for the torrent-tv proxy.
4
4
  *
5
- * Parses command-line arguments, starts the local HTTP server, registers
6
- * this proxy with the registry server, establishes the WebSocket tunnel,
7
- * and maintains a periodic heartbeat.
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.
8
9
  */
9
10
 
10
11
  import { Command } from "commander";
@@ -12,8 +13,11 @@ import crypto from "node:crypto";
12
13
  import { spawnSync } from "node:child_process";
13
14
  import ffmpegStatic from "ffmpeg-static";
14
15
  import { startProxyServer } from "../server.js";
15
- import { registerClient, sendHeartbeat } from "../services/registry-api.js";
16
+ import { registerClient } from "../services/registry-api.js";
16
17
  import { createTunnelClient } from "../services/tunnel-client.js";
18
+ import { createWebRtcManager } from "../services/webrtc-manager.js";
19
+ import { createDataChannelHandler } from "../services/data-channel-handler.js";
20
+ import { collectHealthMetrics } from "../services/health-collector.js";
17
21
  import { logger } from "../utils/logger.js";
18
22
 
19
23
  const program = new Command();
@@ -96,9 +100,6 @@ function assertFfmpegAvailability() {
96
100
  /** @type {boolean} */
97
101
  let registrationInProgress = false;
98
102
 
99
- /** @type {ReturnType<typeof setInterval> | null} */
100
- let heartbeatTimer = null;
101
-
102
103
  /** @type {import("fastify").FastifyInstance | null} */
103
104
  let app = null;
104
105
 
@@ -108,9 +109,10 @@ let actualPort = localPort;
108
109
  /** @type {boolean} */
109
110
  let shutdownInProgress = false;
110
111
 
111
- /** @type {ReturnType<typeof import("../services/tunnel-client.js").createTunnelClient> | null} */
112
+ /** @type {ReturnType<typeof createTunnelClient> | null} */
112
113
  let tunnelClient = null;
113
114
 
115
+
114
116
  /**
115
117
  * Register this proxy with the registry server.
116
118
  * Silently skips if a registration is already in flight.
@@ -130,7 +132,7 @@ async function registerClientSafe() {
130
132
  baseUrl: explicitBaseUrl || `http://${bindHost}:${actualPort}`,
131
133
  token
132
134
  });
133
- logger.success(`Registered: ${JSON.stringify(result.client)}`);
135
+ logger.success(`Registered as "${result.client?.name}" (${result.client?.id?.slice(0, 8)})`);
134
136
  } finally {
135
137
  registrationInProgress = false;
136
138
  }
@@ -172,10 +174,6 @@ async function shutdown(signal) {
172
174
  return;
173
175
  }
174
176
  shutdownInProgress = true;
175
- if (heartbeatTimer) {
176
- clearInterval(heartbeatTimer);
177
- heartbeatTimer = null;
178
- }
179
177
  if (tunnelClient) {
180
178
  tunnelClient.disconnect();
181
179
  tunnelClient = null;
@@ -213,33 +211,56 @@ try {
213
211
  logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
214
212
  }
215
213
 
216
- await registerWithRetry();
214
+ // Create tunnel + WebRTC manager.
215
+ // The tunnel forwards WebRTC signals between browser (via server) and this proxy.
216
+ // The WebRTC manager handles the actual peer connection and data channel.
217
+ //
218
+ // We use a late-binding ref so both objects can reference each other without
219
+ // running into the TDZ (tunnelClient is declared above; webRtcManager uses let
220
+ // so the closure in createTunnelClient can call it after both are initialised).
221
+ /** @type {ReturnType<typeof createWebRtcManager> | null} */
222
+ let webRtcManager = null;
217
223
 
218
224
  tunnelClient = createTunnelClient({
219
225
  serverUrl,
220
226
  proxyId: clientId,
221
227
  token,
222
228
  proxyPort: actualPort,
223
- onLog: (msg) => logger.info(msg)
229
+ onSignal(sessionId, signal) {
230
+ webRtcManager?.handleSignal(sessionId, signal);
231
+ },
232
+ onHealthRequest() {
233
+ return collectHealthMetrics();
234
+ },
235
+ onConnect() {
236
+ // Re-register on every tunnel connect/reconnect so the server's
237
+ // in-memory store stays consistent after server restarts.
238
+ void registerClientSafe().catch((error) => {
239
+ const message = error instanceof Error ? error.message : String(error);
240
+ logger.error(`Re-registration after tunnel connect failed: ${message}`);
241
+ });
242
+ },
243
+ onLog: (message) => logger.info(message)
244
+ });
245
+
246
+ const dataChannelHandler = createDataChannelHandler({
247
+ proxyPort: actualPort,
248
+ onLog: (message) => logger.info(message)
249
+ });
250
+
251
+ webRtcManager = createWebRtcManager({
252
+ sendSignal(sessionId, signal) {
253
+ tunnelClient?.sendSignal(sessionId, signal);
254
+ },
255
+ onDataChannel(sessionId, channel) {
256
+ dataChannelHandler.handleChannel(sessionId, channel);
257
+ },
258
+ onLog: (message) => logger.info(message)
224
259
  });
260
+
225
261
  tunnelClient.connect();
226
262
 
227
- heartbeatTimer = setInterval(async () => {
228
- const status = await sendHeartbeat({
229
- serverUrl,
230
- id: clientId,
231
- token
232
- });
233
- if (status === 404) {
234
- logger.warn("Heartbeat returned 404, re-registering...");
235
- try {
236
- await registerClientSafe();
237
- } catch (error) {
238
- const message = error instanceof Error ? error.message : String(error);
239
- logger.error(`Re-register failed: ${message}`);
240
- }
241
- }
242
- }, 20_000);
263
+ await registerWithRetry();
243
264
  } catch (error) {
244
265
  const message = error instanceof Error ? error.message : String(error);
245
266
  logger.error(message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.2.0",
3
+ "version": "2.5.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -26,6 +26,8 @@
26
26
  "fastify": "^5.8.5",
27
27
  "ffmpeg-static": "^5.3.0",
28
28
  "get-port": "^7.1.0",
29
- "webtorrent": "^2.8.4"
29
+ "node-datachannel": "^0.14.1",
30
+ "webtorrent": "^2.8.4",
31
+ "ws": "^8.18.2"
30
32
  }
31
33
  }
package/server.js CHANGED
@@ -77,6 +77,12 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }
77
77
  allowedHeaders: ["Content-Type", "Range"]
78
78
  });
79
79
 
80
+ // Allow browser requests from an HTTPS page to this private-network proxy
81
+ // without triggering Chromium's Private Network Access permission prompt.
82
+ app.addHook("onRequest", async (_req, reply) => {
83
+ reply.header("Access-Control-Allow-Private-Network", "true");
84
+ });
85
+
80
86
  const sourceRegistry = createSourceRegistry(200);
81
87
  const torrentPool = new TorrentPool();
82
88
  const selectedPort = await getPort({