runlocal 0.3.1 → 0.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.
Files changed (4) hide show
  1. package/README.md +92 -0
  2. package/index.js +4 -179
  3. package/lib.js +276 -0
  4. package/package.json +5 -2
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # runlocal
2
+
3
+ Expose your local development server to the internet. Get a public HTTPS URL that tunnels requests to `localhost` via [runlocal.eu](https://runlocal.eu).
4
+
5
+ No account needed. No configuration. Just run it.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install -g runlocal
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```sh
16
+ # Tunnel to localhost:3000 (default)
17
+ runlocal
18
+
19
+ # Tunnel to a specific port
20
+ runlocal 4000
21
+
22
+ # Use a custom tunnel server
23
+ runlocal 3000 --host wss://your-server.com
24
+ ```
25
+
26
+ You'll get a public URL like `https://abc123.runlocal.eu` that forwards HTTP requests to your local server.
27
+
28
+ ### Options
29
+
30
+ | Option | Description |
31
+ |---|---|
32
+ | `<port>` | Local port to forward to (default: `3000`) |
33
+ | `--host <url>` | Tunnel server URL (default: `wss://runlocal.eu`) |
34
+ | `--help`, `-h` | Show help |
35
+
36
+ ### Environment variables
37
+
38
+ | Variable | Description |
39
+ |---|---|
40
+ | `RUNLOCAL_HOST` | Same as `--host`. The flag takes precedence. |
41
+
42
+ ## How it works
43
+
44
+ `runlocal` opens a WebSocket connection to the tunnel server. When someone visits your public URL, the server forwards the HTTP request through the WebSocket. `runlocal` proxies it to your local server and sends the response back.
45
+
46
+ ```
47
+ Browser → runlocal.eu → WebSocket → runlocal CLI → localhost:3000
48
+ ```
49
+
50
+ ## Contributing
51
+
52
+ ### Setup
53
+
54
+ ```sh
55
+ git clone git@github.com:runlater-eu/runlocal.git
56
+ cd runlocal
57
+ npm install
58
+ ```
59
+
60
+ ### Running tests
61
+
62
+ ```sh
63
+ npm test
64
+ ```
65
+
66
+ Tests use Node's built-in test runner (`node:test`) with no additional dependencies. The test suite covers:
67
+
68
+ - **Argument parsing** — defaults, custom port, `--host` flag, env var precedence
69
+ - **Header filtering** — strips `host` and `accept-encoding`, preserves others
70
+ - **HTTP proxying** — GET/POST, query strings, response headers, error handling
71
+ - **WebSocket lifecycle** — join, heartbeat, tunnel creation, reconnection
72
+ - **Integration** — full round-trip with real WebSocket and HTTP servers
73
+
74
+ ### Project structure
75
+
76
+ ```
77
+ index.js CLI entry point
78
+ lib.js Core logic (parseArgs, filterHeaders, handleRequest, createConnection)
79
+ test/ Test files
80
+ ```
81
+
82
+ The core logic in `lib.js` uses dependency injection for the WebSocket constructor and logger, making it straightforward to test without mocking globals.
83
+
84
+ ### Submitting changes
85
+
86
+ 1. Create a branch for your change
87
+ 2. Make sure `npm test` passes
88
+ 3. Open a pull request
89
+
90
+ ## License
91
+
92
+ MIT
package/index.js CHANGED
@@ -1,187 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const WebSocket = require("ws");
4
- const http = require("http");
4
+ const { parseArgs, createConnection } = require("./lib");
5
5
 
6
- const PORT = parseInt(process.argv[2] || "3000", 10);
7
- const HOST = process.env.RUNLOCAL_HOST || "wss://runlocal.eu";
8
- const WS_URL = `${HOST}/tunnel/websocket?vsn=2.0.0`;
9
-
10
- const GREEN = "\x1b[32m";
11
- const CYAN = "\x1b[36m";
12
- const DIM = "\x1b[2m";
13
6
  const BOLD = "\x1b[1m";
14
7
  const RESET = "\x1b[0m";
15
- const RED = "\x1b[31m";
16
- const YELLOW = "\x1b[33m";
17
-
18
- let refCounter = 0;
19
- const nextRef = () => String(++refCounter);
20
-
21
- function connect() {
22
- const ws = new WebSocket(WS_URL);
23
- let heartbeatTimer = null;
24
- let joinRef = null;
25
-
26
- ws.on("open", () => {
27
- console.log(`${DIM}Connecting to runlocal.eu...${RESET}`);
28
- joinRef = nextRef();
29
- // Phoenix Channel join message: [join_ref, ref, topic, event, payload]
30
- ws.send(JSON.stringify([joinRef, joinRef, "tunnel:connect", "phx_join", {}]));
31
-
32
- // Heartbeat every 30s
33
- heartbeatTimer = setInterval(() => {
34
- ws.send(
35
- JSON.stringify([null, nextRef(), "phoenix", "heartbeat", {}])
36
- );
37
- }, 30000);
38
- });
39
-
40
- ws.on("message", (data) => {
41
- const msg = JSON.parse(data.toString());
42
- // Phoenix v2 serializer: [join_ref, ref, topic, event, payload]
43
- const [, , topic, event, payload] = msg;
44
-
45
- if (event === "phx_reply" && topic === "tunnel:connect") {
46
- if (payload.status === "ok") {
47
- // Join succeeded, wait for tunnel_created push
48
- } else {
49
- console.error(`${RED}Failed to join: ${JSON.stringify(payload)}${RESET}`);
50
- }
51
- return;
52
- }
53
-
54
- if (event === "tunnel_created") {
55
- console.log("");
56
- console.log(` ${GREEN}${BOLD}Tunnel created!${RESET}`);
57
- console.log(` ${CYAN}${BOLD}${payload.url}${RESET}`);
58
- console.log("");
59
- console.log(` ${DIM}Forwarding to localhost:${PORT}${RESET}`);
60
- console.log(` ${DIM}Press Ctrl+C to stop${RESET}`);
61
- console.log("");
62
- return;
63
- }
64
-
65
- if (event === "http_request") {
66
- handleRequest(ws, joinRef, topic, payload);
67
- return;
68
- }
69
-
70
- if (event === "phx_close") {
71
- console.log(`${YELLOW}Tunnel closed by server${RESET}`);
72
- process.exit(0);
73
- }
74
- });
75
-
76
- ws.on("close", () => {
77
- clearInterval(heartbeatTimer);
78
- console.log(`${YELLOW}Disconnected. Reconnecting in 3s...${RESET}`);
79
- setTimeout(connect, 3000);
80
- });
81
-
82
- ws.on("error", (err) => {
83
- if (err.code === "ECONNREFUSED") {
84
- console.error(
85
- `${RED}Could not connect to runlocal.eu${RESET}`
86
- );
87
- } else {
88
- console.error(`${RED}WebSocket error: ${err.message}${RESET}`);
89
- }
90
- });
91
- }
92
-
93
- function handleRequest(ws, joinRef, topic, payload) {
94
- const { request_id, method, path, query_string, headers, body } = payload;
95
- const fullPath = query_string ? `${path}?${query_string}` : path;
96
-
97
- const timestamp = new Date().toLocaleTimeString();
98
- console.log(
99
- `${DIM}${timestamp}${RESET} ${BOLD}${method}${RESET} ${fullPath}`
100
- );
101
-
102
- const reqHeaders = {};
103
- if (headers) {
104
- for (const [k, v] of headers) {
105
- // Skip host header (proxying to localhost) and accept-encoding
106
- // (compressed responses corrupt during string conversion)
107
- if (k.toLowerCase() !== "host" && k.toLowerCase() !== "accept-encoding") {
108
- reqHeaders[k] = v;
109
- }
110
- }
111
- }
112
-
113
- const options = {
114
- hostname: "127.0.0.1",
115
- port: PORT,
116
- path: fullPath,
117
- method: method,
118
- headers: reqHeaders,
119
- };
120
-
121
- const proxyReq = http.request(options, (proxyRes) => {
122
- const chunks = [];
123
- proxyRes.on("data", (chunk) => chunks.push(chunk));
124
- proxyRes.on("end", () => {
125
- const respBody = Buffer.concat(chunks).toString();
126
- const respHeaders = [];
127
- for (const [k, v] of Object.entries(proxyRes.headers)) {
128
- if (Array.isArray(v)) {
129
- for (const val of v) {
130
- respHeaders.push([k, val]);
131
- }
132
- } else {
133
- respHeaders.push([k, v]);
134
- }
135
- }
136
-
137
- const statusColor =
138
- proxyRes.statusCode < 400 ? GREEN : RED;
139
- console.log(
140
- `${DIM}${timestamp}${RESET} ${statusColor}${proxyRes.statusCode}${RESET} ${fullPath}`
141
- );
142
-
143
- ws.send(
144
- JSON.stringify([
145
- joinRef,
146
- nextRef(),
147
- topic,
148
- "http_response",
149
- {
150
- request_id,
151
- status: proxyRes.statusCode,
152
- headers: respHeaders,
153
- body: respBody,
154
- },
155
- ])
156
- );
157
- });
158
- });
159
-
160
- proxyReq.on("error", (err) => {
161
- console.log(
162
- `${DIM}${timestamp}${RESET} ${RED}ERR${RESET} ${fullPath} — ${err.message}`
163
- );
164
- ws.send(
165
- JSON.stringify([
166
- joinRef,
167
- nextRef(),
168
- topic,
169
- "http_response",
170
- {
171
- request_id,
172
- status: 502,
173
- headers: [["content-type", "text/plain"]],
174
- body: `Could not connect to localhost:${PORT} — ${err.message}`,
175
- },
176
- ])
177
- );
178
- });
179
8
 
180
- if (body && body.length > 0) {
181
- proxyReq.write(body);
182
- }
183
- proxyReq.end();
184
- }
9
+ const { port, host, apiKey, subdomain } = parseArgs(process.argv.slice(2));
185
10
 
186
- console.log(`${BOLD}runlocal${RESET} — expose localhost:${PORT} to the internet`);
187
- connect();
11
+ console.log(`${BOLD}runlocal${RESET} — expose localhost:${port} to the internet`);
12
+ createConnection({ host, port, apiKey, subdomain, WebSocket });
package/lib.js ADDED
@@ -0,0 +1,276 @@
1
+ const http = require("http");
2
+
3
+ const GREEN = "\x1b[32m";
4
+ const CYAN = "\x1b[36m";
5
+ const DIM = "\x1b[2m";
6
+ const BOLD = "\x1b[1m";
7
+ const RESET = "\x1b[0m";
8
+ const RED = "\x1b[31m";
9
+ const YELLOW = "\x1b[33m";
10
+
11
+ const TIPS = [
12
+ "Want a stable URL that never changes? Sign up at runlater.eu",
13
+ "Need request inspection & replay? Use with runlater.eu",
14
+ "Forward webhooks to multiple URLs at once with runlater.eu",
15
+ ];
16
+
17
+ function parseArgs(argv) {
18
+ let port = 3000;
19
+ let host = process.env.RUNLOCAL_HOST || "wss://runlocal.eu";
20
+ let apiKey = process.env.RUNLATER_API_KEY || null;
21
+ let subdomain = null;
22
+ for (let i = 0; i < argv.length; i++) {
23
+ if (argv[i] === "--host" && argv[i + 1]) {
24
+ host = argv[++i];
25
+ } else if (argv[i] === "--api-key" && argv[i + 1]) {
26
+ apiKey = argv[++i];
27
+ } else if (argv[i] === "--subdomain" && argv[i + 1]) {
28
+ subdomain = argv[++i];
29
+ } else if (argv[i] === "--help" || argv[i] === "-h") {
30
+ console.log("Usage: runlocal <port> [options]");
31
+ console.log("");
32
+ console.log("Options:");
33
+ console.log(" --host <url> Server URL (default: wss://runlocal.eu)");
34
+ console.log(" --api-key <key> Runlater API key for stable subdomain");
35
+ console.log(" --subdomain <name> Custom subdomain (Pro plan, requires --api-key)");
36
+ console.log(" --help, -h Show this help");
37
+ console.log("");
38
+ console.log("Environment:");
39
+ console.log(" RUNLOCAL_HOST Same as --host");
40
+ console.log(" RUNLATER_API_KEY Same as --api-key");
41
+ process.exit(0);
42
+ } else if (!argv[i].startsWith("-")) {
43
+ port = parseInt(argv[i], 10);
44
+ }
45
+ }
46
+
47
+ return { port, host, apiKey, subdomain };
48
+ }
49
+
50
+ function filterHeaders(headers) {
51
+ const filtered = {};
52
+ if (headers) {
53
+ for (const [k, v] of headers) {
54
+ if (k.toLowerCase() !== "host" && k.toLowerCase() !== "accept-encoding") {
55
+ filtered[k] = v;
56
+ }
57
+ }
58
+ }
59
+ return filtered;
60
+ }
61
+
62
+ function buildWsUrl(host, apiKey, subdomain) {
63
+ const params = new URLSearchParams({ vsn: "2.0.0" });
64
+ if (apiKey) params.set("api_key", apiKey);
65
+ if (subdomain) params.set("subdomain", subdomain);
66
+ return `${host}/tunnel/websocket?${params.toString()}`;
67
+ }
68
+
69
+ function handleRequest(ws, joinRef, topic, payload, port, nextRef, log) {
70
+ const { request_id, method, path, query_string, headers, body } = payload;
71
+ const fullPath = query_string ? `${path}?${query_string}` : path;
72
+
73
+ const timestamp = new Date().toLocaleTimeString();
74
+ log(
75
+ `${DIM}${timestamp}${RESET} ${BOLD}${method}${RESET} ${fullPath}`
76
+ );
77
+
78
+ const reqHeaders = filterHeaders(headers);
79
+
80
+ const options = {
81
+ hostname: "127.0.0.1",
82
+ port: port,
83
+ path: fullPath,
84
+ method: method,
85
+ headers: reqHeaders,
86
+ };
87
+
88
+ const proxyReq = http.request(options, (proxyRes) => {
89
+ const chunks = [];
90
+ proxyRes.on("data", (chunk) => chunks.push(chunk));
91
+ proxyRes.on("end", () => {
92
+ const respBody = Buffer.concat(chunks).toString();
93
+ const respHeaders = [];
94
+ for (const [k, v] of Object.entries(proxyRes.headers)) {
95
+ if (Array.isArray(v)) {
96
+ for (const val of v) {
97
+ respHeaders.push([k, val]);
98
+ }
99
+ } else {
100
+ respHeaders.push([k, v]);
101
+ }
102
+ }
103
+
104
+ const statusColor =
105
+ proxyRes.statusCode < 400 ? GREEN : RED;
106
+ log(
107
+ `${DIM}${timestamp}${RESET} ${statusColor}${proxyRes.statusCode}${RESET} ${fullPath}`
108
+ );
109
+
110
+ ws.send(
111
+ JSON.stringify([
112
+ joinRef,
113
+ nextRef(),
114
+ topic,
115
+ "http_response",
116
+ {
117
+ request_id,
118
+ status: proxyRes.statusCode,
119
+ headers: respHeaders,
120
+ body: respBody,
121
+ },
122
+ ])
123
+ );
124
+ });
125
+ });
126
+
127
+ proxyReq.on("error", (err) => {
128
+ log(
129
+ `${DIM}${timestamp}${RESET} ${RED}ERR${RESET} ${fullPath} — ${err.message}`
130
+ );
131
+ ws.send(
132
+ JSON.stringify([
133
+ joinRef,
134
+ nextRef(),
135
+ topic,
136
+ "http_response",
137
+ {
138
+ request_id,
139
+ status: 502,
140
+ headers: [["content-type", "text/plain"]],
141
+ body: `Could not connect to localhost:${port} — ${err.message}`,
142
+ },
143
+ ])
144
+ );
145
+ });
146
+
147
+ if (body && body.length > 0) {
148
+ proxyReq.write(body);
149
+ }
150
+ proxyReq.end();
151
+ }
152
+
153
+ function createConnection(options) {
154
+ const {
155
+ host,
156
+ port,
157
+ apiKey,
158
+ subdomain,
159
+ WebSocket,
160
+ onTunnelCreated,
161
+ onClose,
162
+ log = console.log,
163
+ logError = console.error,
164
+ } = options;
165
+ const wsUrl = buildWsUrl(host, apiKey, subdomain);
166
+
167
+ let refCounter = 0;
168
+ const nextRef = () => String(++refCounter);
169
+
170
+ const ws = new WebSocket(wsUrl);
171
+ let heartbeatTimer = null;
172
+ let joinRef = null;
173
+
174
+ ws.on("open", () => {
175
+ const displayHost = host.replace(/^wss?:\/\//, "");
176
+ log(`${DIM}Connecting to ${displayHost}...${RESET}`);
177
+ joinRef = nextRef();
178
+ ws.send(JSON.stringify([joinRef, joinRef, "tunnel:connect", "phx_join", {}]));
179
+
180
+ heartbeatTimer = setInterval(() => {
181
+ ws.send(
182
+ JSON.stringify([null, nextRef(), "phoenix", "heartbeat", {}])
183
+ );
184
+ }, 30000);
185
+ heartbeatTimer.unref();
186
+ });
187
+
188
+ ws.on("message", (data) => {
189
+ const msg = JSON.parse(data.toString());
190
+ const [, , topic, event, payload] = msg;
191
+
192
+ if (event === "phx_reply" && topic === "tunnel:connect") {
193
+ if (payload.status === "ok") {
194
+ // Join succeeded, wait for tunnel_created push
195
+ } else {
196
+ const reason = payload.response && payload.response.reason;
197
+ if (reason === "invalid_api_key") {
198
+ logError(`${RED}Invalid API key. Check your --api-key or RUNLATER_API_KEY${RESET}`);
199
+ } else if (reason === "verification_failed") {
200
+ logError(`${RED}Subdomain verification failed${RESET}`);
201
+ } else if (reason === "verification_unavailable") {
202
+ logError(`${RED}Could not reach runlater.eu to verify API key${RESET}`);
203
+ } else {
204
+ logError(`${RED}Failed to join: ${JSON.stringify(payload)}${RESET}`);
205
+ }
206
+ }
207
+ return;
208
+ }
209
+
210
+ if (event === "tunnel_created") {
211
+ log("");
212
+ log(` ${GREEN}${BOLD}Tunnel created!${RESET}`);
213
+ log(` ${CYAN}${BOLD}${payload.url}${RESET}`);
214
+
215
+ if (payload.fallback) {
216
+ log(` ${YELLOW}${payload.requested_subdomain} is already in use. Using random subdomain.${RESET}`);
217
+ }
218
+
219
+ log("");
220
+ log(` ${DIM}Forwarding to localhost:${port}${RESET}`);
221
+ log(` ${DIM}Press Ctrl+C to stop${RESET}`);
222
+
223
+ // Show tip for users without an API key
224
+ if (!apiKey) {
225
+ log("");
226
+ log(` ${DIM}Tip: ${TIPS[Math.floor(Math.random() * TIPS.length)]}${RESET}`);
227
+ }
228
+
229
+ log("");
230
+ if (onTunnelCreated) onTunnelCreated(payload);
231
+ return;
232
+ }
233
+
234
+ if (event === "http_request") {
235
+ handleRequest(ws, joinRef, topic, payload, port, nextRef, log);
236
+ return;
237
+ }
238
+
239
+ if (event === "phx_close") {
240
+ log(`${YELLOW}Tunnel closed by server${RESET}`);
241
+ if (onClose) {
242
+ onClose();
243
+ } else {
244
+ process.exit(0);
245
+ }
246
+ }
247
+ });
248
+
249
+ ws.on("close", () => {
250
+ clearInterval(heartbeatTimer);
251
+ log(`${YELLOW}Disconnected. Reconnecting in 3s...${RESET}`);
252
+ const reconnectTimer = setTimeout(() => createConnection(options), 3000);
253
+ reconnectTimer.unref();
254
+ });
255
+
256
+ ws.on("error", (err) => {
257
+ if (err.code === "ECONNREFUSED") {
258
+ const displayHost = host.replace(/^wss?:\/\//, "");
259
+ logError(
260
+ `${RED}Could not connect to ${displayHost}${RESET}`
261
+ );
262
+ } else {
263
+ logError(`${RED}WebSocket error: ${err.message}${RESET}`);
264
+ }
265
+ });
266
+
267
+ return { ws, getJoinRef: () => joinRef, nextRef };
268
+ }
269
+
270
+ module.exports = {
271
+ parseArgs,
272
+ filterHeaders,
273
+ buildWsUrl,
274
+ handleRequest,
275
+ createConnection,
276
+ };
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "runlocal",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Expose localhost to the internet via runlocal.eu",
5
5
  "bin": {
6
6
  "runlocal": "./index.js"
7
7
  },
8
- "files": ["index.js"],
8
+ "files": ["index.js", "lib.js"],
9
+ "scripts": {
10
+ "test": "node --test test/*.test.js"
11
+ },
9
12
  "keywords": ["tunnel", "localhost", "ngrok", "dev", "https"],
10
13
  "license": "MIT",
11
14
  "dependencies": {