runlocal 0.7.0 → 0.9.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/index.js +3 -3
- package/lib.js +139 -14
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -6,7 +6,7 @@ const { parseArgs, createConnection } = require("./lib");
|
|
|
6
6
|
const BOLD = "\x1b[1m";
|
|
7
7
|
const RESET = "\x1b[0m";
|
|
8
8
|
|
|
9
|
-
const {
|
|
9
|
+
const { target, host, apiKey, subdomain } = parseArgs(process.argv.slice(2));
|
|
10
10
|
|
|
11
|
-
console.log(`${BOLD}runlocal${RESET} — expose
|
|
12
|
-
createConnection({ host,
|
|
11
|
+
console.log(`${BOLD}runlocal${RESET} — expose ${target.display} to the internet`);
|
|
12
|
+
createConnection({ host, target, apiKey, subdomain, WebSocket });
|
package/lib.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const http = require("http");
|
|
2
|
+
const https = require("https");
|
|
2
3
|
const fs = require("fs");
|
|
3
4
|
const path = require("path");
|
|
4
5
|
const os = require("os");
|
|
@@ -25,8 +26,27 @@ function readApiKeyFile() {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
function parseTarget(value) {
|
|
30
|
+
if (/^https?:\/\//.test(value)) {
|
|
31
|
+
const url = new URL(value);
|
|
32
|
+
return {
|
|
33
|
+
hostname: url.hostname,
|
|
34
|
+
port: url.port ? parseInt(url.port, 10) : (url.protocol === "https:" ? 443 : 80),
|
|
35
|
+
protocol: url.protocol,
|
|
36
|
+
display: value.replace(/\/$/, ""),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const port = parseInt(value, 10);
|
|
40
|
+
return {
|
|
41
|
+
hostname: "127.0.0.1",
|
|
42
|
+
port,
|
|
43
|
+
protocol: "http:",
|
|
44
|
+
display: `localhost:${port}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
28
48
|
function parseArgs(argv) {
|
|
29
|
-
let
|
|
49
|
+
let target = parseTarget("3000");
|
|
30
50
|
let host = process.env.RUNLOCAL_HOST || "wss://runlocal.eu";
|
|
31
51
|
let apiKey = process.env.RUNLATER_API_KEY || readApiKeyFile();
|
|
32
52
|
let subdomain = null;
|
|
@@ -38,9 +58,9 @@ function parseArgs(argv) {
|
|
|
38
58
|
} else if (argv[i] === "--subdomain" && argv[i + 1]) {
|
|
39
59
|
subdomain = argv[++i];
|
|
40
60
|
} else if (argv[i] === "--help" || argv[i] === "-h") {
|
|
41
|
-
console.log("Usage: runlocal <port> [options]");
|
|
61
|
+
console.log("Usage: runlocal <port|url> [options]");
|
|
42
62
|
console.log("");
|
|
43
|
-
console.log(" Expose
|
|
63
|
+
console.log(" Expose a local server to the internet. Works with runlocal.eu");
|
|
44
64
|
console.log(" or any self-hosted runlocal server.");
|
|
45
65
|
console.log("");
|
|
46
66
|
console.log("Options:");
|
|
@@ -55,19 +75,21 @@ function parseArgs(argv) {
|
|
|
55
75
|
console.log("");
|
|
56
76
|
console.log("Examples:");
|
|
57
77
|
console.log(" npx runlocal 3000 Random subdomain");
|
|
78
|
+
console.log(" npx runlocal https://10.8.0.1 Proxy any URL");
|
|
79
|
+
console.log(" npx runlocal http://myapp.local:8080 Custom host and port");
|
|
58
80
|
console.log(" npx runlocal 3000 --api-key pk_xxx Stable subdomain");
|
|
59
81
|
console.log(" npx runlocal 3000 --subdomain my-api Custom subdomain");
|
|
60
82
|
console.log(" npx runlocal 3000 --server wss://tunnel.example.com Self-hosted");
|
|
61
83
|
console.log("");
|
|
62
|
-
console.log("Self-hosting: https://github.com/runlater-eu/runlocal");
|
|
84
|
+
console.log("Self-hosting: https://github.com/runlater-eu/runlocal-server");
|
|
63
85
|
console.log("Hosted version: https://runlocal.eu");
|
|
64
86
|
process.exit(0);
|
|
65
87
|
} else if (!argv[i].startsWith("-")) {
|
|
66
|
-
|
|
88
|
+
target = parseTarget(argv[i]);
|
|
67
89
|
}
|
|
68
90
|
}
|
|
69
91
|
|
|
70
|
-
return {
|
|
92
|
+
return { target, host, apiKey, subdomain };
|
|
71
93
|
}
|
|
72
94
|
|
|
73
95
|
function filterHeaders(headers) {
|
|
@@ -89,7 +111,7 @@ function buildWsUrl(host, apiKey, subdomain) {
|
|
|
89
111
|
return `${host}/tunnel/websocket?${params.toString()}`;
|
|
90
112
|
}
|
|
91
113
|
|
|
92
|
-
function handleRequest(ws, joinRef, topic, payload,
|
|
114
|
+
function handleRequest(ws, joinRef, topic, payload, target, nextRef, log) {
|
|
93
115
|
const { request_id, method, path, query_string, headers, body } = payload;
|
|
94
116
|
const fullPath = query_string ? `${path}?${query_string}` : path;
|
|
95
117
|
|
|
@@ -100,15 +122,17 @@ function handleRequest(ws, joinRef, topic, payload, port, nextRef, log) {
|
|
|
100
122
|
|
|
101
123
|
const reqHeaders = filterHeaders(headers);
|
|
102
124
|
|
|
125
|
+
const requester = target.protocol === "https:" ? https : http;
|
|
103
126
|
const options = {
|
|
104
|
-
hostname:
|
|
105
|
-
port: port,
|
|
127
|
+
hostname: target.hostname,
|
|
128
|
+
port: target.port,
|
|
106
129
|
path: fullPath,
|
|
107
130
|
method: method,
|
|
108
131
|
headers: reqHeaders,
|
|
132
|
+
rejectUnauthorized: false,
|
|
109
133
|
};
|
|
110
134
|
|
|
111
|
-
const proxyReq =
|
|
135
|
+
const proxyReq = requester.request(options, (proxyRes) => {
|
|
112
136
|
const chunks = [];
|
|
113
137
|
proxyRes.on("data", (chunk) => chunks.push(chunk));
|
|
114
138
|
proxyRes.on("end", () => {
|
|
@@ -161,7 +185,7 @@ function handleRequest(ws, joinRef, topic, payload, port, nextRef, log) {
|
|
|
161
185
|
request_id,
|
|
162
186
|
status: 502,
|
|
163
187
|
headers: [["content-type", "text/plain"]],
|
|
164
|
-
body: `Could not connect to
|
|
188
|
+
body: `Could not connect to ${target.display} — ${err.message}`,
|
|
165
189
|
},
|
|
166
190
|
])
|
|
167
191
|
);
|
|
@@ -176,7 +200,7 @@ function handleRequest(ws, joinRef, topic, payload, port, nextRef, log) {
|
|
|
176
200
|
function createConnection(options) {
|
|
177
201
|
const {
|
|
178
202
|
host,
|
|
179
|
-
|
|
203
|
+
target,
|
|
180
204
|
apiKey,
|
|
181
205
|
subdomain,
|
|
182
206
|
WebSocket,
|
|
@@ -193,6 +217,7 @@ function createConnection(options) {
|
|
|
193
217
|
const ws = new WebSocket(wsUrl);
|
|
194
218
|
let heartbeatTimer = null;
|
|
195
219
|
let joinRef = null;
|
|
220
|
+
const activeWsConnections = new Map();
|
|
196
221
|
|
|
197
222
|
ws.on("open", () => {
|
|
198
223
|
const displayHost = host.replace(/^wss?:\/\//, "");
|
|
@@ -251,7 +276,7 @@ function createConnection(options) {
|
|
|
251
276
|
}
|
|
252
277
|
|
|
253
278
|
log("");
|
|
254
|
-
log(` ${DIM}Forwarding to
|
|
279
|
+
log(` ${DIM}Forwarding to ${target.display}${RESET}`);
|
|
255
280
|
log(` ${DIM}Inspect requests at ${RESET}${CYAN}${inspectUrl}${RESET}`);
|
|
256
281
|
log(` ${DIM}Press Ctrl+C to stop${RESET}`);
|
|
257
282
|
|
|
@@ -267,7 +292,22 @@ function createConnection(options) {
|
|
|
267
292
|
}
|
|
268
293
|
|
|
269
294
|
if (event === "http_request") {
|
|
270
|
-
handleRequest(ws, joinRef, topic, payload,
|
|
295
|
+
handleRequest(ws, joinRef, topic, payload, target, nextRef, log);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (event === "ws_upgrade") {
|
|
300
|
+
handleWsUpgrade(ws, joinRef, topic, payload, target, nextRef, log, activeWsConnections, WebSocket);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (event === "ws_client_frame") {
|
|
305
|
+
handleWsClientFrame(payload, activeWsConnections);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (event === "ws_close") {
|
|
310
|
+
handleWsClose(payload, activeWsConnections);
|
|
271
311
|
return;
|
|
272
312
|
}
|
|
273
313
|
|
|
@@ -283,6 +323,10 @@ function createConnection(options) {
|
|
|
283
323
|
|
|
284
324
|
ws.on("close", () => {
|
|
285
325
|
clearInterval(heartbeatTimer);
|
|
326
|
+
for (const [, localWs] of activeWsConnections) {
|
|
327
|
+
try { localWs.close(); } catch {}
|
|
328
|
+
}
|
|
329
|
+
activeWsConnections.clear();
|
|
286
330
|
log(`${YELLOW}Disconnected. Reconnecting in 3s...${RESET}`);
|
|
287
331
|
const reconnectTimer = setTimeout(() => createConnection(options), 3000);
|
|
288
332
|
reconnectTimer.unref();
|
|
@@ -302,8 +346,89 @@ function createConnection(options) {
|
|
|
302
346
|
return { ws, getJoinRef: () => joinRef, nextRef };
|
|
303
347
|
}
|
|
304
348
|
|
|
349
|
+
function handleWsUpgrade(ws, joinRef, topic, payload, target, nextRef, log, activeWsConnections, WebSocket) {
|
|
350
|
+
const { ws_id, path: wsPath, query_string, headers } = payload;
|
|
351
|
+
const fullPath = query_string ? `${wsPath}?${query_string}` : wsPath;
|
|
352
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
353
|
+
|
|
354
|
+
log(`${DIM}${timestamp}${RESET} ${BOLD}WS${RESET} ${fullPath}`);
|
|
355
|
+
|
|
356
|
+
const wsProtocol = target.protocol === "https:" ? "wss:" : "ws:";
|
|
357
|
+
const localWsUrl = `${wsProtocol}//${target.hostname}:${target.port}${fullPath}`;
|
|
358
|
+
|
|
359
|
+
const reqHeaders = {};
|
|
360
|
+
if (headers) {
|
|
361
|
+
for (const [k, v] of headers) {
|
|
362
|
+
const lower = k.toLowerCase();
|
|
363
|
+
if (lower !== "host" && lower !== "upgrade" && lower !== "connection" &&
|
|
364
|
+
lower !== "sec-websocket-key" && lower !== "sec-websocket-version" &&
|
|
365
|
+
lower !== "sec-websocket-extensions") {
|
|
366
|
+
reqHeaders[k] = v;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let localWs;
|
|
372
|
+
try {
|
|
373
|
+
localWs = new WebSocket(localWsUrl, { headers: reqHeaders, rejectUnauthorized: false });
|
|
374
|
+
} catch (err) {
|
|
375
|
+
log(`${DIM}${timestamp}${RESET} ${RED}WS ERR${RESET} ${fullPath} — ${err.message}`);
|
|
376
|
+
ws.send(JSON.stringify([joinRef, nextRef(), topic, "ws_close", { ws_id }]));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
activeWsConnections.set(ws_id, localWs);
|
|
381
|
+
|
|
382
|
+
localWs.on("message", (data, isBinary) => {
|
|
383
|
+
const opcode = isBinary ? "binary" : "text";
|
|
384
|
+
const frameData = isBinary ? Buffer.from(data).toString("base64") : data.toString();
|
|
385
|
+
|
|
386
|
+
ws.send(JSON.stringify([
|
|
387
|
+
joinRef,
|
|
388
|
+
nextRef(),
|
|
389
|
+
topic,
|
|
390
|
+
"ws_frame",
|
|
391
|
+
{ ws_id, data: frameData, opcode },
|
|
392
|
+
]));
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
localWs.on("close", () => {
|
|
396
|
+
activeWsConnections.delete(ws_id);
|
|
397
|
+
log(`${DIM}${timestamp}${RESET} ${DIM}WS closed${RESET} ${fullPath}`);
|
|
398
|
+
ws.send(JSON.stringify([joinRef, nextRef(), topic, "ws_close", { ws_id }]));
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
localWs.on("error", (err) => {
|
|
402
|
+
log(`${DIM}${timestamp}${RESET} ${RED}WS ERR${RESET} ${fullPath} — ${err.message}`);
|
|
403
|
+
activeWsConnections.delete(ws_id);
|
|
404
|
+
ws.send(JSON.stringify([joinRef, nextRef(), topic, "ws_close", { ws_id }]));
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function handleWsClientFrame(payload, activeWsConnections) {
|
|
409
|
+
const { ws_id, data, opcode } = payload;
|
|
410
|
+
const localWs = activeWsConnections.get(ws_id);
|
|
411
|
+
if (!localWs || localWs.readyState !== 1) return;
|
|
412
|
+
|
|
413
|
+
if (opcode === "binary") {
|
|
414
|
+
localWs.send(Buffer.from(data, "base64"));
|
|
415
|
+
} else {
|
|
416
|
+
localWs.send(data);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function handleWsClose(payload, activeWsConnections) {
|
|
421
|
+
const { ws_id } = payload;
|
|
422
|
+
const localWs = activeWsConnections.get(ws_id);
|
|
423
|
+
if (localWs) {
|
|
424
|
+
activeWsConnections.delete(ws_id);
|
|
425
|
+
try { localWs.close(); } catch {}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
305
429
|
module.exports = {
|
|
306
430
|
parseArgs,
|
|
431
|
+
parseTarget,
|
|
307
432
|
filterHeaders,
|
|
308
433
|
buildWsUrl,
|
|
309
434
|
handleRequest,
|