@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 +1 -1
- package/README.md +248 -143
- package/bin/cli.js +52 -31
- package/package.json +4 -2
- package/server.js +6 -0
- package/services/data-channel-handler.js +226 -0
- package/services/health-collector.js +48 -0
- package/services/registry-api.js +31 -43
- package/services/tunnel-client.js +107 -29
- package/services/webrtc-manager.js +166 -0
|
@@ -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
|
+
}
|
package/services/registry-api.js
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file HTTP client for the registry server API.
|
|
3
3
|
*
|
|
4
|
-
* Handles proxy registration
|
|
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
|
-
*
|
|
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:
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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}
|
|
12
|
-
*
|
|
13
|
-
* @property {string}
|
|
14
|
-
*
|
|
15
|
-
* @property {
|
|
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
|
|
21
|
-
* @property {string} method - HTTP method
|
|
22
|
-
* @property {string} path - Request path (e.g. "/health").
|
|
23
|
-
* @property {string} query -
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
109
|
-
*
|
|
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
|
-
*
|
|
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`
|
|
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
|
|
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
|
-
*
|
|
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
|
}
|