@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.
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @file WebRTC data channel request handler (proxy side).
3
+ *
4
+ * When a browser opens a data channel to this proxy, this handler wires up
5
+ * message handlers that implement an HTTP-over-DataChannel protocol:
6
+ * each incoming `request` message triggers a local `fetch` to the Fastify
7
+ * server, and the response is streamed back as base64-encoded chunks.
8
+ *
9
+ * ## Wire protocol
10
+ *
11
+ * Browser → Proxy
12
+ * ```
13
+ * { type: "request", requestId, method, path, query, headers, body }
14
+ * { type: "ping", id }
15
+ * ```
16
+ *
17
+ * Proxy → Browser
18
+ * ```
19
+ * { type: "response-start", requestId, status, headers }
20
+ * { type: "response-chunk", requestId, data: string (base64), done: boolean }
21
+ * { type: "response-error", requestId, error: string }
22
+ * { type: "pong", id }
23
+ * ```
24
+ *
25
+ * The protocol mirrors the tunnel relay protocol so both transports share
26
+ * the same mental model and the same browser-side `WebRtcProxy` implementation.
27
+ */
28
+
29
+ /** @import { DataChannel } from 'node-datachannel' */
30
+
31
+ /**
32
+ * Configuration for the data channel handler.
33
+ *
34
+ * @typedef {Object} DataChannelHandlerOptions
35
+ * @property {number} proxyPort
36
+ * Local port the proxy's Fastify HTTP server is listening on.
37
+ * Incoming requests are forwarded to `http://127.0.0.1:{proxyPort}`.
38
+ * @property {(message: string) => void} [onLog]
39
+ * Optional log sink.
40
+ */
41
+
42
+ /**
43
+ * An incoming request message received over the data channel.
44
+ *
45
+ * @typedef {Object} DataChannelRequest
46
+ * @property {string} requestId
47
+ * @property {string} method - HTTP method (GET, POST, …).
48
+ * @property {string} path - Request path (e.g. "/api/sources").
49
+ * @property {string} query - Raw query string without the leading "?".
50
+ * @property {Record<string, string>} headers - Headers to forward.
51
+ * @property {string | null} body - Request body string, or null.
52
+ */
53
+
54
+ /**
55
+ * The object returned by {@link createDataChannelHandler}.
56
+ *
57
+ * @typedef {Object} DataChannelHandler
58
+ * @property {(sessionId: string, channel: DataChannel) => void} handleChannel
59
+ * Wire message handlers onto a freshly opened data channel.
60
+ */
61
+
62
+ /**
63
+ * Create a handler for incoming WebRTC data channels.
64
+ *
65
+ * @param {DataChannelHandlerOptions} options
66
+ * @returns {DataChannelHandler}
67
+ */
68
+ export function createDataChannelHandler({ proxyPort, onLog }) {
69
+ /**
70
+ * @param {string} message
71
+ * @returns {void}
72
+ */
73
+ function log(message) {
74
+ if (typeof onLog === "function") {
75
+ onLog(message);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Wire up the `onMessage`, `onClosed`, and `onError` handlers for a channel.
81
+ *
82
+ * @param {string} sessionId
83
+ * @param {DataChannel} channel
84
+ * @returns {void}
85
+ */
86
+ function handleChannel(sessionId, channel) {
87
+ log(`[dc] Session ${sessionId.slice(0, 8)}: channel open`);
88
+
89
+ channel.onMessage((raw) => {
90
+ /** @type {DataChannelRequest | { type: "ping", id: string }} */
91
+ let message;
92
+ try {
93
+ message = JSON.parse(typeof raw === "string" ? raw : raw.toString());
94
+ } catch {
95
+ return;
96
+ }
97
+
98
+ if (message.type === "request") {
99
+ void handleRequest(channel, message).catch((error) => {
100
+ log(`[dc] Session ${sessionId.slice(0, 8)}: request error: ${error?.message ?? error}`);
101
+ });
102
+ return;
103
+ }
104
+
105
+ if (message.type === "ping") {
106
+ send(channel, { type: "pong", id: message.id });
107
+ }
108
+ });
109
+
110
+ channel.onClosed(() => {
111
+ log(`[dc] Session ${sessionId.slice(0, 8)}: channel closed`);
112
+ });
113
+
114
+ channel.onError((err) => {
115
+ log(`[dc] Session ${sessionId.slice(0, 8)}: channel error: ${err}`);
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Fetch a resource from the local proxy HTTP server and stream the response
121
+ * back to the browser over the data channel.
122
+ *
123
+ * The `Host` header is rewritten to `127.0.0.1:{proxyPort}` so that Fastify
124
+ * routes the request correctly regardless of what the browser sent.
125
+ *
126
+ * @param {DataChannel} channel
127
+ * @param {DataChannelRequest} req
128
+ * @returns {Promise<void>}
129
+ */
130
+ async function handleRequest(channel, req) {
131
+ const { requestId, method, path, query, headers: forwardedHeaders, body } = req;
132
+
133
+ // Reject paths that are not absolute, contain traversal sequences, or
134
+ // do not start with a known proxy route prefix. All valid browser-side
135
+ // requests use /api/*, /stream, /transcode/*, /health, or /healthz.
136
+ if (
137
+ typeof path !== "string" ||
138
+ !path.startsWith("/") ||
139
+ path.includes("..") ||
140
+ !PATH_ALLOWLIST_RE.test(path)
141
+ ) {
142
+ send(channel, { type: "response-error", requestId, error: "Invalid request path." });
143
+ return;
144
+ }
145
+
146
+ // Reject unreasonably large JSON bodies (legitimate API calls are small).
147
+ const MAX_BODY_BYTES = 64 * 1024; // 64 KB
148
+ if (body != null && typeof body === "string" && body.length > MAX_BODY_BYTES) {
149
+ send(channel, { type: "response-error", requestId, error: "Request body too large." });
150
+ return;
151
+ }
152
+
153
+ const targetUrl = `http://127.0.0.1:${proxyPort}${path}${query ? `?${query}` : ""}`;
154
+ const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
155
+
156
+ let response;
157
+ try {
158
+ response = await fetch(targetUrl, {
159
+ method,
160
+ headers: requestHeaders,
161
+ body: body != null ? body : undefined,
162
+ redirect: "manual"
163
+ });
164
+ } catch (fetchError) {
165
+ send(channel, { type: "response-error", requestId, error: fetchError?.message ?? String(fetchError) });
166
+ return;
167
+ }
168
+
169
+ /** @type {Record<string, string>} */
170
+ const responseHeaders = {};
171
+ for (const [name, value] of response.headers.entries()) {
172
+ responseHeaders[name] = value;
173
+ }
174
+
175
+ send(channel, { type: "response-start", requestId, status: response.status, headers: responseHeaders });
176
+
177
+ if (!response.body) {
178
+ send(channel, { type: "response-chunk", requestId, data: "", done: true });
179
+ return;
180
+ }
181
+
182
+ try {
183
+ const reader = response.body.getReader();
184
+ while (true) {
185
+ const { done, value } = await reader.read();
186
+ if (done) {
187
+ send(channel, { type: "response-chunk", requestId, data: "", done: true });
188
+ break;
189
+ }
190
+ send(channel, {
191
+ type: "response-chunk",
192
+ requestId,
193
+ data: Buffer.from(value).toString("base64"),
194
+ done: false
195
+ });
196
+ }
197
+ } catch {
198
+ send(channel, { type: "response-chunk", requestId, data: "", done: true });
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Serialise `message` to JSON and send it over the data channel.
204
+ * Errors are silently swallowed — the channel may have closed between
205
+ * the open check and the actual send.
206
+ *
207
+ * @param {DataChannel} channel
208
+ * @param {object} message
209
+ * @returns {void}
210
+ */
211
+ function send(channel, message) {
212
+ try {
213
+ channel.sendMessage(JSON.stringify(message));
214
+ } catch {
215
+ // Channel closed between check and send — safe to ignore.
216
+ }
217
+ }
218
+
219
+ return { handleChannel };
220
+ }
221
+
222
+ /**
223
+ * Allowed path prefixes for data-channel requests.
224
+ * Only the known proxy API and streaming routes are accepted.
225
+ */
226
+ const PATH_ALLOWLIST_RE = /^(?:\/api\/|\/stream(?:$|\?)|\/?transcode\/|\/health(?:z)?(?:$|\?))/;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @file System health metrics for proxy scoring.
3
+ *
4
+ * Collects lightweight OS-level metrics that allow the registry server to
5
+ * score and rank proxy clients when a browser requests playback.
6
+ * All values are cheap to read and require no background work.
7
+ */
8
+
9
+ import os from "node:os";
10
+
11
+ /**
12
+ * Snapshot of system health at a point in time.
13
+ *
14
+ * `cpuLoad` — 1-minute load average divided by the number of logical CPUs.
15
+ * 0 means idle, 1 means fully utilised, >1 means overloaded.
16
+ * Suitable as input to `Math.max(0, 1 - Math.min(1, cpuLoad))` for a
17
+ * normalised "CPU availability" score.
18
+ *
19
+ * `memFree` — fraction of total system RAM that is currently free (0–1).
20
+ *
21
+ * `uptime` — process uptime in whole seconds (useful for preferring
22
+ * already-warmed proxies over freshly started ones).
23
+ *
24
+ * @typedef {Object} HealthMetrics
25
+ * @property {number} cpuLoad - 1-min load avg / cpu-count. 0 = idle, 1 = saturated, >1 = overloaded.
26
+ * @property {number} memFree - Free RAM as a fraction of total RAM (0–1).
27
+ * @property {number} uptime - Process uptime in seconds.
28
+ */
29
+
30
+ /**
31
+ * Collect current system health metrics.
32
+ *
33
+ * All three values are rounded to three decimal places to avoid unnecessary
34
+ * diff noise when serialising to JSON across the tunnel.
35
+ *
36
+ * @returns {HealthMetrics}
37
+ */
38
+ export function collectHealthMetrics() {
39
+ const cpuCount = os.cpus().length || 1;
40
+ const cpuLoad = os.loadavg()[0] / cpuCount;
41
+ const memFree = os.freemem() / os.totalmem();
42
+
43
+ return {
44
+ cpuLoad: Math.round(cpuLoad * 1000) / 1000,
45
+ memFree: Math.round(memFree * 1000) / 1000,
46
+ uptime: Math.floor(process.uptime())
47
+ };
48
+ }
@@ -1,8 +1,34 @@
1
1
  /**
2
2
  * @file HTTP client for the registry server API.
3
3
  *
4
- * Handles proxy registration and periodic heartbeat requests.
4
+ * Handles proxy registration with the registry server.
5
5
  * The auth token is sent as the `x-proxy-token` request header.
6
+ *
7
+ * Liveness is tracked via the WebSocket tunnel connection — no heartbeat
8
+ * HTTP polling is needed. The proxy re-registers on every tunnel reconnect
9
+ * so the server's in-memory store stays consistent after restarts.
10
+ */
11
+
12
+ /**
13
+ * Parameters required to register this proxy with the registry server.
14
+ *
15
+ * @typedef {Object} RegisterClientParams
16
+ * @property {string} serverUrl - Base URL of the registry server (e.g. "http://my-server:8080").
17
+ * @property {string} id - Stable unique identifier for this proxy instance.
18
+ * @property {string} name - Human-readable display name shown in the server UI.
19
+ * @property {string} baseUrl - Publicly reachable base URL of this proxy's HTTP server.
20
+ * @property {string} token - Auth token sent as the `x-proxy-token` header.
21
+ */
22
+
23
+ /**
24
+ * The summary record returned by the server after a successful registration.
25
+ *
26
+ * @typedef {Object} ProxyClientSummary
27
+ * @property {string} id - Stable unique identifier.
28
+ * @property {string} name - Display name.
29
+ * @property {string} baseUrl - Advertised base URL.
30
+ * @property {string} createdAt - ISO timestamp of first registration.
31
+ * @property {string} lastSeenAt - ISO timestamp of this registration.
6
32
  */
7
33
 
8
34
  /**
@@ -27,21 +53,14 @@ function ensureBaseUrl(serverUrl) {
27
53
  return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
28
54
  }
29
55
 
30
- /**
31
- * @typedef {Object} RegisterClientParams
32
- * @property {string} serverUrl - Base URL of the registry server.
33
- * @property {string} id - Stable unique identifier for this proxy.
34
- * @property {string} name - Human-readable display name.
35
- * @property {string} baseUrl - Publicly reachable base URL of this proxy.
36
- * @property {string} token - Auth token sent as `x-proxy-token`.
37
- */
38
-
39
56
  /**
40
57
  * Register this proxy with the registry server.
41
- * Throws if the server responds with a non-2xx status.
58
+ *
59
+ * Throws if the server responds with a non-2xx status. The caller is
60
+ * responsible for retrying — see `cli.js` → `registerWithRetry`.
42
61
  *
43
62
  * @param {RegisterClientParams} params
44
- * @returns {Promise<{ client: { id: string, name: string, baseUrl: string, createdAt: string, lastSeenAt: string } }>}
63
+ * @returns {Promise<{ client: ProxyClientSummary }>}
45
64
  */
46
65
  export async function registerClient({ serverUrl, id, name, baseUrl, token }) {
47
66
  const response = await fetch(buildRegistryUrl(serverUrl, "api/proxy-clients/register"), {
@@ -60,34 +79,3 @@ export async function registerClient({ serverUrl, id, name, baseUrl, token }) {
60
79
 
61
80
  return response.json();
62
81
  }
63
-
64
- /**
65
- * @typedef {Object} SendHeartbeatParams
66
- * @property {string} serverUrl - Base URL of the registry server.
67
- * @property {string} id - Proxy ID to refresh.
68
- * @property {string} token - Auth token sent as `x-proxy-token`.
69
- */
70
-
71
- /**
72
- * Send a heartbeat to the registry server to refresh `lastSeenAt`.
73
- * Returns the HTTP status code, or `null` if the request failed entirely
74
- * (e.g. network error).
75
- *
76
- * @param {SendHeartbeatParams} params
77
- * @returns {Promise<number | null>}
78
- */
79
- export async function sendHeartbeat({ serverUrl, id, token }) {
80
- try {
81
- const response = await fetch(buildRegistryUrl(serverUrl, "api/proxy-clients/heartbeat"), {
82
- method: "POST",
83
- headers: {
84
- "Content-Type": "application/json",
85
- "x-proxy-token": token
86
- },
87
- body: JSON.stringify({ id })
88
- });
89
- return response.status;
90
- } catch (_error) {
91
- return null;
92
- }
93
- }
@@ -1,28 +1,77 @@
1
1
  /**
2
2
  * @file Outbound WebSocket tunnel from the proxy to the registry server.
3
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.
4
+ * The proxy opens one persistent connection on startup. Through it the
5
+ * server can:
6
+ * - relay browser HTTP requests to the proxy's local Fastify server, and
7
+ * - forward WebRTC signalling messages (offers, ICE candidates) between
8
+ * the browser and the proxy's WebRTC manager.
9
+ *
10
+ * The tunnel reconnects automatically with a fixed back-off after any
11
+ * unexpected close.
7
12
  */
8
13
 
14
+ import { WebSocket } from "ws";
15
+
16
+ /** @import { HealthMetrics } from './health-collector.js' */
17
+
9
18
  /**
19
+ * Configuration for the tunnel client.
20
+ *
10
21
  * @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.
22
+ * @property {string} serverUrl
23
+ * Base URL of the registry server (http or https converted to ws/wss automatically).
24
+ * @property {string} proxyId
25
+ * Stable ID used to identify this proxy on the server.
26
+ * @property {string} token
27
+ * Auth token sent as the `x-proxy-id` / `x-proxy-token` headers during the WS handshake.
28
+ * @property {number} proxyPort
29
+ * Local port the proxy's Fastify server is listening on.
30
+ * @property {(sessionId: string, signal: WebRtcSignal) => void} [onSignal]
31
+ * Called when the server forwards a WebRTC signal (SDP offer or ICE candidate)
32
+ * from a browser to this proxy. `sessionId` scopes the signal to a P2P session.
33
+ * @property {() => void} [onConnect]
34
+ * Called each time the WebSocket connection becomes open (including reconnects).
35
+ * Use to re-register the proxy so the server's in-memory store stays consistent
36
+ * after server restarts.
37
+ * @property {() => HealthMetrics} [onHealthRequest]
38
+ * Called when the server sends a `health-request` message. The return value is
39
+ * sent back as `health-response` and used by the server to score this proxy.
40
+ * @property {(message: string) => void} [onLog]
41
+ * Optional structured log sink.
42
+ */
43
+
44
+ /**
45
+ * A single WebRTC signal message forwarded through the tunnel.
46
+ *
47
+ * @typedef {Object} WebRtcSignal
48
+ * @property {string} type - Signal kind: "offer" | "answer" | "candidate".
49
+ * @property {string} [sdp] - SDP string (for "offer" and "answer").
50
+ * @property {string} [candidate] - ICE candidate string (for "candidate").
51
+ * @property {string} [mid] - SDP media ID associated with the candidate.
16
52
  */
17
53
 
18
54
  /**
55
+ * A relay request sent by the server — asking the proxy to perform a local
56
+ * HTTP fetch and stream the response back through the tunnel.
57
+ *
19
58
  * @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 "?".
59
+ * @property {string} requestId - Unique ID that ties request response chunks.
60
+ * @property {string} method - HTTP method (GET, POST, etc.).
61
+ * @property {string} path - Request path on the local proxy (e.g. "/health").
62
+ * @property {string} query - Raw query string without the leading "?".
24
63
  * @property {Record<string, string>} headers - Headers forwarded from the browser.
25
- * @property {string | null} body - Serialised JSON body, or null for GET.
64
+ * @property {string | null} body - Serialised request body, or null.
65
+ */
66
+
67
+ /**
68
+ * The object returned by {@link createTunnelClient}.
69
+ *
70
+ * @typedef {Object} TunnelClient
71
+ * @property {() => void} connect - Open the tunnel; reconnects on drop.
72
+ * @property {() => void} disconnect - Close the tunnel; suppresses reconnects.
73
+ * @property {(sessionId: string, signal: WebRtcSignal) => void} sendSignal
74
+ * Send a WebRTC signal (answer / candidate) back to the browser.
26
75
  */
27
76
 
28
77
  const RECONNECT_DELAY_MS = 5_000;
@@ -31,9 +80,9 @@ const RECONNECT_DELAY_MS = 5_000;
31
80
  * Create and manage the outbound WebSocket tunnel to the registry server.
32
81
  *
33
82
  * @param {TunnelClientOptions} options
34
- * @returns {{ connect: () => void, disconnect: () => void }}
83
+ * @returns {TunnelClient}
35
84
  */
36
- export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog }) {
85
+ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onSignal, onConnect, onHealthRequest, onLog }) {
37
86
  const wsUrl = serverUrl.replace(/^http/, "ws").replace(/\/+$/, "") + "/ws/proxy-tunnel";
38
87
 
39
88
  /** @type {WebSocket | null} */
@@ -43,7 +92,7 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
43
92
  let stopped = false;
44
93
 
45
94
  /**
46
- * Emit a log message via the provided callback.
95
+ * Write a message to the log sink if one was provided.
47
96
  *
48
97
  * @param {string} message
49
98
  * @returns {void}
@@ -56,7 +105,7 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
56
105
 
57
106
  /**
58
107
  * Open a new WebSocket connection to the server.
59
- * Automatically reconnects on close unless {@link disconnect} was called.
108
+ * Automatically schedules a reconnect after any unintentional close.
60
109
  *
61
110
  * @returns {void}
62
111
  */
@@ -75,6 +124,9 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
75
124
 
76
125
  socket.addEventListener("open", () => {
77
126
  log("Tunnel connected.");
127
+ if (typeof onConnect === "function") {
128
+ onConnect();
129
+ }
78
130
  });
79
131
 
80
132
  socket.addEventListener("message", (event) => {
@@ -84,10 +136,27 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
84
136
  } catch {
85
137
  return;
86
138
  }
139
+
87
140
  if (message.type === "request") {
88
141
  void handleRelayRequest(message).catch((error) => {
89
142
  log(`Tunnel relay error: ${error?.message ?? error}`);
90
143
  });
144
+ return;
145
+ }
146
+
147
+ // WebRTC signalling: server forwards a signal from a browser session.
148
+ if (message.type === "signal") {
149
+ if (typeof message.sessionId === "string" && message.signal && typeof onSignal === "function") {
150
+ onSignal(message.sessionId, message.signal);
151
+ }
152
+ return;
153
+ }
154
+
155
+ // Health check: server requests current metrics for proxy scoring.
156
+ if (message.type === "health-request") {
157
+ const metrics = typeof onHealthRequest === "function" ? onHealthRequest() : {};
158
+ send({ type: "health-response", requestId: message.requestId, metrics });
159
+ return;
91
160
  }
92
161
  });
93
162
 
@@ -105,8 +174,8 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
105
174
  }
106
175
 
107
176
  /**
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.
177
+ * Fetch a resource from the local Fastify server and stream the response
178
+ * back to the registry server chunk-by-chunk over the WebSocket.
110
179
  *
111
180
  * @param {TunnelRelayRequest} relayRequest
112
181
  * @returns {Promise<void>}
@@ -129,17 +198,13 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
129
198
  return;
130
199
  }
131
200
 
201
+ /** @type {Record<string, string>} */
132
202
  const responseHeaders = {};
133
203
  for (const [headerName, headerValue] of response.headers.entries()) {
134
204
  responseHeaders[headerName] = headerValue;
135
205
  }
136
206
 
137
- send({
138
- type: "response-start",
139
- requestId,
140
- status: response.status,
141
- headers: responseHeaders
142
- });
207
+ send({ type: "response-start", requestId, status: response.status, headers: responseHeaders });
143
208
 
144
209
  if (!response.body) {
145
210
  send({ type: "response-chunk", requestId, data: "", done: true });
@@ -167,7 +232,7 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
167
232
  }
168
233
 
169
234
  /**
170
- * Send a JSON message through the WebSocket if it is open.
235
+ * Serialise a message to JSON and send it through the WebSocket if open.
171
236
  *
172
237
  * @param {object} message
173
238
  * @returns {void}
@@ -179,7 +244,7 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
179
244
  }
180
245
 
181
246
  /**
182
- * Send a `response-error` message back to the server for the given request.
247
+ * Send a `response-error` frame for a given relay request.
183
248
  *
184
249
  * @param {string} requestId
185
250
  * @param {string} errorMessage
@@ -191,7 +256,7 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
191
256
 
192
257
  return {
193
258
  /**
194
- * Start the tunnel, connecting immediately and reconnecting on drop.
259
+ * Start the tunnel. Connects immediately and auto-reconnects on drop.
195
260
  *
196
261
  * @returns {void}
197
262
  */
@@ -201,7 +266,8 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
201
266
  },
202
267
 
203
268
  /**
204
- * Stop the tunnel and close the current connection without reconnecting.
269
+ * Tear down the tunnel. Closes the current connection and prevents
270
+ * any future reconnect attempts.
205
271
  *
206
272
  * @returns {void}
207
273
  */
@@ -215,6 +281,18 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
215
281
  socket.close(1000, "shutdown");
216
282
  socket = null;
217
283
  }
284
+ },
285
+
286
+ /**
287
+ * Forward a WebRTC signal (SDP answer or ICE candidate) from this proxy
288
+ * to the browser via the server tunnel.
289
+ *
290
+ * @param {string} sessionId - Scopes the signal to a single P2P session.
291
+ * @param {WebRtcSignal} signal
292
+ * @returns {void}
293
+ */
294
+ sendSignal(sessionId, signal) {
295
+ send({ type: "signal", sessionId, signal });
218
296
  }
219
297
  };
220
298
  }