@torrent-tv/proxy 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,22 @@
1
+ /**
2
+ * @file WebTorrent client pool.
3
+ *
4
+ * Manages a shared WebTorrent client instance and a map of active torrents
5
+ * keyed by a hash of their source. Tracks file-level usage so that only
6
+ * the pieces needed by active streams are selected for download.
7
+ */
8
+
1
9
  import crypto from "node:crypto";
2
- import chalk from "chalk";
3
10
  import WebTorrent from "webtorrent";
11
+ import { logger } from "../utils/logger.js";
4
12
 
13
+ /**
14
+ * Decode a raw torrent source value into the format expected by WebTorrent.
15
+ *
16
+ * @param {"magnet" | "torrent"} sourceType - How the source is encoded.
17
+ * @param {string} source - Magnet URI or base64-encoded .torrent file.
18
+ * @returns {string | Buffer}
19
+ */
5
20
  function decodeTorrentSource(sourceType, source) {
6
21
  if (sourceType === "magnet") {
7
22
  return source;
@@ -12,17 +27,47 @@ function decodeTorrentSource(sourceType, source) {
12
27
  throw new Error("Unsupported sourceType. Expected magnet or torrent.");
13
28
  }
14
29
 
30
+ /**
31
+ * Shared WebTorrent pool.
32
+ *
33
+ * Torrents are loaded on demand and cached indefinitely (the pool has no
34
+ * eviction policy — callers are responsible for keeping the set small).
35
+ * File-level piece selection is tracked through a reference-count map so
36
+ * that only files with at least one active stream cause downloading.
37
+ */
15
38
  export class TorrentPool {
16
39
  constructor() {
40
+ /** @type {import("webtorrent").WebTorrent} */
17
41
  this.client = new WebTorrent();
42
+
43
+ /**
44
+ * Active torrents keyed by `"${sourceType}:${sha1(source)}"`.
45
+ *
46
+ * @type {Map<string, import("webtorrent").Torrent>}
47
+ */
18
48
  this.torrents = new Map();
49
+
50
+ /**
51
+ * Per-torrent file usage reference counts.
52
+ * Maps torrent object → (fileIndex → refCount).
53
+ *
54
+ * @type {WeakMap<import("webtorrent").Torrent, Map<number, number>>}
55
+ */
19
56
  this.fileUsageByTorrent = new WeakMap();
20
57
 
21
58
  this.client.on("error", (error) => {
22
- console.error(chalk.red(`[proxy-client] WebTorrent client error: ${error.message}`));
59
+ logger.error(`WebTorrent client error: ${error.message}`);
23
60
  });
24
61
  }
25
62
 
63
+ /**
64
+ * Return the torrent for the given source, loading it if necessary.
65
+ * Resolves once the torrent metadata is ready.
66
+ *
67
+ * @param {"magnet" | "torrent"} sourceType
68
+ * @param {string} source - Magnet URI or base64-encoded .torrent bytes.
69
+ * @returns {Promise<import("webtorrent").Torrent>}
70
+ */
26
71
  async getTorrent(sourceType, source) {
27
72
  const key = `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
28
73
  const existing = this.torrents.get(key);
@@ -47,6 +92,14 @@ export class TorrentPool {
47
92
  return torrent;
48
93
  }
49
94
 
95
+ /**
96
+ * Mark a single file as active, deselecting all others.
97
+ * Prefer {@link acquireFile} when the active set may contain multiple files.
98
+ *
99
+ * @param {import("webtorrent").Torrent} torrent
100
+ * @param {number} fileIndex - Zero-based index into `torrent.files`.
101
+ * @returns {void}
102
+ */
50
103
  setActiveFile(torrent, fileIndex) {
51
104
  if (!torrent || !Array.isArray(torrent.files)) {
52
105
  return;
@@ -68,6 +121,15 @@ export class TorrentPool {
68
121
  }
69
122
  }
70
123
 
124
+ /**
125
+ * Increment the reference count for a file, selecting it for download.
126
+ * Returns a release function that decrements the count; when it reaches
127
+ * zero the file is automatically deselected.
128
+ *
129
+ * @param {import("webtorrent").Torrent} torrent
130
+ * @param {number} fileIndex - Zero-based index into `torrent.files`.
131
+ * @returns {() => void} Release function — call it once when done streaming.
132
+ */
71
133
  acquireFile(torrent, fileIndex) {
72
134
  if (!torrent || !Array.isArray(torrent.files) || !Number.isInteger(fileIndex) || fileIndex < 0) {
73
135
  return () => undefined;
@@ -99,6 +161,14 @@ export class TorrentPool {
99
161
  };
100
162
  }
101
163
 
164
+ /**
165
+ * Update WebTorrent piece selection to match the current usage map.
166
+ * Files with at least one consumer are selected; all others are deselected.
167
+ *
168
+ * @param {import("webtorrent").Torrent} torrent
169
+ * @param {Map<number, number>} usage - fileIndex → refCount.
170
+ * @returns {void}
171
+ */
102
172
  #syncSelections(torrent, usage) {
103
173
  if (!torrent || !Array.isArray(torrent.files)) {
104
174
  return;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @file Outbound WebSocket tunnel from the proxy to the registry server.
3
+ *
4
+ * The proxy establishes one persistent connection on startup.
5
+ * The server sends relay requests through it; the proxy fetches them
6
+ * locally (against 127.0.0.1) and streams responses back chunk-by-chunk.
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} TunnelClientOptions
11
+ * @property {string} serverUrl - Base URL of the registry server (http/https).
12
+ * @property {string} proxyId - Stable ID used to identify this proxy on the server.
13
+ * @property {string} token - Auth token sent as a header during the WS handshake.
14
+ * @property {number} proxyPort - Local port the proxy HTTP server is listening on.
15
+ * @property {(message: string) => void} [onLog] - Optional log callback.
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} TunnelRelayRequest
20
+ * @property {string} requestId - Unique ID assigned by the server for this relay round-trip.
21
+ * @property {string} method - HTTP method to use when calling the local proxy.
22
+ * @property {string} path - Request path (e.g. "/health").
23
+ * @property {string} query - Query string without the leading "?".
24
+ * @property {Record<string, string>} headers - Headers forwarded from the browser.
25
+ * @property {string | null} body - Serialised JSON body, or null for GET.
26
+ */
27
+
28
+ const RECONNECT_DELAY_MS = 5_000;
29
+
30
+ /**
31
+ * Create and manage the outbound WebSocket tunnel to the registry server.
32
+ *
33
+ * @param {TunnelClientOptions} options
34
+ * @returns {{ connect: () => void, disconnect: () => void }}
35
+ */
36
+ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog }) {
37
+ const wsUrl = serverUrl.replace(/^http/, "ws").replace(/\/+$/, "") + "/ws/proxy-tunnel";
38
+
39
+ /** @type {WebSocket | null} */
40
+ let socket = null;
41
+ /** @type {ReturnType<typeof setTimeout> | null} */
42
+ let reconnectTimer = null;
43
+ let stopped = false;
44
+
45
+ /**
46
+ * Emit a log message via the provided callback.
47
+ *
48
+ * @param {string} message
49
+ * @returns {void}
50
+ */
51
+ function log(message) {
52
+ if (typeof onLog === "function") {
53
+ onLog(message);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Open a new WebSocket connection to the server.
59
+ * Automatically reconnects on close unless {@link disconnect} was called.
60
+ *
61
+ * @returns {void}
62
+ */
63
+ function connect() {
64
+ if (stopped) {
65
+ return;
66
+ }
67
+ log(`Connecting tunnel to ${wsUrl}`);
68
+
69
+ socket = new WebSocket(wsUrl, {
70
+ headers: {
71
+ "x-proxy-id": proxyId,
72
+ "x-proxy-token": token
73
+ }
74
+ });
75
+
76
+ socket.addEventListener("open", () => {
77
+ log("Tunnel connected.");
78
+ });
79
+
80
+ socket.addEventListener("message", (event) => {
81
+ let message;
82
+ try {
83
+ message = JSON.parse(event.data);
84
+ } catch {
85
+ return;
86
+ }
87
+ if (message.type === "request") {
88
+ void handleRelayRequest(message).catch((error) => {
89
+ log(`Tunnel relay error: ${error?.message ?? error}`);
90
+ });
91
+ }
92
+ });
93
+
94
+ socket.addEventListener("close", (event) => {
95
+ log(`Tunnel disconnected (code=${event.code}). Reconnecting in ${RECONNECT_DELAY_MS}ms...`);
96
+ socket = null;
97
+ if (!stopped) {
98
+ reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
99
+ }
100
+ });
101
+
102
+ socket.addEventListener("error", (event) => {
103
+ log(`Tunnel WebSocket error: ${event.message ?? "unknown"}`);
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Execute a relay request sent by the server: fetch the resource
109
+ * from the local proxy and stream the response back chunk-by-chunk.
110
+ *
111
+ * @param {TunnelRelayRequest} relayRequest
112
+ * @returns {Promise<void>}
113
+ */
114
+ async function handleRelayRequest(relayRequest) {
115
+ const { requestId, method, path, query, headers: forwardedHeaders, body } = relayRequest;
116
+ const targetUrl = `http://127.0.0.1:${proxyPort}${path}` + (query ? `?${query}` : "");
117
+ const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
118
+
119
+ let response;
120
+ try {
121
+ response = await fetch(targetUrl, {
122
+ method,
123
+ headers: requestHeaders,
124
+ body: body != null ? body : undefined,
125
+ redirect: "manual"
126
+ });
127
+ } catch (fetchError) {
128
+ sendError(requestId, fetchError?.message ?? String(fetchError));
129
+ return;
130
+ }
131
+
132
+ const responseHeaders = {};
133
+ for (const [headerName, headerValue] of response.headers.entries()) {
134
+ responseHeaders[headerName] = headerValue;
135
+ }
136
+
137
+ send({
138
+ type: "response-start",
139
+ requestId,
140
+ status: response.status,
141
+ headers: responseHeaders
142
+ });
143
+
144
+ if (!response.body) {
145
+ send({ type: "response-chunk", requestId, data: "", done: true });
146
+ return;
147
+ }
148
+
149
+ try {
150
+ const reader = response.body.getReader();
151
+ while (true) {
152
+ const { done, value } = await reader.read();
153
+ if (done) {
154
+ send({ type: "response-chunk", requestId, data: "", done: true });
155
+ break;
156
+ }
157
+ send({
158
+ type: "response-chunk",
159
+ requestId,
160
+ data: Buffer.from(value).toString("base64"),
161
+ done: false
162
+ });
163
+ }
164
+ } catch {
165
+ send({ type: "response-chunk", requestId, data: "", done: true });
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Send a JSON message through the WebSocket if it is open.
171
+ *
172
+ * @param {object} message
173
+ * @returns {void}
174
+ */
175
+ function send(message) {
176
+ if (socket && socket.readyState === WebSocket.OPEN) {
177
+ socket.send(JSON.stringify(message));
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Send a `response-error` message back to the server for the given request.
183
+ *
184
+ * @param {string} requestId
185
+ * @param {string} errorMessage
186
+ * @returns {void}
187
+ */
188
+ function sendError(requestId, errorMessage) {
189
+ send({ type: "response-error", requestId, error: errorMessage });
190
+ }
191
+
192
+ return {
193
+ /**
194
+ * Start the tunnel, connecting immediately and reconnecting on drop.
195
+ *
196
+ * @returns {void}
197
+ */
198
+ connect() {
199
+ stopped = false;
200
+ connect();
201
+ },
202
+
203
+ /**
204
+ * Stop the tunnel and close the current connection without reconnecting.
205
+ *
206
+ * @returns {void}
207
+ */
208
+ disconnect() {
209
+ stopped = true;
210
+ if (reconnectTimer != null) {
211
+ clearTimeout(reconnectTimer);
212
+ reconnectTimer = null;
213
+ }
214
+ if (socket) {
215
+ socket.close(1000, "shutdown");
216
+ socket = null;
217
+ }
218
+ }
219
+ };
220
+ }
@@ -1,9 +1,42 @@
1
+ /**
2
+ * @file In-memory registry of torrent sources.
3
+ *
4
+ * Sources are stored under a SHA-1 key derived from their type and content.
5
+ * The registry is bounded: when `maxSources` is exceeded, the oldest entries
6
+ * are evicted to keep memory usage predictable.
7
+ */
8
+
1
9
  import crypto from "node:crypto";
2
10
 
11
+ /**
12
+ * @typedef {Object} SourceRecord
13
+ * @property {"magnet" | "torrent"} sourceType - Encoding of the `source` field.
14
+ * @property {string} source - Magnet URI or base64-encoded .torrent bytes.
15
+ * @property {number} updatedAt - Unix ms timestamp of the last upsert.
16
+ */
17
+
18
+ /**
19
+ * Create a bounded in-memory source registry.
20
+ *
21
+ * @param {number} [maxSources=200] - Maximum number of entries to retain.
22
+ * @returns {{
23
+ * upsert: (sourceType: string, source: string) => string,
24
+ * get: (sourceKey: string) => SourceRecord | null
25
+ * }}
26
+ */
3
27
  export function createSourceRegistry(maxSources = 200) {
28
+ /** @type {Map<string, SourceRecord>} */
4
29
  const sources = new Map();
5
30
 
6
31
  return {
32
+ /**
33
+ * Insert or refresh a source entry and return its key.
34
+ * Evicts the oldest entries if the map exceeds `maxSources`.
35
+ *
36
+ * @param {string} sourceType
37
+ * @param {string} source
38
+ * @returns {string} Stable SHA-1 hex key for this source.
39
+ */
7
40
  upsert(sourceType, source) {
8
41
  const sourceKey = crypto
9
42
  .createHash("sha1")
@@ -25,6 +58,14 @@ export function createSourceRegistry(maxSources = 200) {
25
58
 
26
59
  return sourceKey;
27
60
  },
61
+
62
+ /**
63
+ * Look up a source by its key.
64
+ * Returns `null` if the key is not registered.
65
+ *
66
+ * @param {string} sourceKey
67
+ * @returns {SourceRecord | null}
68
+ */
28
69
  get(sourceKey) {
29
70
  return sources.get(sourceKey) ?? null;
30
71
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file Centralised console logger for the proxy process.
3
+ *
4
+ * All messages are prefixed with `[proxy-client]` and coloured with chalk
5
+ * for consistent, readable terminal output.
6
+ */
7
+
8
+ import chalk from "chalk";
9
+
10
+ const PREFIX = "[proxy-client]";
11
+
12
+ /**
13
+ * @typedef {Object} ProxyLogger
14
+ * @property {(message: string) => void} info - Informational message (cyan).
15
+ * @property {(message: string) => void} success - Positive outcome (green).
16
+ * @property {(message: string) => void} warn - Non-fatal warning (yellow).
17
+ * @property {(message: string) => void} error - Error condition (red).
18
+ */
19
+
20
+ /**
21
+ * Shared logger instance used throughout the proxy process.
22
+ *
23
+ * @type {ProxyLogger}
24
+ */
25
+ export const logger = {
26
+ info: (message) => console.log(chalk.cyan(`${PREFIX} ${message}`)),
27
+ success: (message) => console.log(chalk.green(`${PREFIX} ${message}`)),
28
+ warn: (message) => console.warn(chalk.yellow(`${PREFIX} ${message}`)),
29
+ error: (message) => console.error(chalk.red(`${PREFIX} ${message}`)),
30
+ };
@@ -1,3 +1,16 @@
1
+ /**
2
+ * @file HTTP Range header parser.
3
+ */
4
+
5
+ /**
6
+ * Parse an HTTP `Range` header value into a byte range clamped to the file size.
7
+ * Only the `bytes=<start>-<end>` form is supported.
8
+ *
9
+ * @param {string | undefined} rangeHeader - Value of the `Range` request header.
10
+ * @param {number} fileLength - Total file size in bytes.
11
+ * @returns {{ start: number, end: number } | null} Byte range, or `null` if the
12
+ * header is absent, malformed, or uses an unsupported unit.
13
+ */
1
14
  export function parseRange(rangeHeader, fileLength) {
2
15
  if (!rangeHeader || !rangeHeader.startsWith("bytes=")) {
3
16
  return null;