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.
- package/README.md +92 -0
- package/index.js +4 -179
- package/lib.js +276 -0
- 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
|
|
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
|
-
|
|
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:${
|
|
187
|
-
|
|
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
|
+
"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": {
|