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.
Files changed (2) hide show
  1. package/index.js +186 -0
  2. 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
+ }