runlocal 0.7.0 → 0.8.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 (2) hide show
  1. package/lib.js +100 -1
  2. package/package.json +1 -1
package/lib.js CHANGED
@@ -59,7 +59,7 @@ function parseArgs(argv) {
59
59
  console.log(" npx runlocal 3000 --subdomain my-api Custom subdomain");
60
60
  console.log(" npx runlocal 3000 --server wss://tunnel.example.com Self-hosted");
61
61
  console.log("");
62
- console.log("Self-hosting: https://github.com/runlater-eu/runlocal");
62
+ console.log("Self-hosting: https://github.com/runlater-eu/runlocal-server");
63
63
  console.log("Hosted version: https://runlocal.eu");
64
64
  process.exit(0);
65
65
  } else if (!argv[i].startsWith("-")) {
@@ -193,6 +193,7 @@ function createConnection(options) {
193
193
  const ws = new WebSocket(wsUrl);
194
194
  let heartbeatTimer = null;
195
195
  let joinRef = null;
196
+ const activeWsConnections = new Map();
196
197
 
197
198
  ws.on("open", () => {
198
199
  const displayHost = host.replace(/^wss?:\/\//, "");
@@ -271,6 +272,21 @@ function createConnection(options) {
271
272
  return;
272
273
  }
273
274
 
275
+ if (event === "ws_upgrade") {
276
+ handleWsUpgrade(ws, joinRef, topic, payload, port, nextRef, log, activeWsConnections, WebSocket);
277
+ return;
278
+ }
279
+
280
+ if (event === "ws_client_frame") {
281
+ handleWsClientFrame(payload, activeWsConnections);
282
+ return;
283
+ }
284
+
285
+ if (event === "ws_close") {
286
+ handleWsClose(payload, activeWsConnections);
287
+ return;
288
+ }
289
+
274
290
  if (event === "phx_close") {
275
291
  log(`${YELLOW}Tunnel closed by server${RESET}`);
276
292
  if (onClose) {
@@ -283,6 +299,10 @@ function createConnection(options) {
283
299
 
284
300
  ws.on("close", () => {
285
301
  clearInterval(heartbeatTimer);
302
+ for (const [, localWs] of activeWsConnections) {
303
+ try { localWs.close(); } catch {}
304
+ }
305
+ activeWsConnections.clear();
286
306
  log(`${YELLOW}Disconnected. Reconnecting in 3s...${RESET}`);
287
307
  const reconnectTimer = setTimeout(() => createConnection(options), 3000);
288
308
  reconnectTimer.unref();
@@ -302,6 +322,85 @@ function createConnection(options) {
302
322
  return { ws, getJoinRef: () => joinRef, nextRef };
303
323
  }
304
324
 
325
+ function handleWsUpgrade(ws, joinRef, topic, payload, port, nextRef, log, activeWsConnections, WebSocket) {
326
+ const { ws_id, path: wsPath, query_string, headers } = payload;
327
+ const fullPath = query_string ? `${wsPath}?${query_string}` : wsPath;
328
+ const timestamp = new Date().toLocaleTimeString();
329
+
330
+ log(`${DIM}${timestamp}${RESET} ${BOLD}WS${RESET} ${fullPath}`);
331
+
332
+ const localWsUrl = `ws://127.0.0.1:${port}${fullPath}`;
333
+
334
+ const reqHeaders = {};
335
+ if (headers) {
336
+ for (const [k, v] of headers) {
337
+ const lower = k.toLowerCase();
338
+ if (lower !== "host" && lower !== "upgrade" && lower !== "connection" &&
339
+ lower !== "sec-websocket-key" && lower !== "sec-websocket-version" &&
340
+ lower !== "sec-websocket-extensions") {
341
+ reqHeaders[k] = v;
342
+ }
343
+ }
344
+ }
345
+
346
+ let localWs;
347
+ try {
348
+ localWs = new WebSocket(localWsUrl, { headers: reqHeaders });
349
+ } catch (err) {
350
+ log(`${DIM}${timestamp}${RESET} ${RED}WS ERR${RESET} ${fullPath} — ${err.message}`);
351
+ ws.send(JSON.stringify([joinRef, nextRef(), topic, "ws_close", { ws_id }]));
352
+ return;
353
+ }
354
+
355
+ activeWsConnections.set(ws_id, localWs);
356
+
357
+ localWs.on("message", (data, isBinary) => {
358
+ const opcode = isBinary ? "binary" : "text";
359
+ const frameData = isBinary ? Buffer.from(data).toString("base64") : data.toString();
360
+
361
+ ws.send(JSON.stringify([
362
+ joinRef,
363
+ nextRef(),
364
+ topic,
365
+ "ws_frame",
366
+ { ws_id, data: frameData, opcode },
367
+ ]));
368
+ });
369
+
370
+ localWs.on("close", () => {
371
+ activeWsConnections.delete(ws_id);
372
+ log(`${DIM}${timestamp}${RESET} ${DIM}WS closed${RESET} ${fullPath}`);
373
+ ws.send(JSON.stringify([joinRef, nextRef(), topic, "ws_close", { ws_id }]));
374
+ });
375
+
376
+ localWs.on("error", (err) => {
377
+ log(`${DIM}${timestamp}${RESET} ${RED}WS ERR${RESET} ${fullPath} — ${err.message}`);
378
+ activeWsConnections.delete(ws_id);
379
+ ws.send(JSON.stringify([joinRef, nextRef(), topic, "ws_close", { ws_id }]));
380
+ });
381
+ }
382
+
383
+ function handleWsClientFrame(payload, activeWsConnections) {
384
+ const { ws_id, data, opcode } = payload;
385
+ const localWs = activeWsConnections.get(ws_id);
386
+ if (!localWs || localWs.readyState !== 1) return;
387
+
388
+ if (opcode === "binary") {
389
+ localWs.send(Buffer.from(data, "base64"));
390
+ } else {
391
+ localWs.send(data);
392
+ }
393
+ }
394
+
395
+ function handleWsClose(payload, activeWsConnections) {
396
+ const { ws_id } = payload;
397
+ const localWs = activeWsConnections.get(ws_id);
398
+ if (localWs) {
399
+ activeWsConnections.delete(ws_id);
400
+ try { localWs.close(); } catch {}
401
+ }
402
+ }
403
+
305
404
  module.exports = {
306
405
  parseArgs,
307
406
  filterHeaders,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlocal",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Expose localhost to the internet — open source tunnel server",
5
5
  "bin": {
6
6
  "runlocal": "./index.js"