@torrent-tv/proxy 2.2.0 → 2.4.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,75 @@
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 { HealthMetrics } from './health-collector.js' */
15
+
9
16
  /**
17
+ * Configuration for the tunnel client.
18
+ *
10
19
  * @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.
20
+ * @property {string} serverUrl
21
+ * Base URL of the registry server (http or https converted to ws/wss automatically).
22
+ * @property {string} proxyId
23
+ * Stable ID used to identify this proxy on the server.
24
+ * @property {string} token
25
+ * Auth token sent as the `x-proxy-id` / `x-proxy-token` headers during the WS handshake.
26
+ * @property {number} proxyPort
27
+ * Local port the proxy's Fastify server is listening on.
28
+ * @property {(sessionId: string, signal: WebRtcSignal) => void} [onSignal]
29
+ * Called when the server forwards a WebRTC signal (SDP offer or ICE candidate)
30
+ * from a browser to this proxy. `sessionId` scopes the signal to a P2P session.
31
+ * @property {() => void} [onConnect]
32
+ * Called each time the WebSocket connection becomes open (including reconnects).
33
+ * Use to re-register the proxy so the server's in-memory store stays consistent
34
+ * after server restarts.
35
+ * @property {() => HealthMetrics} [onHealthRequest]
36
+ * Called when the server sends a `health-request` message. The return value is
37
+ * sent back as `health-response` and used by the server to score this proxy.
38
+ * @property {(message: string) => void} [onLog]
39
+ * Optional structured log sink.
40
+ */
41
+
42
+ /**
43
+ * A single WebRTC signal message forwarded through the tunnel.
44
+ *
45
+ * @typedef {Object} WebRtcSignal
46
+ * @property {string} type - Signal kind: "offer" | "answer" | "candidate".
47
+ * @property {string} [sdp] - SDP string (for "offer" and "answer").
48
+ * @property {string} [candidate] - ICE candidate string (for "candidate").
49
+ * @property {string} [mid] - SDP media ID associated with the candidate.
16
50
  */
17
51
 
18
52
  /**
53
+ * A relay request sent by the server — asking the proxy to perform a local
54
+ * HTTP fetch and stream the response back through the tunnel.
55
+ *
19
56
  * @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 "?".
57
+ * @property {string} requestId - Unique ID that ties request response chunks.
58
+ * @property {string} method - HTTP method (GET, POST, etc.).
59
+ * @property {string} path - Request path on the local proxy (e.g. "/health").
60
+ * @property {string} query - Raw query string without the leading "?".
24
61
  * @property {Record<string, string>} headers - Headers forwarded from the browser.
25
- * @property {string | null} body - Serialised JSON body, or null for GET.
62
+ * @property {string | null} body - Serialised request body, or null.
63
+ */
64
+
65
+ /**
66
+ * The object returned by {@link createTunnelClient}.
67
+ *
68
+ * @typedef {Object} TunnelClient
69
+ * @property {() => void} connect - Open the tunnel; reconnects on drop.
70
+ * @property {() => void} disconnect - Close the tunnel; suppresses reconnects.
71
+ * @property {(sessionId: string, signal: WebRtcSignal) => void} sendSignal
72
+ * Send a WebRTC signal (answer / candidate) back to the browser.
26
73
  */
27
74
 
28
75
  const RECONNECT_DELAY_MS = 5_000;
@@ -31,9 +78,9 @@ const RECONNECT_DELAY_MS = 5_000;
31
78
  * Create and manage the outbound WebSocket tunnel to the registry server.
32
79
  *
33
80
  * @param {TunnelClientOptions} options
34
- * @returns {{ connect: () => void, disconnect: () => void }}
81
+ * @returns {TunnelClient}
35
82
  */
36
- export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog }) {
83
+ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onSignal, onConnect, onHealthRequest, onLog }) {
37
84
  const wsUrl = serverUrl.replace(/^http/, "ws").replace(/\/+$/, "") + "/ws/proxy-tunnel";
38
85
 
39
86
  /** @type {WebSocket | null} */
@@ -43,7 +90,7 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
43
90
  let stopped = false;
44
91
 
45
92
  /**
46
- * Emit a log message via the provided callback.
93
+ * Write a message to the log sink if one was provided.
47
94
  *
48
95
  * @param {string} message
49
96
  * @returns {void}
@@ -56,7 +103,7 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
56
103
 
57
104
  /**
58
105
  * Open a new WebSocket connection to the server.
59
- * Automatically reconnects on close unless {@link disconnect} was called.
106
+ * Automatically schedules a reconnect after any unintentional close.
60
107
  *
61
108
  * @returns {void}
62
109
  */
@@ -75,6 +122,9 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
75
122
 
76
123
  socket.addEventListener("open", () => {
77
124
  log("Tunnel connected.");
125
+ if (typeof onConnect === "function") {
126
+ onConnect();
127
+ }
78
128
  });
79
129
 
80
130
  socket.addEventListener("message", (event) => {
@@ -84,10 +134,27 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
84
134
  } catch {
85
135
  return;
86
136
  }
137
+
87
138
  if (message.type === "request") {
88
139
  void handleRelayRequest(message).catch((error) => {
89
140
  log(`Tunnel relay error: ${error?.message ?? error}`);
90
141
  });
142
+ return;
143
+ }
144
+
145
+ // WebRTC signalling: server forwards a signal from a browser session.
146
+ if (message.type === "signal") {
147
+ if (typeof message.sessionId === "string" && message.signal && typeof onSignal === "function") {
148
+ onSignal(message.sessionId, message.signal);
149
+ }
150
+ return;
151
+ }
152
+
153
+ // Health check: server requests current metrics for proxy scoring.
154
+ if (message.type === "health-request") {
155
+ const metrics = typeof onHealthRequest === "function" ? onHealthRequest() : {};
156
+ send({ type: "health-response", requestId: message.requestId, metrics });
157
+ return;
91
158
  }
92
159
  });
93
160
 
@@ -105,8 +172,8 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
105
172
  }
106
173
 
107
174
  /**
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.
175
+ * Fetch a resource from the local Fastify server and stream the response
176
+ * back to the registry server chunk-by-chunk over the WebSocket.
110
177
  *
111
178
  * @param {TunnelRelayRequest} relayRequest
112
179
  * @returns {Promise<void>}
@@ -129,17 +196,13 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
129
196
  return;
130
197
  }
131
198
 
199
+ /** @type {Record<string, string>} */
132
200
  const responseHeaders = {};
133
201
  for (const [headerName, headerValue] of response.headers.entries()) {
134
202
  responseHeaders[headerName] = headerValue;
135
203
  }
136
204
 
137
- send({
138
- type: "response-start",
139
- requestId,
140
- status: response.status,
141
- headers: responseHeaders
142
- });
205
+ send({ type: "response-start", requestId, status: response.status, headers: responseHeaders });
143
206
 
144
207
  if (!response.body) {
145
208
  send({ type: "response-chunk", requestId, data: "", done: true });
@@ -167,7 +230,7 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
167
230
  }
168
231
 
169
232
  /**
170
- * Send a JSON message through the WebSocket if it is open.
233
+ * Serialise a message to JSON and send it through the WebSocket if open.
171
234
  *
172
235
  * @param {object} message
173
236
  * @returns {void}
@@ -179,7 +242,7 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
179
242
  }
180
243
 
181
244
  /**
182
- * Send a `response-error` message back to the server for the given request.
245
+ * Send a `response-error` frame for a given relay request.
183
246
  *
184
247
  * @param {string} requestId
185
248
  * @param {string} errorMessage
@@ -191,7 +254,7 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
191
254
 
192
255
  return {
193
256
  /**
194
- * Start the tunnel, connecting immediately and reconnecting on drop.
257
+ * Start the tunnel. Connects immediately and auto-reconnects on drop.
195
258
  *
196
259
  * @returns {void}
197
260
  */
@@ -201,7 +264,8 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
201
264
  },
202
265
 
203
266
  /**
204
- * Stop the tunnel and close the current connection without reconnecting.
267
+ * Tear down the tunnel. Closes the current connection and prevents
268
+ * any future reconnect attempts.
205
269
  *
206
270
  * @returns {void}
207
271
  */
@@ -215,6 +279,18 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onLog
215
279
  socket.close(1000, "shutdown");
216
280
  socket = null;
217
281
  }
282
+ },
283
+
284
+ /**
285
+ * Forward a WebRTC signal (SDP answer or ICE candidate) from this proxy
286
+ * to the browser via the server tunnel.
287
+ *
288
+ * @param {string} sessionId - Scopes the signal to a single P2P session.
289
+ * @param {WebRtcSignal} signal
290
+ * @returns {void}
291
+ */
292
+ sendSignal(sessionId, signal) {
293
+ send({ type: "signal", sessionId, signal });
218
294
  }
219
295
  };
220
296
  }