portless 0.2.2 → 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 +74 -30
- package/dist/chunk-VRBD6YAY.js +412 -0
- package/dist/cli.js +880 -165
- package/dist/index.d.ts +36 -5
- package/dist/index.js +9 -1
- package/package.json +2 -4
- package/dist/chunk-SE7KL62V.js +0 -247
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Replace port numbers with stable, named .localhost URLs. For humans and agents.
|
|
|
4
4
|
|
|
5
5
|
```diff
|
|
6
6
|
- "dev": "next dev" # http://localhost:3000
|
|
7
|
-
+ "dev": "portless myapp next dev" # http://myapp.localhost
|
|
7
|
+
+ "dev": "portless myapp next dev" # http://myapp.localhost:1355
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
## Quick Start
|
|
@@ -13,29 +13,29 @@ Replace port numbers with stable, named .localhost URLs. For humans and agents.
|
|
|
13
13
|
# Install
|
|
14
14
|
npm install -g portless
|
|
15
15
|
|
|
16
|
-
# Start the proxy (once,
|
|
17
|
-
|
|
16
|
+
# Start the proxy (once, no sudo needed)
|
|
17
|
+
portless proxy start
|
|
18
18
|
|
|
19
|
-
# Run your app
|
|
19
|
+
# Run your app (auto-starts the proxy if needed)
|
|
20
20
|
portless myapp next dev
|
|
21
|
-
#
|
|
21
|
+
# -> http://myapp.localhost:1355
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
>
|
|
24
|
+
> The proxy auto-starts when you run an app. You can also start it explicitly with `portless proxy start`.
|
|
25
25
|
|
|
26
26
|
## Why
|
|
27
27
|
|
|
28
28
|
Local dev with port numbers is fragile:
|
|
29
29
|
|
|
30
|
-
- **Port conflicts**
|
|
31
|
-
- **Memorizing ports**
|
|
32
|
-
- **Refreshing shows the wrong app**
|
|
33
|
-
- **Monorepo multiplier**
|
|
34
|
-
- **Agents test the wrong port**
|
|
35
|
-
- **Cookie and storage clashes**
|
|
36
|
-
- **Hardcoded ports in config**
|
|
37
|
-
- **Sharing URLs with teammates**
|
|
38
|
-
- **Browser history is useless**
|
|
30
|
+
- **Port conflicts** -- two projects default to the same port and you get `EADDRINUSE`
|
|
31
|
+
- **Memorizing ports** -- was the API on 3001 or 8080?
|
|
32
|
+
- **Refreshing shows the wrong app** -- stop one server, start another on the same port, and your open tab now shows something completely different
|
|
33
|
+
- **Monorepo multiplier** -- every problem above scales with each service in the repo
|
|
34
|
+
- **Agents test the wrong port** -- AI coding agents guess or hardcode the wrong port, especially in monorepos
|
|
35
|
+
- **Cookie and storage clashes** -- cookies set on `localhost` bleed across apps on different ports; localStorage is lost when ports shift
|
|
36
|
+
- **Hardcoded ports in config** -- CORS allowlists, OAuth redirect URIs, and `.env` files all break when ports change
|
|
37
|
+
- **Sharing URLs with teammates** -- "what port is that on?" becomes a Slack question
|
|
38
|
+
- **Browser history is useless** -- your history for `localhost:3000` is a jumble of unrelated projects
|
|
39
39
|
|
|
40
40
|
Portless fixes all of this by giving each dev server a stable, named `.localhost` URL that both humans and agents can rely on.
|
|
41
41
|
|
|
@@ -44,14 +44,14 @@ Portless fixes all of this by giving each dev server a stable, named `.localhost
|
|
|
44
44
|
```bash
|
|
45
45
|
# Basic
|
|
46
46
|
portless myapp next dev
|
|
47
|
-
#
|
|
47
|
+
# -> http://myapp.localhost:1355
|
|
48
48
|
|
|
49
49
|
# Subdomains
|
|
50
50
|
portless api.myapp pnpm start
|
|
51
|
-
#
|
|
51
|
+
# -> http://api.myapp.localhost:1355
|
|
52
52
|
|
|
53
53
|
portless docs.myapp next dev
|
|
54
|
-
#
|
|
54
|
+
# -> http://docs.myapp.localhost:1355
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
### In package.json
|
|
@@ -64,52 +64,96 @@ portless docs.myapp next dev
|
|
|
64
64
|
}
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
The proxy auto-starts when you run an app. Or start it explicitly: `portless proxy start`.
|
|
68
68
|
|
|
69
69
|
## How It Works
|
|
70
70
|
|
|
71
71
|
```mermaid
|
|
72
72
|
flowchart TD
|
|
73
|
-
Browser["Browser\nmyapp.localhost"]
|
|
74
|
-
Proxy["portless proxy\n(port
|
|
73
|
+
Browser["Browser\nmyapp.localhost:1355"]
|
|
74
|
+
Proxy["portless proxy\n(port 1355)"]
|
|
75
75
|
App1[":4123\nmyapp"]
|
|
76
76
|
App2[":4567\napi"]
|
|
77
77
|
|
|
78
|
-
Browser -->|port
|
|
78
|
+
Browser -->|port 1355| Proxy
|
|
79
79
|
Proxy --> App1
|
|
80
80
|
Proxy --> App2
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
1. **Start the proxy** --
|
|
83
|
+
1. **Start the proxy** -- auto-starts when you run an app, or start explicitly with `portless proxy start`
|
|
84
84
|
2. **Run apps** -- `portless <name> <command>` assigns a free port and registers with the proxy
|
|
85
|
-
3. **Access via URL** -- `http://<name>.localhost` routes through the proxy to your app
|
|
85
|
+
3. **Access via URL** -- `http://<name>.localhost:1355` routes through the proxy to your app
|
|
86
86
|
|
|
87
87
|
Apps are assigned a random port (4000-4999) via the `PORT` environment variable. Most frameworks (Next.js, Vite, etc.) respect this automatically.
|
|
88
88
|
|
|
89
|
+
## HTTP/2 + HTTPS
|
|
90
|
+
|
|
91
|
+
Enable HTTP/2 for faster dev server page loads. Browsers limit HTTP/1.1 to 6 connections per host, which bottlenecks dev servers that serve many unbundled files (Vite, Nuxt, etc.). HTTP/2 multiplexes all requests over a single connection.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Start with HTTPS/2 -- generates certs and trusts them automatically
|
|
95
|
+
portless proxy start --https
|
|
96
|
+
|
|
97
|
+
# First run prompts for sudo once to add the CA to your system trust store.
|
|
98
|
+
# After that, no prompts. No browser warnings.
|
|
99
|
+
|
|
100
|
+
# Make it permanent (add to .bashrc / .zshrc)
|
|
101
|
+
export PORTLESS_HTTPS=1
|
|
102
|
+
portless proxy start # HTTPS by default now
|
|
103
|
+
|
|
104
|
+
# Use your own certs (e.g., from mkcert)
|
|
105
|
+
portless proxy start --cert ./cert.pem --key ./key.pem
|
|
106
|
+
|
|
107
|
+
# If you skipped sudo on first run, trust the CA later
|
|
108
|
+
sudo portless trust
|
|
109
|
+
```
|
|
110
|
+
|
|
89
111
|
## Commands
|
|
90
112
|
|
|
91
113
|
```bash
|
|
92
|
-
portless <name> <cmd> [args...] # Run app at http://<name>.localhost
|
|
114
|
+
portless <name> <cmd> [args...] # Run app at http://<name>.localhost:1355
|
|
93
115
|
portless list # Show active routes
|
|
116
|
+
portless trust # Add local CA to system trust store
|
|
94
117
|
|
|
95
118
|
# Disable portless (run command directly)
|
|
96
119
|
PORTLESS=0 pnpm dev # Bypasses proxy, uses default port
|
|
97
120
|
# Also accepts PORTLESS=skip
|
|
98
121
|
|
|
99
122
|
# Proxy control
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
123
|
+
portless proxy start # Start the proxy (port 1355, daemon)
|
|
124
|
+
portless proxy start --https # Start with HTTP/2 + TLS
|
|
125
|
+
portless proxy start -p 80 # Start on port 80 (requires sudo)
|
|
126
|
+
portless proxy start --foreground # Start in foreground (for debugging)
|
|
127
|
+
portless proxy stop # Stop the proxy
|
|
103
128
|
|
|
104
129
|
# Options
|
|
105
|
-
--port <number>
|
|
106
|
-
# Ports
|
|
130
|
+
-p, --port <number> # Port for the proxy (default: 1355)
|
|
131
|
+
# Ports < 1024 require sudo
|
|
132
|
+
--https # Enable HTTP/2 + TLS with auto-generated certs
|
|
133
|
+
--cert <path> # Use a custom TLS certificate (implies --https)
|
|
134
|
+
--key <path> # Use a custom TLS private key (implies --https)
|
|
135
|
+
--no-tls # Disable HTTPS (overrides PORTLESS_HTTPS)
|
|
136
|
+
--foreground # Run proxy in foreground instead of daemon
|
|
137
|
+
|
|
138
|
+
# Environment variables
|
|
139
|
+
PORTLESS_PORT=<number> # Override the default proxy port
|
|
140
|
+
PORTLESS_HTTPS=1 # Always enable HTTPS
|
|
141
|
+
PORTLESS_STATE_DIR=<path> # Override the state directory
|
|
107
142
|
|
|
108
143
|
# Info
|
|
109
144
|
portless --help # Show help
|
|
110
145
|
portless --version # Show version
|
|
111
146
|
```
|
|
112
147
|
|
|
148
|
+
## State Directory
|
|
149
|
+
|
|
150
|
+
Portless stores its state (routes, PID file, port file) in a directory that depends on the proxy port:
|
|
151
|
+
|
|
152
|
+
- **Port < 1024** (sudo required): `/tmp/portless` -- shared between root and user processes
|
|
153
|
+
- **Port >= 1024** (no sudo): `~/.portless` -- user-scoped, no root involvement
|
|
154
|
+
|
|
155
|
+
Override with the `PORTLESS_STATE_DIR` environment variable if needed.
|
|
156
|
+
|
|
113
157
|
## Requirements
|
|
114
158
|
|
|
115
159
|
- Node.js 20+
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// src/utils.ts
|
|
2
|
+
function isErrnoException(err) {
|
|
3
|
+
return err instanceof Error && "code" in err && typeof err.code === "string";
|
|
4
|
+
}
|
|
5
|
+
function escapeHtml(str) {
|
|
6
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7
|
+
}
|
|
8
|
+
function formatUrl(hostname, proxyPort, tls = false) {
|
|
9
|
+
const proto = tls ? "https" : "http";
|
|
10
|
+
const defaultPort = tls ? 443 : 80;
|
|
11
|
+
return proxyPort === defaultPort ? `${proto}://${hostname}` : `${proto}://${hostname}:${proxyPort}`;
|
|
12
|
+
}
|
|
13
|
+
function parseHostname(input) {
|
|
14
|
+
let hostname = input.trim().replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
|
|
15
|
+
if (!hostname || hostname === ".localhost") {
|
|
16
|
+
throw new Error("Hostname cannot be empty");
|
|
17
|
+
}
|
|
18
|
+
if (!hostname.endsWith(".localhost")) {
|
|
19
|
+
hostname = `${hostname}.localhost`;
|
|
20
|
+
}
|
|
21
|
+
const name = hostname.replace(/\.localhost$/, "");
|
|
22
|
+
if (name.includes("..")) {
|
|
23
|
+
throw new Error(`Invalid hostname "${name}": consecutive dots are not allowed`);
|
|
24
|
+
}
|
|
25
|
+
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Invalid hostname "${name}": must contain only lowercase letters, digits, hyphens, and dots`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return hostname;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/proxy.ts
|
|
34
|
+
import * as http from "http";
|
|
35
|
+
import * as http2 from "http2";
|
|
36
|
+
import * as net from "net";
|
|
37
|
+
var PORTLESS_HEADER = "X-Portless";
|
|
38
|
+
var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
39
|
+
"connection",
|
|
40
|
+
"keep-alive",
|
|
41
|
+
"proxy-connection",
|
|
42
|
+
"transfer-encoding",
|
|
43
|
+
"upgrade"
|
|
44
|
+
]);
|
|
45
|
+
function getRequestHost(req) {
|
|
46
|
+
const authority = req.headers[":authority"];
|
|
47
|
+
if (typeof authority === "string" && authority) return authority;
|
|
48
|
+
return req.headers.host || "";
|
|
49
|
+
}
|
|
50
|
+
function buildForwardedHeaders(req, tls) {
|
|
51
|
+
const headers = {};
|
|
52
|
+
const remoteAddress = req.socket.remoteAddress || "127.0.0.1";
|
|
53
|
+
const proto = tls ? "https" : "http";
|
|
54
|
+
const defaultPort = tls ? "443" : "80";
|
|
55
|
+
const hostHeader = getRequestHost(req);
|
|
56
|
+
headers["x-forwarded-for"] = req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remoteAddress}` : remoteAddress;
|
|
57
|
+
headers["x-forwarded-proto"] = req.headers["x-forwarded-proto"] || proto;
|
|
58
|
+
headers["x-forwarded-host"] = req.headers["x-forwarded-host"] || hostHeader;
|
|
59
|
+
headers["x-forwarded-port"] = req.headers["x-forwarded-port"] || hostHeader.split(":")[1] || defaultPort;
|
|
60
|
+
return headers;
|
|
61
|
+
}
|
|
62
|
+
function createProxyServer(options) {
|
|
63
|
+
const { getRoutes, proxyPort, onError = (msg) => console.error(msg), tls } = options;
|
|
64
|
+
const isTls = !!tls;
|
|
65
|
+
const handleRequest = (req, res) => {
|
|
66
|
+
res.setHeader(PORTLESS_HEADER, "1");
|
|
67
|
+
const routes = getRoutes();
|
|
68
|
+
const host = getRequestHost(req).split(":")[0];
|
|
69
|
+
if (!host) {
|
|
70
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
71
|
+
res.end("Missing Host header");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const route = routes.find((r) => r.hostname === host);
|
|
75
|
+
if (!route) {
|
|
76
|
+
const safeHost = escapeHtml(host);
|
|
77
|
+
res.writeHead(404, { "Content-Type": "text/html" });
|
|
78
|
+
res.end(`
|
|
79
|
+
<html>
|
|
80
|
+
<head><title>portless - Not Found</title></head>
|
|
81
|
+
<body style="font-family: system-ui; padding: 40px; max-width: 600px; margin: 0 auto;">
|
|
82
|
+
<h1>Not Found</h1>
|
|
83
|
+
<p>No app registered for <strong>${safeHost}</strong></p>
|
|
84
|
+
${routes.length > 0 ? `
|
|
85
|
+
<h2>Active apps:</h2>
|
|
86
|
+
<ul>
|
|
87
|
+
${routes.map((r) => `<li><a href="${escapeHtml(formatUrl(r.hostname, proxyPort, isTls))}">${escapeHtml(r.hostname)}</a> - localhost:${escapeHtml(String(r.port))}</li>`).join("")}
|
|
88
|
+
</ul>
|
|
89
|
+
` : "<p><em>No apps running.</em></p>"}
|
|
90
|
+
<p>Start an app with: <code>portless ${safeHost.replace(".localhost", "")} your-command</code></p>
|
|
91
|
+
</body>
|
|
92
|
+
</html>
|
|
93
|
+
`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const forwardedHeaders = buildForwardedHeaders(req, isTls);
|
|
97
|
+
const proxyReqHeaders = { ...req.headers };
|
|
98
|
+
for (const [key, value] of Object.entries(forwardedHeaders)) {
|
|
99
|
+
proxyReqHeaders[key] = value;
|
|
100
|
+
}
|
|
101
|
+
for (const key of Object.keys(proxyReqHeaders)) {
|
|
102
|
+
if (key.startsWith(":")) {
|
|
103
|
+
delete proxyReqHeaders[key];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const proxyReq = http.request(
|
|
107
|
+
{
|
|
108
|
+
hostname: "127.0.0.1",
|
|
109
|
+
port: route.port,
|
|
110
|
+
path: req.url,
|
|
111
|
+
method: req.method,
|
|
112
|
+
headers: proxyReqHeaders
|
|
113
|
+
},
|
|
114
|
+
(proxyRes) => {
|
|
115
|
+
const responseHeaders = { ...proxyRes.headers };
|
|
116
|
+
if (isTls) {
|
|
117
|
+
for (const h of HOP_BY_HOP_HEADERS) {
|
|
118
|
+
delete responseHeaders[h];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
res.writeHead(proxyRes.statusCode || 502, responseHeaders);
|
|
122
|
+
proxyRes.pipe(res);
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
proxyReq.on("error", (err) => {
|
|
126
|
+
onError(`Proxy error for ${getRequestHost(req)}: ${err.message}`);
|
|
127
|
+
if (!res.headersSent) {
|
|
128
|
+
const errWithCode = err;
|
|
129
|
+
const message = errWithCode.code === "ECONNREFUSED" ? "Bad Gateway: the target app is not responding. It may have crashed." : "Bad Gateway: the target app may not be running.";
|
|
130
|
+
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
131
|
+
res.end(message);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
res.on("close", () => {
|
|
135
|
+
if (!proxyReq.destroyed) {
|
|
136
|
+
proxyReq.destroy();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
req.on("error", () => {
|
|
140
|
+
if (!proxyReq.destroyed) {
|
|
141
|
+
proxyReq.destroy();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
req.pipe(proxyReq);
|
|
145
|
+
};
|
|
146
|
+
const handleUpgrade = (req, socket, head) => {
|
|
147
|
+
const routes = getRoutes();
|
|
148
|
+
const host = getRequestHost(req).split(":")[0];
|
|
149
|
+
const route = routes.find((r) => r.hostname === host);
|
|
150
|
+
if (!route) {
|
|
151
|
+
socket.destroy();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const forwardedHeaders = buildForwardedHeaders(req, isTls);
|
|
155
|
+
const proxyReqHeaders = { ...req.headers };
|
|
156
|
+
for (const [key, value] of Object.entries(forwardedHeaders)) {
|
|
157
|
+
proxyReqHeaders[key] = value;
|
|
158
|
+
}
|
|
159
|
+
for (const key of Object.keys(proxyReqHeaders)) {
|
|
160
|
+
if (key.startsWith(":")) {
|
|
161
|
+
delete proxyReqHeaders[key];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const proxyReq = http.request({
|
|
165
|
+
hostname: "127.0.0.1",
|
|
166
|
+
port: route.port,
|
|
167
|
+
path: req.url,
|
|
168
|
+
method: req.method,
|
|
169
|
+
headers: proxyReqHeaders
|
|
170
|
+
});
|
|
171
|
+
proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
|
|
172
|
+
let response = `HTTP/1.1 101 Switching Protocols\r
|
|
173
|
+
`;
|
|
174
|
+
for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) {
|
|
175
|
+
response += `${proxyRes.rawHeaders[i]}: ${proxyRes.rawHeaders[i + 1]}\r
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
response += "\r\n";
|
|
179
|
+
socket.write(response);
|
|
180
|
+
if (proxyHead.length > 0) {
|
|
181
|
+
socket.write(proxyHead);
|
|
182
|
+
}
|
|
183
|
+
proxySocket.pipe(socket);
|
|
184
|
+
socket.pipe(proxySocket);
|
|
185
|
+
proxySocket.on("error", () => socket.destroy());
|
|
186
|
+
socket.on("error", () => proxySocket.destroy());
|
|
187
|
+
});
|
|
188
|
+
proxyReq.on("error", (err) => {
|
|
189
|
+
onError(`WebSocket proxy error for ${getRequestHost(req)}: ${err.message}`);
|
|
190
|
+
socket.destroy();
|
|
191
|
+
});
|
|
192
|
+
proxyReq.on("response", (res) => {
|
|
193
|
+
if (!socket.destroyed) {
|
|
194
|
+
let response = `HTTP/1.1 ${res.statusCode} ${res.statusMessage}\r
|
|
195
|
+
`;
|
|
196
|
+
for (let i = 0; i < res.rawHeaders.length; i += 2) {
|
|
197
|
+
response += `${res.rawHeaders[i]}: ${res.rawHeaders[i + 1]}\r
|
|
198
|
+
`;
|
|
199
|
+
}
|
|
200
|
+
response += "\r\n";
|
|
201
|
+
socket.write(response);
|
|
202
|
+
res.pipe(socket);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
if (head.length > 0) {
|
|
206
|
+
proxyReq.write(head);
|
|
207
|
+
}
|
|
208
|
+
proxyReq.end();
|
|
209
|
+
};
|
|
210
|
+
if (tls) {
|
|
211
|
+
const h2Server = http2.createSecureServer({
|
|
212
|
+
cert: tls.cert,
|
|
213
|
+
key: tls.key,
|
|
214
|
+
allowHTTP1: true,
|
|
215
|
+
...tls.SNICallback ? { SNICallback: tls.SNICallback } : {}
|
|
216
|
+
});
|
|
217
|
+
h2Server.on("request", (req, res) => {
|
|
218
|
+
handleRequest(req, res);
|
|
219
|
+
});
|
|
220
|
+
h2Server.on("upgrade", (req, socket, head) => {
|
|
221
|
+
handleUpgrade(req, socket, head);
|
|
222
|
+
});
|
|
223
|
+
const plainServer = http.createServer(handleRequest);
|
|
224
|
+
plainServer.on("upgrade", handleUpgrade);
|
|
225
|
+
const wrapper = net.createServer((socket) => {
|
|
226
|
+
socket.once("readable", () => {
|
|
227
|
+
const buf = socket.read(1);
|
|
228
|
+
if (!buf) {
|
|
229
|
+
socket.destroy();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
socket.unshift(buf);
|
|
233
|
+
if (buf[0] === 22) {
|
|
234
|
+
h2Server.emit("connection", socket);
|
|
235
|
+
} else {
|
|
236
|
+
plainServer.emit("connection", socket);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
const origClose = wrapper.close.bind(wrapper);
|
|
241
|
+
wrapper.close = function(cb) {
|
|
242
|
+
h2Server.close();
|
|
243
|
+
plainServer.close();
|
|
244
|
+
return origClose(cb);
|
|
245
|
+
};
|
|
246
|
+
return wrapper;
|
|
247
|
+
}
|
|
248
|
+
const httpServer = http.createServer(handleRequest);
|
|
249
|
+
httpServer.on("upgrade", handleUpgrade);
|
|
250
|
+
return httpServer;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/routes.ts
|
|
254
|
+
import * as fs from "fs";
|
|
255
|
+
import * as path from "path";
|
|
256
|
+
var STALE_LOCK_THRESHOLD_MS = 1e4;
|
|
257
|
+
var LOCK_MAX_RETRIES = 20;
|
|
258
|
+
var LOCK_RETRY_DELAY_MS = 50;
|
|
259
|
+
var FILE_MODE = 420;
|
|
260
|
+
var DIR_MODE = 493;
|
|
261
|
+
function isValidRoute(value) {
|
|
262
|
+
return typeof value === "object" && value !== null && typeof value.hostname === "string" && typeof value.port === "number" && typeof value.pid === "number";
|
|
263
|
+
}
|
|
264
|
+
var RouteStore = class _RouteStore {
|
|
265
|
+
/** The state directory path. */
|
|
266
|
+
dir;
|
|
267
|
+
routesPath;
|
|
268
|
+
lockPath;
|
|
269
|
+
pidPath;
|
|
270
|
+
portFilePath;
|
|
271
|
+
onWarning;
|
|
272
|
+
constructor(dir, options) {
|
|
273
|
+
this.dir = dir;
|
|
274
|
+
this.routesPath = path.join(dir, "routes.json");
|
|
275
|
+
this.lockPath = path.join(dir, "routes.lock");
|
|
276
|
+
this.pidPath = path.join(dir, "proxy.pid");
|
|
277
|
+
this.portFilePath = path.join(dir, "proxy.port");
|
|
278
|
+
this.onWarning = options?.onWarning;
|
|
279
|
+
}
|
|
280
|
+
ensureDir() {
|
|
281
|
+
if (!fs.existsSync(this.dir)) {
|
|
282
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: DIR_MODE });
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
fs.chmodSync(this.dir, DIR_MODE);
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
getRoutesPath() {
|
|
290
|
+
return this.routesPath;
|
|
291
|
+
}
|
|
292
|
+
// -- Locking ---------------------------------------------------------------
|
|
293
|
+
static sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
294
|
+
syncSleep(ms) {
|
|
295
|
+
Atomics.wait(_RouteStore.sleepBuffer, 0, 0, ms);
|
|
296
|
+
}
|
|
297
|
+
acquireLock(maxRetries = LOCK_MAX_RETRIES, retryDelayMs = LOCK_RETRY_DELAY_MS) {
|
|
298
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
299
|
+
try {
|
|
300
|
+
fs.mkdirSync(this.lockPath);
|
|
301
|
+
return true;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
if (isErrnoException(err) && err.code === "EEXIST") {
|
|
304
|
+
try {
|
|
305
|
+
const stat = fs.statSync(this.lockPath);
|
|
306
|
+
if (Date.now() - stat.mtimeMs > STALE_LOCK_THRESHOLD_MS) {
|
|
307
|
+
fs.rmSync(this.lockPath, { recursive: true });
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
this.syncSleep(retryDelayMs);
|
|
314
|
+
} else {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
releaseLock() {
|
|
322
|
+
try {
|
|
323
|
+
fs.rmSync(this.lockPath, { recursive: true });
|
|
324
|
+
} catch {
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// -- Route I/O -------------------------------------------------------------
|
|
328
|
+
isProcessAlive(pid) {
|
|
329
|
+
try {
|
|
330
|
+
process.kill(pid, 0);
|
|
331
|
+
return true;
|
|
332
|
+
} catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Load routes from disk, filtering out stale entries whose owning process
|
|
338
|
+
* is no longer alive. Stale-route cleanup is only persisted when the caller
|
|
339
|
+
* already holds the lock (i.e. inside addRoute/removeRoute) to avoid
|
|
340
|
+
* unprotected concurrent writes.
|
|
341
|
+
*/
|
|
342
|
+
loadRoutes(persistCleanup = false) {
|
|
343
|
+
if (!fs.existsSync(this.routesPath)) {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const raw = fs.readFileSync(this.routesPath, "utf-8");
|
|
348
|
+
let parsed;
|
|
349
|
+
try {
|
|
350
|
+
parsed = JSON.parse(raw);
|
|
351
|
+
} catch {
|
|
352
|
+
this.onWarning?.(`Corrupted routes file (invalid JSON): ${this.routesPath}`);
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
if (!Array.isArray(parsed)) {
|
|
356
|
+
this.onWarning?.(`Corrupted routes file (expected array): ${this.routesPath}`);
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
const routes = parsed.filter(isValidRoute);
|
|
360
|
+
const alive = routes.filter((r) => this.isProcessAlive(r.pid));
|
|
361
|
+
if (persistCleanup && alive.length !== routes.length) {
|
|
362
|
+
try {
|
|
363
|
+
fs.writeFileSync(this.routesPath, JSON.stringify(alive, null, 2), { mode: FILE_MODE });
|
|
364
|
+
} catch {
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return alive;
|
|
368
|
+
} catch {
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
saveRoutes(routes) {
|
|
373
|
+
fs.writeFileSync(this.routesPath, JSON.stringify(routes, null, 2), { mode: FILE_MODE });
|
|
374
|
+
}
|
|
375
|
+
addRoute(hostname, port, pid) {
|
|
376
|
+
this.ensureDir();
|
|
377
|
+
if (!this.acquireLock()) {
|
|
378
|
+
throw new Error("Failed to acquire route lock");
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
const routes = this.loadRoutes(true).filter((r) => r.hostname !== hostname);
|
|
382
|
+
routes.push({ hostname, port, pid });
|
|
383
|
+
this.saveRoutes(routes);
|
|
384
|
+
} finally {
|
|
385
|
+
this.releaseLock();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
removeRoute(hostname) {
|
|
389
|
+
this.ensureDir();
|
|
390
|
+
if (!this.acquireLock()) {
|
|
391
|
+
throw new Error("Failed to acquire route lock");
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
const routes = this.loadRoutes(true).filter((r) => r.hostname !== hostname);
|
|
395
|
+
this.saveRoutes(routes);
|
|
396
|
+
} finally {
|
|
397
|
+
this.releaseLock();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
export {
|
|
403
|
+
isErrnoException,
|
|
404
|
+
escapeHtml,
|
|
405
|
+
formatUrl,
|
|
406
|
+
parseHostname,
|
|
407
|
+
PORTLESS_HEADER,
|
|
408
|
+
createProxyServer,
|
|
409
|
+
FILE_MODE,
|
|
410
|
+
DIR_MODE,
|
|
411
|
+
RouteStore
|
|
412
|
+
};
|