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 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, requires sudo for port 80)
17
- sudo portless proxy
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
- # http://myapp.localhost
21
+ # -> http://myapp.localhost:1355
22
22
  ```
23
23
 
24
- > When run directly in your terminal, portless can auto-start the proxy for you (prompts for sudo once). Via package scripts, start the proxy manually first.
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** 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
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
- # http://myapp.localhost
47
+ # -> http://myapp.localhost:1355
48
48
 
49
49
  # Subdomains
50
50
  portless api.myapp pnpm start
51
- # http://api.myapp.localhost
51
+ # -> http://api.myapp.localhost:1355
52
52
 
53
53
  portless docs.myapp next dev
54
- # http://docs.myapp.localhost
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
- Start the proxy once (`sudo portless proxy`), then just `pnpm dev`.
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 80)"]
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 80| Proxy
78
+ Browser -->|port 1355| Proxy
79
79
  Proxy --> App1
80
80
  Proxy --> App2
81
81
  ```
82
82
 
83
- 1. **Start the proxy** -- runs on port 80 by default in the background (requires sudo once)
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
- sudo portless proxy # Start the proxy (port 80)
101
- sudo portless proxy --port 8080 # Start the proxy on a custom port
102
- sudo portless proxy stop # Stop the proxy
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> # Port for the proxy (default: 80)
106
- # Ports >= 1024 do not require sudo
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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
+ };