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.
Files changed (3) hide show
  1. package/index.js +3 -3
  2. package/lib.js +139 -14
  3. 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 { port, host, apiKey, subdomain } = parseArgs(process.argv.slice(2));
9
+ const { target, host, apiKey, subdomain } = parseArgs(process.argv.slice(2));
10
10
 
11
- console.log(`${BOLD}runlocal${RESET} — expose localhost:${port} to the internet`);
12
- createConnection({ host, port, apiKey, subdomain, WebSocket });
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 port = 3000;
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 localhost to the internet. Works with runlocal.eu");
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
- port = parseInt(argv[i], 10);
88
+ target = parseTarget(argv[i]);
67
89
  }
68
90
  }
69
91
 
70
- return { port, host, apiKey, subdomain };
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, port, nextRef, log) {
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: "127.0.0.1",
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 = http.request(options, (proxyRes) => {
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 localhost:${port} — ${err.message}`,
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
- port,
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 localhost:${port}${RESET}`);
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, port, nextRef, log);
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlocal",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Expose localhost to the internet — open source tunnel server",
5
5
  "bin": {
6
6
  "runlocal": "./index.js"