runlocal 0.1.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 +186 -0
- package/package.json +14 -0
package/index.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const WebSocket = require("ws");
|
|
4
|
+
const http = require("http");
|
|
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
|
+
const BOLD = "\x1b[1m";
|
|
14
|
+
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, 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, 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 — we're proxying to localhost
|
|
106
|
+
if (k.toLowerCase() !== "host") {
|
|
107
|
+
reqHeaders[k] = v;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const options = {
|
|
113
|
+
hostname: "127.0.0.1",
|
|
114
|
+
port: PORT,
|
|
115
|
+
path: fullPath,
|
|
116
|
+
method: method,
|
|
117
|
+
headers: reqHeaders,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const proxyReq = http.request(options, (proxyRes) => {
|
|
121
|
+
const chunks = [];
|
|
122
|
+
proxyRes.on("data", (chunk) => chunks.push(chunk));
|
|
123
|
+
proxyRes.on("end", () => {
|
|
124
|
+
const respBody = Buffer.concat(chunks).toString();
|
|
125
|
+
const respHeaders = [];
|
|
126
|
+
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
127
|
+
if (Array.isArray(v)) {
|
|
128
|
+
for (const val of v) {
|
|
129
|
+
respHeaders.push([k, val]);
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
respHeaders.push([k, v]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const statusColor =
|
|
137
|
+
proxyRes.statusCode < 400 ? GREEN : RED;
|
|
138
|
+
console.log(
|
|
139
|
+
`${DIM}${timestamp}${RESET} ${statusColor}${proxyRes.statusCode}${RESET} ${fullPath}`
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
ws.send(
|
|
143
|
+
JSON.stringify([
|
|
144
|
+
null,
|
|
145
|
+
nextRef(),
|
|
146
|
+
topic,
|
|
147
|
+
"http_response",
|
|
148
|
+
{
|
|
149
|
+
request_id,
|
|
150
|
+
status: proxyRes.statusCode,
|
|
151
|
+
headers: respHeaders,
|
|
152
|
+
body: respBody,
|
|
153
|
+
},
|
|
154
|
+
])
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
proxyReq.on("error", (err) => {
|
|
160
|
+
console.log(
|
|
161
|
+
`${DIM}${timestamp}${RESET} ${RED}ERR${RESET} ${fullPath} — ${err.message}`
|
|
162
|
+
);
|
|
163
|
+
ws.send(
|
|
164
|
+
JSON.stringify([
|
|
165
|
+
null,
|
|
166
|
+
nextRef(),
|
|
167
|
+
topic,
|
|
168
|
+
"http_response",
|
|
169
|
+
{
|
|
170
|
+
request_id,
|
|
171
|
+
status: 502,
|
|
172
|
+
headers: [["content-type", "text/plain"]],
|
|
173
|
+
body: `Could not connect to localhost:${PORT} — ${err.message}`,
|
|
174
|
+
},
|
|
175
|
+
])
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (body && body.length > 0) {
|
|
180
|
+
proxyReq.write(body);
|
|
181
|
+
}
|
|
182
|
+
proxyReq.end();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
console.log(`${BOLD}runlocal${RESET} — expose localhost:${PORT} to the internet`);
|
|
186
|
+
connect();
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "runlocal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Expose localhost to the internet via runlocal.eu",
|
|
5
|
+
"bin": {
|
|
6
|
+
"runlocal": "./index.js"
|
|
7
|
+
},
|
|
8
|
+
"files": ["index.js"],
|
|
9
|
+
"keywords": ["tunnel", "localhost", "ngrok", "dev", "https"],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"ws": "^8.0.0"
|
|
13
|
+
}
|
|
14
|
+
}
|