portless 0.2.2 → 0.3.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,32 +64,32 @@ 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
89
  ## Commands
90
90
 
91
91
  ```bash
92
- portless <name> <cmd> [args...] # Run app at http://<name>.localhost
92
+ portless <name> <cmd> [args...] # Run app at http://<name>.localhost:1355
93
93
  portless list # Show active routes
94
94
 
95
95
  # Disable portless (run command directly)
@@ -97,19 +97,34 @@ PORTLESS=0 pnpm dev # Bypasses proxy, uses default port
97
97
  # Also accepts PORTLESS=skip
98
98
 
99
99
  # 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
100
+ portless proxy start # Start the proxy (port 1355, daemon)
101
+ portless proxy start -p 80 # Start on port 80 (requires sudo)
102
+ portless proxy start --foreground # Start in foreground (for debugging)
103
+ portless proxy stop # Stop the proxy
103
104
 
104
105
  # Options
105
- --port <number> # Port for the proxy (default: 80)
106
- # Ports >= 1024 do not require sudo
106
+ -p, --port <number> # Port for the proxy (default: 1355)
107
+ # Ports < 1024 require sudo
108
+ --foreground # Run proxy in foreground instead of daemon
109
+
110
+ # Environment variables
111
+ PORTLESS_PORT=<number> # Override the default proxy port
112
+ PORTLESS_STATE_DIR=<path> # Override the state directory
107
113
 
108
114
  # Info
109
115
  portless --help # Show help
110
116
  portless --version # Show version
111
117
  ```
112
118
 
119
+ ## State Directory
120
+
121
+ Portless stores its state (routes, PID file, port file) in a directory that depends on the proxy port:
122
+
123
+ - **Port < 1024** (sudo required): `/tmp/portless` -- shared between root and user processes
124
+ - **Port >= 1024** (no sudo): `~/.portless` -- user-scoped, no root involvement
125
+
126
+ Override with the `PORTLESS_STATE_DIR` environment variable if needed.
127
+
113
128
  ## Requirements
114
129
 
115
130
  - Node.js 20+
@@ -1,10 +1,13 @@
1
1
  // src/utils.ts
2
2
  function isErrnoException(err) {
3
- return err instanceof Error && "code" in err;
3
+ return err instanceof Error && "code" in err && typeof err.code === "string";
4
4
  }
5
5
  function escapeHtml(str) {
6
6
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
7
7
  }
8
+ function formatUrl(hostname, proxyPort) {
9
+ return proxyPort === 80 ? `http://${hostname}` : `http://${hostname}:${proxyPort}`;
10
+ }
8
11
  function parseHostname(input) {
9
12
  let hostname = input.trim().replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
10
13
  if (!hostname || hostname === ".localhost") {
@@ -27,23 +30,22 @@ function parseHostname(input) {
27
30
 
28
31
  // src/proxy.ts
29
32
  import * as http from "http";
30
- import httpProxy from "http-proxy";
33
+ var PORTLESS_HEADER = "X-Portless";
34
+ function buildForwardedHeaders(req) {
35
+ const headers = {};
36
+ const remoteAddress = req.socket.remoteAddress || "127.0.0.1";
37
+ const proto = "http";
38
+ const hostHeader = req.headers.host || "";
39
+ headers["x-forwarded-for"] = req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remoteAddress}` : remoteAddress;
40
+ headers["x-forwarded-proto"] = req.headers["x-forwarded-proto"] || proto;
41
+ headers["x-forwarded-host"] = req.headers["x-forwarded-host"] || hostHeader;
42
+ headers["x-forwarded-port"] = req.headers["x-forwarded-port"] || hostHeader.split(":")[1] || "80";
43
+ return headers;
44
+ }
31
45
  function createProxyServer(options) {
32
- const { getRoutes, onError = (msg) => console.error(msg) } = options;
33
- const proxy = httpProxy.createProxyServer({});
34
- proxy.on("error", (err, req, res) => {
35
- onError(`Proxy error for ${req.headers.host}: ${err.message}`);
36
- if (res && "writeHead" in res) {
37
- const serverRes = res;
38
- if (!serverRes.headersSent) {
39
- const errWithCode = err;
40
- 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.";
41
- serverRes.writeHead(502, { "Content-Type": "text/plain" });
42
- serverRes.end(message);
43
- }
44
- }
45
- });
46
+ const { getRoutes, proxyPort, onError = (msg) => console.error(msg) } = options;
46
47
  const handleRequest = (req, res) => {
48
+ res.setHeader(PORTLESS_HEADER, "1");
47
49
  const routes = getRoutes();
48
50
  const host = (req.headers.host || "").split(":")[0];
49
51
  if (!host) {
@@ -64,7 +66,7 @@ function createProxyServer(options) {
64
66
  ${routes.length > 0 ? `
65
67
  <h2>Active apps:</h2>
66
68
  <ul>
67
- ${routes.map((r) => `<li><a href="http://${escapeHtml(r.hostname)}">${escapeHtml(r.hostname)}</a> - localhost:${escapeHtml(String(r.port))}</li>`).join("")}
69
+ ${routes.map((r) => `<li><a href="${escapeHtml(formatUrl(r.hostname, proxyPort))}">${escapeHtml(r.hostname)}</a> - localhost:${escapeHtml(String(r.port))}</li>`).join("")}
68
70
  </ul>
69
71
  ` : "<p><em>No apps running.</em></p>"}
70
72
  <p>Start an app with: <code>portless ${safeHost.replace(".localhost", "")} your-command</code></p>
@@ -73,10 +75,44 @@ function createProxyServer(options) {
73
75
  `);
74
76
  return;
75
77
  }
76
- proxy.web(req, res, {
77
- target: `http://127.0.0.1:${route.port}`,
78
- xfwd: true
78
+ const forwardedHeaders = buildForwardedHeaders(req);
79
+ const proxyReqHeaders = { ...req.headers };
80
+ for (const [key, value] of Object.entries(forwardedHeaders)) {
81
+ proxyReqHeaders[key] = value;
82
+ }
83
+ const proxyReq = http.request(
84
+ {
85
+ hostname: "127.0.0.1",
86
+ port: route.port,
87
+ path: req.url,
88
+ method: req.method,
89
+ headers: proxyReqHeaders
90
+ },
91
+ (proxyRes) => {
92
+ res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
93
+ proxyRes.pipe(res);
94
+ }
95
+ );
96
+ proxyReq.on("error", (err) => {
97
+ onError(`Proxy error for ${req.headers.host}: ${err.message}`);
98
+ if (!res.headersSent) {
99
+ const errWithCode = err;
100
+ 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.";
101
+ res.writeHead(502, { "Content-Type": "text/plain" });
102
+ res.end(message);
103
+ }
104
+ });
105
+ res.on("close", () => {
106
+ if (!proxyReq.destroyed) {
107
+ proxyReq.destroy();
108
+ }
79
109
  });
110
+ req.on("error", () => {
111
+ if (!proxyReq.destroyed) {
112
+ proxyReq.destroy();
113
+ }
114
+ });
115
+ req.pipe(proxyReq);
80
116
  };
81
117
  const handleUpgrade = (req, socket, head) => {
82
118
  const routes = getRoutes();
@@ -86,10 +122,56 @@ function createProxyServer(options) {
86
122
  socket.destroy();
87
123
  return;
88
124
  }
89
- proxy.ws(req, socket, head, {
90
- target: `http://127.0.0.1:${route.port}`,
91
- xfwd: true
125
+ const forwardedHeaders = buildForwardedHeaders(req);
126
+ const proxyReqHeaders = { ...req.headers };
127
+ for (const [key, value] of Object.entries(forwardedHeaders)) {
128
+ proxyReqHeaders[key] = value;
129
+ }
130
+ const proxyReq = http.request({
131
+ hostname: "127.0.0.1",
132
+ port: route.port,
133
+ path: req.url,
134
+ method: req.method,
135
+ headers: proxyReqHeaders
92
136
  });
137
+ proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
138
+ let response = `HTTP/1.1 101 Switching Protocols\r
139
+ `;
140
+ for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) {
141
+ response += `${proxyRes.rawHeaders[i]}: ${proxyRes.rawHeaders[i + 1]}\r
142
+ `;
143
+ }
144
+ response += "\r\n";
145
+ socket.write(response);
146
+ if (proxyHead.length > 0) {
147
+ socket.write(proxyHead);
148
+ }
149
+ proxySocket.pipe(socket);
150
+ socket.pipe(proxySocket);
151
+ proxySocket.on("error", () => socket.destroy());
152
+ socket.on("error", () => proxySocket.destroy());
153
+ });
154
+ proxyReq.on("error", (err) => {
155
+ onError(`WebSocket proxy error for ${req.headers.host}: ${err.message}`);
156
+ socket.destroy();
157
+ });
158
+ proxyReq.on("response", (res) => {
159
+ if (!socket.destroyed) {
160
+ let response = `HTTP/1.1 ${res.statusCode} ${res.statusMessage}\r
161
+ `;
162
+ for (let i = 0; i < res.rawHeaders.length; i += 2) {
163
+ response += `${res.rawHeaders[i]}: ${res.rawHeaders[i + 1]}\r
164
+ `;
165
+ }
166
+ response += "\r\n";
167
+ socket.write(response);
168
+ res.pipe(socket);
169
+ }
170
+ });
171
+ if (head.length > 0) {
172
+ proxyReq.write(head);
173
+ }
174
+ proxyReq.end();
93
175
  };
94
176
  const httpServer = http.createServer(handleRequest);
95
177
  httpServer.on("upgrade", handleUpgrade);
@@ -99,6 +181,11 @@ function createProxyServer(options) {
99
181
  // src/routes.ts
100
182
  import * as fs from "fs";
101
183
  import * as path from "path";
184
+ var STALE_LOCK_THRESHOLD_MS = 1e4;
185
+ var LOCK_MAX_RETRIES = 20;
186
+ var LOCK_RETRY_DELAY_MS = 50;
187
+ var FILE_MODE = 420;
188
+ var DIR_MODE = 493;
102
189
  function isValidRoute(value) {
103
190
  return typeof value === "object" && value !== null && typeof value.hostname === "string" && typeof value.port === "number" && typeof value.pid === "number";
104
191
  }
@@ -107,20 +194,22 @@ var RouteStore = class _RouteStore {
107
194
  routesPath;
108
195
  lockPath;
109
196
  pidPath;
197
+ portFilePath;
110
198
  onWarning;
111
199
  constructor(dir, options) {
112
200
  this.dir = dir;
113
201
  this.routesPath = path.join(dir, "routes.json");
114
202
  this.lockPath = path.join(dir, "routes.lock");
115
203
  this.pidPath = path.join(dir, "proxy.pid");
204
+ this.portFilePath = path.join(dir, "proxy.port");
116
205
  this.onWarning = options?.onWarning;
117
206
  }
118
207
  ensureDir() {
119
208
  if (!fs.existsSync(this.dir)) {
120
- fs.mkdirSync(this.dir, { recursive: true, mode: 493 });
209
+ fs.mkdirSync(this.dir, { recursive: true, mode: DIR_MODE });
121
210
  }
122
211
  try {
123
- fs.chmodSync(this.dir, 493);
212
+ fs.chmodSync(this.dir, DIR_MODE);
124
213
  } catch {
125
214
  }
126
215
  }
@@ -132,7 +221,7 @@ var RouteStore = class _RouteStore {
132
221
  syncSleep(ms) {
133
222
  Atomics.wait(_RouteStore.sleepBuffer, 0, 0, ms);
134
223
  }
135
- acquireLock(maxRetries = 20, retryDelayMs = 50) {
224
+ acquireLock(maxRetries = LOCK_MAX_RETRIES, retryDelayMs = LOCK_RETRY_DELAY_MS) {
136
225
  for (let i = 0; i < maxRetries; i++) {
137
226
  try {
138
227
  fs.mkdirSync(this.lockPath);
@@ -141,7 +230,7 @@ var RouteStore = class _RouteStore {
141
230
  if (isErrnoException(err) && err.code === "EEXIST") {
142
231
  try {
143
232
  const stat = fs.statSync(this.lockPath);
144
- if (Date.now() - stat.mtimeMs > 1e4) {
233
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_THRESHOLD_MS) {
145
234
  fs.rmSync(this.lockPath, { recursive: true });
146
235
  continue;
147
236
  }
@@ -198,7 +287,7 @@ var RouteStore = class _RouteStore {
198
287
  const alive = routes.filter((r) => this.isProcessAlive(r.pid));
199
288
  if (persistCleanup && alive.length !== routes.length) {
200
289
  try {
201
- fs.writeFileSync(this.routesPath, JSON.stringify(alive, null, 2), { mode: 420 });
290
+ fs.writeFileSync(this.routesPath, JSON.stringify(alive, null, 2), { mode: FILE_MODE });
202
291
  } catch {
203
292
  }
204
293
  }
@@ -208,8 +297,7 @@ var RouteStore = class _RouteStore {
208
297
  }
209
298
  }
210
299
  saveRoutes(routes) {
211
- this.ensureDir();
212
- fs.writeFileSync(this.routesPath, JSON.stringify(routes, null, 2), { mode: 420 });
300
+ fs.writeFileSync(this.routesPath, JSON.stringify(routes, null, 2), { mode: FILE_MODE });
213
301
  }
214
302
  addRoute(hostname, port, pid) {
215
303
  this.ensureDir();
@@ -241,7 +329,11 @@ var RouteStore = class _RouteStore {
241
329
  export {
242
330
  isErrnoException,
243
331
  escapeHtml,
332
+ formatUrl,
244
333
  parseHostname,
334
+ PORTLESS_HEADER,
245
335
  createProxyServer,
336
+ FILE_MODE,
337
+ DIR_MODE,
246
338
  RouteStore
247
339
  };
package/dist/cli.js CHANGED
@@ -1,21 +1,89 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ FILE_MODE,
4
+ PORTLESS_HEADER,
3
5
  RouteStore,
4
6
  createProxyServer,
7
+ formatUrl,
5
8
  isErrnoException,
6
9
  parseHostname
7
- } from "./chunk-SE7KL62V.js";
10
+ } from "./chunk-Y5OVKUR4.js";
8
11
 
9
12
  // src/cli.ts
10
13
  import chalk from "chalk";
11
- import * as fs from "fs";
12
- import * as path from "path";
13
- import * as readline from "readline";
14
- import { execSync, spawn, spawnSync } from "child_process";
14
+ import * as fs2 from "fs";
15
+ import * as path2 from "path";
16
+ import { spawn as spawn2, spawnSync } from "child_process";
15
17
 
16
18
  // src/cli-utils.ts
19
+ import * as fs from "fs";
20
+ import * as http from "http";
17
21
  import * as net from "net";
18
- async function findFreePort(minPort = 4e3, maxPort = 4999) {
22
+ import * as os from "os";
23
+ import * as path from "path";
24
+ import * as readline from "readline";
25
+ import { execSync, spawn } from "child_process";
26
+ var DEFAULT_PROXY_PORT = 1355;
27
+ var PRIVILEGED_PORT_THRESHOLD = 1024;
28
+ var SYSTEM_STATE_DIR = "/tmp/portless";
29
+ var USER_STATE_DIR = path.join(os.homedir(), ".portless");
30
+ var MIN_APP_PORT = 4e3;
31
+ var MAX_APP_PORT = 4999;
32
+ var RANDOM_PORT_ATTEMPTS = 50;
33
+ var SOCKET_TIMEOUT_MS = 500;
34
+ var LSOF_TIMEOUT_MS = 5e3;
35
+ var WAIT_FOR_PROXY_MAX_ATTEMPTS = 20;
36
+ var WAIT_FOR_PROXY_INTERVAL_MS = 250;
37
+ var SIGNAL_CODES = {
38
+ SIGHUP: 1,
39
+ SIGINT: 2,
40
+ SIGQUIT: 3,
41
+ SIGABRT: 6,
42
+ SIGKILL: 9,
43
+ SIGTERM: 15
44
+ };
45
+ function getDefaultPort() {
46
+ const envPort = process.env.PORTLESS_PORT;
47
+ if (envPort) {
48
+ const port = parseInt(envPort, 10);
49
+ if (!isNaN(port) && port >= 1 && port <= 65535) return port;
50
+ }
51
+ return DEFAULT_PROXY_PORT;
52
+ }
53
+ function resolveStateDir(port) {
54
+ if (process.env.PORTLESS_STATE_DIR) return process.env.PORTLESS_STATE_DIR;
55
+ return port < PRIVILEGED_PORT_THRESHOLD ? SYSTEM_STATE_DIR : USER_STATE_DIR;
56
+ }
57
+ function readPortFromDir(dir) {
58
+ try {
59
+ const raw = fs.readFileSync(path.join(dir, "proxy.port"), "utf-8").trim();
60
+ const port = parseInt(raw, 10);
61
+ return isNaN(port) ? null : port;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+ async function discoverState() {
67
+ if (process.env.PORTLESS_STATE_DIR) {
68
+ const dir = process.env.PORTLESS_STATE_DIR;
69
+ const port = readPortFromDir(dir) ?? getDefaultPort();
70
+ return { dir, port };
71
+ }
72
+ const userPort = readPortFromDir(USER_STATE_DIR);
73
+ if (userPort !== null && await isProxyRunning(userPort)) {
74
+ return { dir: USER_STATE_DIR, port: userPort };
75
+ }
76
+ const systemPort = readPortFromDir(SYSTEM_STATE_DIR);
77
+ if (systemPort !== null && await isProxyRunning(systemPort)) {
78
+ return { dir: SYSTEM_STATE_DIR, port: systemPort };
79
+ }
80
+ const defaultPort = getDefaultPort();
81
+ return { dir: resolveStateDir(defaultPort), port: defaultPort };
82
+ }
83
+ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
84
+ if (minPort > maxPort) {
85
+ throw new Error(`minPort (${minPort}) must be <= maxPort (${maxPort})`);
86
+ }
19
87
  const tryPort = (port) => {
20
88
  return new Promise((resolve) => {
21
89
  const server = net.createServer();
@@ -25,7 +93,7 @@ async function findFreePort(minPort = 4e3, maxPort = 4999) {
25
93
  server.on("error", () => resolve(false));
26
94
  });
27
95
  };
28
- for (let i = 0; i < 50; i++) {
96
+ for (let i = 0; i < RANDOM_PORT_ATTEMPTS; i++) {
29
97
  const port = minPort + Math.floor(Math.random() * (maxPort - minPort + 1));
30
98
  if (await tryPort(port)) {
31
99
  return port;
@@ -38,58 +106,34 @@ async function findFreePort(minPort = 4e3, maxPort = 4999) {
38
106
  }
39
107
  throw new Error(`No free port found in range ${minPort}-${maxPort}`);
40
108
  }
41
- function isProxyRunning(port = 80) {
109
+ function isProxyRunning(port) {
42
110
  return new Promise((resolve) => {
43
- const socket = new net.Socket();
44
- socket.setTimeout(500);
45
- socket.on("connect", () => {
46
- socket.destroy();
47
- resolve(true);
48
- });
49
- socket.on("error", () => resolve(false));
50
- socket.on("timeout", () => {
51
- socket.destroy();
111
+ const req = http.request(
112
+ {
113
+ hostname: "127.0.0.1",
114
+ port,
115
+ path: "/",
116
+ method: "HEAD",
117
+ timeout: SOCKET_TIMEOUT_MS
118
+ },
119
+ (res) => {
120
+ res.resume();
121
+ resolve(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
122
+ }
123
+ );
124
+ req.on("error", () => resolve(false));
125
+ req.on("timeout", () => {
126
+ req.destroy();
52
127
  resolve(false);
53
128
  });
54
- socket.connect(port, "127.0.0.1");
129
+ req.end();
55
130
  });
56
131
  }
57
-
58
- // src/cli.ts
59
- var SIGNAL_CODES = { SIGINT: 2, SIGTERM: 15 };
60
- function prompt(question) {
61
- const rl = readline.createInterface({
62
- input: process.stdin,
63
- output: process.stdout
64
- });
65
- return new Promise((resolve) => {
66
- rl.on("close", () => resolve(""));
67
- rl.question(question, (answer) => {
68
- rl.close();
69
- resolve(answer.trim().toLowerCase());
70
- });
71
- });
72
- }
73
- var PORTLESS_DIR = "/tmp/portless";
74
- var PROXY_PORT_PATH = path.join(PORTLESS_DIR, "proxy.port");
75
- var DEFAULT_PROXY_PORT = 80;
76
- var store = new RouteStore(PORTLESS_DIR, {
77
- onWarning: (msg) => console.warn(chalk.yellow(msg))
78
- });
79
- function readProxyPort() {
80
- try {
81
- const raw = fs.readFileSync(PROXY_PORT_PATH, "utf-8").trim();
82
- const port = parseInt(raw, 10);
83
- return isNaN(port) ? DEFAULT_PROXY_PORT : port;
84
- } catch {
85
- return DEFAULT_PROXY_PORT;
86
- }
87
- }
88
132
  function findPidOnPort(port) {
89
133
  try {
90
134
  const output = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
91
135
  encoding: "utf-8",
92
- timeout: 5e3
136
+ timeout: LSOF_TIMEOUT_MS
93
137
  });
94
138
  const pid = parseInt(output.trim().split("\n")[0], 10);
95
139
  return isNaN(pid) ? null : pid;
@@ -97,8 +141,7 @@ function findPidOnPort(port) {
97
141
  return null;
98
142
  }
99
143
  }
100
- async function waitForProxy(proxyPort, maxAttempts = 20, intervalMs = 250) {
101
- const port = proxyPort ?? readProxyPort();
144
+ async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS) {
102
145
  for (let i = 0; i < maxAttempts; i++) {
103
146
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
104
147
  if (await isProxyRunning(port)) {
@@ -113,45 +156,75 @@ function spawnCommand(commandArgs, options) {
113
156
  env: options?.env
114
157
  });
115
158
  let exiting = false;
159
+ const cleanup = () => {
160
+ process.removeListener("SIGINT", onSigInt);
161
+ process.removeListener("SIGTERM", onSigTerm);
162
+ options?.onCleanup?.();
163
+ };
116
164
  const handleSignal = (signal) => {
117
165
  if (exiting) return;
118
166
  exiting = true;
119
167
  child.kill(signal);
120
- options?.onCleanup?.();
168
+ cleanup();
121
169
  process.exit(128 + (SIGNAL_CODES[signal] || 15));
122
170
  };
123
- process.on("SIGINT", () => handleSignal("SIGINT"));
124
- process.on("SIGTERM", () => handleSignal("SIGTERM"));
171
+ const onSigInt = () => handleSignal("SIGINT");
172
+ const onSigTerm = () => handleSignal("SIGTERM");
173
+ process.on("SIGINT", onSigInt);
174
+ process.on("SIGTERM", onSigTerm);
125
175
  child.on("error", (err) => {
126
176
  if (exiting) return;
127
177
  exiting = true;
128
- console.error(chalk.red(`Failed to run command: ${err.message}`));
129
- options?.onCleanup?.();
178
+ console.error(`Failed to run command: ${err.message}`);
179
+ if (err.code === "ENOENT") {
180
+ console.error(`Is "${commandArgs[0]}" installed and in your PATH?`);
181
+ }
182
+ cleanup();
130
183
  process.exit(1);
131
184
  });
132
185
  child.on("exit", (code, signal) => {
133
186
  if (exiting) return;
134
187
  exiting = true;
135
- options?.onCleanup?.();
188
+ cleanup();
136
189
  if (signal) {
137
- process.exit(128 + (SIGNAL_CODES[signal] || 1));
190
+ process.exit(128 + (SIGNAL_CODES[signal] || 15));
138
191
  }
139
192
  process.exit(code ?? 1);
140
193
  });
141
194
  }
142
- function startProxyServer(proxyPort) {
195
+ function prompt(question) {
196
+ const rl = readline.createInterface({
197
+ input: process.stdin,
198
+ output: process.stdout
199
+ });
200
+ return new Promise((resolve) => {
201
+ rl.on("close", () => resolve(""));
202
+ rl.question(question, (answer) => {
203
+ rl.close();
204
+ resolve(answer.trim().toLowerCase());
205
+ });
206
+ });
207
+ }
208
+
209
+ // src/cli.ts
210
+ var DEBOUNCE_MS = 100;
211
+ var POLL_INTERVAL_MS = 3e3;
212
+ var EXIT_TIMEOUT_MS = 2e3;
213
+ var SUDO_SPAWN_TIMEOUT_MS = 3e4;
214
+ function startProxyServer(store, proxyPort) {
143
215
  store.ensureDir();
144
216
  const routesPath = store.getRoutesPath();
145
- if (!fs.existsSync(routesPath)) {
146
- fs.writeFileSync(routesPath, "[]", { mode: 420 });
217
+ if (!fs2.existsSync(routesPath)) {
218
+ fs2.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
147
219
  }
148
220
  try {
149
- fs.chmodSync(routesPath, 420);
221
+ fs2.chmodSync(routesPath, FILE_MODE);
150
222
  } catch {
151
223
  }
152
224
  let cachedRoutes = store.loadRoutes();
153
225
  let debounceTimer = null;
154
226
  let watcher = null;
227
+ let pollingInterval = null;
155
228
  const reloadRoutes = () => {
156
229
  try {
157
230
  cachedRoutes = store.loadRoutes();
@@ -159,60 +232,72 @@ function startProxyServer(proxyPort) {
159
232
  }
160
233
  };
161
234
  try {
162
- watcher = fs.watch(routesPath, () => {
235
+ watcher = fs2.watch(routesPath, () => {
163
236
  if (debounceTimer) clearTimeout(debounceTimer);
164
- debounceTimer = setTimeout(reloadRoutes, 100);
237
+ debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
165
238
  });
166
239
  } catch {
167
240
  console.warn(chalk.yellow("fs.watch unavailable; falling back to polling for route changes"));
168
- setInterval(reloadRoutes, 3e3);
241
+ pollingInterval = setInterval(reloadRoutes, POLL_INTERVAL_MS);
169
242
  }
170
243
  const server = createProxyServer({
171
244
  getRoutes: () => cachedRoutes,
245
+ proxyPort,
172
246
  onError: (msg) => console.error(chalk.red(msg))
173
247
  });
174
248
  server.on("error", (err) => {
175
249
  if (err.code === "EADDRINUSE") {
176
- console.error(chalk.red(`Port ${proxyPort} is already in use`));
250
+ console.error(chalk.red(`Port ${proxyPort} is already in use.`));
251
+ console.error(chalk.blue("Stop the existing proxy first:"));
252
+ console.error(chalk.cyan(" portless proxy stop"));
253
+ console.error(chalk.blue("Or check what is using the port:"));
254
+ console.error(chalk.cyan(` lsof -ti tcp:${proxyPort}`));
177
255
  } else if (err.code === "EACCES") {
178
- console.error(chalk.red("Permission denied. Use: sudo portless proxy"));
256
+ console.error(chalk.red(`Permission denied for port ${proxyPort}.`));
257
+ console.error(chalk.blue("Either run with sudo:"));
258
+ console.error(chalk.cyan(" sudo portless proxy start -p 80"));
259
+ console.error(chalk.blue("Or use a non-privileged port (no sudo needed):"));
260
+ console.error(chalk.cyan(" portless proxy start"));
179
261
  } else {
180
262
  console.error(chalk.red(`Proxy error: ${err.message}`));
181
263
  }
182
264
  process.exit(1);
183
265
  });
184
266
  server.listen(proxyPort, () => {
185
- fs.writeFileSync(store.pidPath, process.pid.toString(), { mode: 420 });
186
- fs.writeFileSync(PROXY_PORT_PATH, proxyPort.toString(), { mode: 420 });
267
+ fs2.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
268
+ fs2.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
187
269
  console.log(chalk.green(`HTTP proxy listening on port ${proxyPort}`));
188
270
  });
189
271
  let exiting = false;
190
272
  const cleanup = () => {
191
273
  if (exiting) return;
192
274
  exiting = true;
275
+ if (debounceTimer) clearTimeout(debounceTimer);
276
+ if (pollingInterval) clearInterval(pollingInterval);
193
277
  if (watcher) {
194
278
  watcher.close();
195
279
  }
196
280
  try {
197
- fs.unlinkSync(store.pidPath);
281
+ fs2.unlinkSync(store.pidPath);
198
282
  } catch {
199
283
  }
200
284
  try {
201
- fs.unlinkSync(PROXY_PORT_PATH);
285
+ fs2.unlinkSync(store.portFilePath);
202
286
  } catch {
203
287
  }
204
288
  server.close(() => process.exit(0));
205
- setTimeout(() => process.exit(0), 2e3).unref();
289
+ setTimeout(() => process.exit(0), EXIT_TIMEOUT_MS).unref();
206
290
  };
207
291
  process.on("SIGINT", cleanup);
208
292
  process.on("SIGTERM", cleanup);
209
293
  console.log(chalk.cyan("\nProxy is running. Press Ctrl+C to stop.\n"));
210
294
  console.log(chalk.gray(`Routes file: ${store.getRoutesPath()}`));
211
295
  }
212
- async function stopProxy() {
296
+ async function stopProxy(store, proxyPort) {
213
297
  const pidPath = store.pidPath;
214
- const proxyPort = readProxyPort();
215
- if (!fs.existsSync(pidPath)) {
298
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
299
+ const sudoHint = needsSudo ? "sudo " : "";
300
+ if (!fs2.existsSync(pidPath)) {
216
301
  if (await isProxyRunning(proxyPort)) {
217
302
  console.log(chalk.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
218
303
  const pid = findPidOnPort(proxyPort);
@@ -220,25 +305,30 @@ async function stopProxy() {
220
305
  try {
221
306
  process.kill(pid, "SIGTERM");
222
307
  try {
223
- fs.unlinkSync(PROXY_PORT_PATH);
308
+ fs2.unlinkSync(store.portFilePath);
224
309
  } catch {
225
310
  }
226
311
  console.log(chalk.green(`Killed process ${pid}. Proxy stopped.`));
227
312
  } catch (err) {
228
313
  if (isErrnoException(err) && err.code === "EPERM") {
229
- console.error(chalk.red("Permission denied. The proxy runs as root."));
230
- console.log(chalk.blue("Use: sudo portless proxy stop"));
314
+ console.error(chalk.red("Permission denied. The proxy was started with sudo."));
315
+ console.error(chalk.blue("Stop it with:"));
316
+ console.error(chalk.cyan(" sudo portless proxy stop"));
231
317
  } else {
232
318
  const message = err instanceof Error ? err.message : String(err);
233
- console.error(chalk.red("Failed to stop proxy:"), message);
319
+ console.error(chalk.red(`Failed to stop proxy: ${message}`));
320
+ console.error(chalk.blue("Check if the process is still running:"));
321
+ console.error(chalk.cyan(` lsof -ti tcp:${proxyPort}`));
234
322
  }
235
323
  }
236
324
  } else if (process.getuid?.() !== 0) {
237
- console.error(chalk.red("Permission denied. The proxy likely runs as root."));
238
- console.log(chalk.blue("Use: sudo portless proxy stop"));
325
+ console.error(chalk.red("Cannot identify the process. It may be running as root."));
326
+ console.error(chalk.blue("Try stopping with sudo:"));
327
+ console.error(chalk.cyan(" sudo portless proxy stop"));
239
328
  } else {
240
329
  console.error(chalk.red(`Could not identify the process on port ${proxyPort}.`));
241
- console.log(chalk.blue(`Try: sudo kill "$(lsof -ti tcp:${proxyPort})"`));
330
+ console.error(chalk.blue("Try manually:"));
331
+ console.error(chalk.cyan(` sudo kill "$(lsof -ti tcp:${proxyPort})"`));
242
332
  }
243
333
  } else {
244
334
  console.log(chalk.yellow("Proxy is not running."));
@@ -246,17 +336,21 @@ async function stopProxy() {
246
336
  return;
247
337
  }
248
338
  try {
249
- const pid = parseInt(fs.readFileSync(pidPath, "utf-8"), 10);
339
+ const pid = parseInt(fs2.readFileSync(pidPath, "utf-8"), 10);
250
340
  if (isNaN(pid)) {
251
341
  console.error(chalk.red("Corrupted PID file. Removing it."));
252
- fs.unlinkSync(pidPath);
342
+ fs2.unlinkSync(pidPath);
253
343
  return;
254
344
  }
255
345
  try {
256
346
  process.kill(pid, 0);
257
347
  } catch {
258
- console.log(chalk.yellow("Proxy process is no longer running. Cleaning up."));
259
- fs.unlinkSync(pidPath);
348
+ console.log(chalk.yellow("Proxy process is no longer running. Cleaning up stale files."));
349
+ fs2.unlinkSync(pidPath);
350
+ try {
351
+ fs2.unlinkSync(store.portFilePath);
352
+ } catch {
353
+ }
260
354
  return;
261
355
  }
262
356
  if (!await isProxyRunning(proxyPort)) {
@@ -266,27 +360,30 @@ async function stopProxy() {
266
360
  )
267
361
  );
268
362
  console.log(chalk.yellow("Removing stale PID file."));
269
- fs.unlinkSync(pidPath);
363
+ fs2.unlinkSync(pidPath);
270
364
  return;
271
365
  }
272
366
  process.kill(pid, "SIGTERM");
273
- fs.unlinkSync(pidPath);
367
+ fs2.unlinkSync(pidPath);
274
368
  try {
275
- fs.unlinkSync(PROXY_PORT_PATH);
369
+ fs2.unlinkSync(store.portFilePath);
276
370
  } catch {
277
371
  }
278
372
  console.log(chalk.green("Proxy stopped."));
279
373
  } catch (err) {
280
374
  if (isErrnoException(err) && err.code === "EPERM") {
281
- console.error(chalk.red("Permission denied. The proxy runs as root."));
282
- console.log(chalk.blue("Use: sudo portless proxy stop"));
375
+ console.error(chalk.red("Permission denied. The proxy was started with sudo."));
376
+ console.error(chalk.blue("Stop it with:"));
377
+ console.error(chalk.cyan(` ${sudoHint}portless proxy stop`));
283
378
  } else {
284
379
  const message = err instanceof Error ? err.message : String(err);
285
- console.error(chalk.red("Failed to stop proxy:"), message);
380
+ console.error(chalk.red(`Failed to stop proxy: ${message}`));
381
+ console.error(chalk.blue("Check if the process is still running:"));
382
+ console.error(chalk.cyan(` lsof -ti tcp:${proxyPort}`));
286
383
  }
287
384
  }
288
385
  }
289
- function listRoutes() {
386
+ function listRoutes(store, proxyPort) {
290
387
  const routes = store.loadRoutes();
291
388
  if (routes.length === 0) {
292
389
  console.log(chalk.yellow("No active routes."));
@@ -295,21 +392,32 @@ function listRoutes() {
295
392
  }
296
393
  console.log(chalk.blue.bold("\nActive routes:\n"));
297
394
  for (const route of routes) {
395
+ const url = formatUrl(route.hostname, proxyPort);
298
396
  console.log(
299
- ` ${chalk.cyan(`http://${route.hostname}`)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}`
397
+ ` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}`
300
398
  );
301
399
  }
302
400
  console.log();
303
401
  }
304
- async function runApp(name, commandArgs) {
402
+ async function runApp(store, proxyPort, stateDir, name, commandArgs) {
305
403
  const hostname = parseHostname(name);
306
- const proxyPort = readProxyPort();
404
+ const appUrl = formatUrl(hostname, proxyPort);
307
405
  console.log(chalk.blue.bold(`
308
406
  portless
309
407
  `));
310
408
  console.log(chalk.gray(`-- ${hostname} (auto-resolves to 127.0.0.1)`));
311
409
  if (!await isProxyRunning(proxyPort)) {
312
- if (process.stdin.isTTY) {
410
+ const defaultPort = getDefaultPort();
411
+ const needsSudo = defaultPort < PRIVILEGED_PORT_THRESHOLD;
412
+ if (needsSudo) {
413
+ if (!process.stdin.isTTY) {
414
+ console.error(chalk.red("Proxy is not running."));
415
+ console.error(chalk.blue("Start the proxy first (requires sudo for this port):"));
416
+ console.error(chalk.cyan(" sudo portless proxy start -p 80"));
417
+ console.error(chalk.blue("Or use the default port (no sudo needed):"));
418
+ console.error(chalk.cyan(" portless proxy start"));
419
+ process.exit(1);
420
+ }
313
421
  const answer = await prompt(chalk.yellow("Proxy not running. Start it? [Y/n/skip] "));
314
422
  if (answer === "n" || answer === "no") {
315
423
  console.log(chalk.gray("Cancelled."));
@@ -321,29 +429,40 @@ portless
321
429
  return;
322
430
  }
323
431
  console.log(chalk.yellow("Starting proxy (requires sudo)..."));
324
- const result = spawnSync("sudo", [process.execPath, process.argv[1], "proxy", "--daemon"], {
432
+ const result = spawnSync("sudo", [process.execPath, process.argv[1], "proxy", "start"], {
325
433
  stdio: "inherit",
326
- timeout: 3e4
434
+ timeout: SUDO_SPAWN_TIMEOUT_MS
327
435
  });
328
436
  if (result.status !== 0) {
329
- console.log(chalk.red("\nFailed to start proxy"));
437
+ console.error(chalk.red("Failed to start proxy."));
438
+ console.error(chalk.blue("Try starting it manually:"));
439
+ console.error(chalk.cyan(" sudo portless proxy start"));
330
440
  process.exit(1);
331
441
  }
332
- if (!await waitForProxy()) {
333
- console.log(chalk.red("\nProxy failed to start"));
334
- const logPath = path.join(PORTLESS_DIR, "proxy.log");
335
- if (fs.existsSync(logPath)) {
336
- console.log(chalk.gray(`Check logs: ${logPath}`));
337
- }
442
+ } else {
443
+ console.log(chalk.yellow("Starting proxy..."));
444
+ const result = spawnSync(process.execPath, [process.argv[1], "proxy", "start"], {
445
+ stdio: "inherit",
446
+ timeout: SUDO_SPAWN_TIMEOUT_MS
447
+ });
448
+ if (result.status !== 0) {
449
+ console.error(chalk.red("Failed to start proxy."));
450
+ console.error(chalk.blue("Try starting it manually:"));
451
+ console.error(chalk.cyan(" portless proxy start"));
338
452
  process.exit(1);
339
453
  }
340
- console.log(chalk.green("Proxy started in background"));
341
- } else {
342
- console.log(chalk.red("\nProxy is not running!"));
343
- console.log(chalk.blue("\nStart the proxy first (one time):"));
344
- console.log(chalk.cyan(" sudo portless proxy\n"));
454
+ }
455
+ if (!await waitForProxy(defaultPort)) {
456
+ console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
457
+ const logPath = path2.join(stateDir, "proxy.log");
458
+ console.error(chalk.blue("Try starting the proxy manually to see the error:"));
459
+ console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start`));
460
+ if (fs2.existsSync(logPath)) {
461
+ console.error(chalk.gray(`Logs: ${logPath}`));
462
+ }
345
463
  process.exit(1);
346
464
  }
465
+ console.log(chalk.green("Proxy started in background"));
347
466
  } else {
348
467
  console.log(chalk.gray("-- Proxy is running"));
349
468
  }
@@ -351,7 +470,7 @@ portless
351
470
  console.log(chalk.green(`-- Using port ${port}`));
352
471
  store.addRoute(hostname, port, process.pid);
353
472
  console.log(chalk.cyan.bold(`
354
- -> http://${hostname}
473
+ -> ${appUrl}
355
474
  `));
356
475
  console.log(chalk.gray(`Running: PORT=${port} ${commandArgs.join(" ")}
357
476
  `));
@@ -371,7 +490,8 @@ async function main() {
371
490
  const isPnpmDlx = !!process.env.PNPM_SCRIPT_SRC_DIR && !process.env.npm_lifecycle_event;
372
491
  if (isNpx || isPnpmDlx) {
373
492
  console.error(chalk.red("Error: portless should not be run via npx or pnpm dlx."));
374
- console.log(chalk.blue("Install globally: npm install -g portless"));
493
+ console.error(chalk.blue("Install globally instead:"));
494
+ console.error(chalk.cyan(" npm install -g portless"));
375
495
  process.exit(1);
376
496
  }
377
497
  const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "skip";
@@ -391,16 +511,16 @@ ${chalk.bold("Install:")}
391
511
  Do NOT add portless as a project dependency.
392
512
 
393
513
  ${chalk.bold("Usage:")}
394
- ${chalk.cyan("sudo portless proxy")} Start the proxy (run once, keep open)
395
- ${chalk.cyan("sudo portless proxy --port 8080")} Start the proxy on a custom port
396
- ${chalk.cyan("sudo portless proxy stop")} Stop the proxy
514
+ ${chalk.cyan("portless proxy start")} Start the proxy (background daemon)
515
+ ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
516
+ ${chalk.cyan("portless proxy stop")} Stop the proxy
397
517
  ${chalk.cyan("portless <name> <cmd>")} Run your app through the proxy
398
518
  ${chalk.cyan("portless list")} Show active routes
399
519
 
400
520
  ${chalk.bold("Examples:")}
401
- sudo portless proxy # Start proxy in terminal 1
402
- portless myapp next dev # Terminal 2 -> http://myapp.localhost
403
- portless api.myapp pnpm start # Terminal 3 -> http://api.myapp.localhost
521
+ portless proxy start # Start proxy on port 1355
522
+ portless myapp next dev # -> http://myapp.localhost:1355
523
+ portless api.myapp pnpm start # -> http://api.myapp.localhost:1355
404
524
 
405
525
  ${chalk.bold("In package.json:")}
406
526
  {
@@ -410,14 +530,20 @@ ${chalk.bold("In package.json:")}
410
530
  }
411
531
 
412
532
  ${chalk.bold("How it works:")}
413
- 1. Start the proxy once with sudo (listens on port 80 by default)
414
- 2. Run your apps - they register automatically
415
- 3. Access via http://<name>.localhost
533
+ 1. Start the proxy once (listens on port 1355 by default, no sudo needed)
534
+ 2. Run your apps - they auto-start the proxy and register automatically
535
+ 3. Access via http://<name>.localhost:1355
416
536
  4. .localhost domains auto-resolve to 127.0.0.1
417
537
 
418
538
  ${chalk.bold("Options:")}
419
- --port <number> Port for the proxy to listen on (default: 80)
420
- Ports >= 1024 do not require sudo
539
+ -p, --port <number> Port for the proxy to listen on (default: 1355)
540
+ Ports < 1024 require sudo
541
+ --foreground Run proxy in foreground (for debugging)
542
+
543
+ ${chalk.bold("Environment variables:")}
544
+ PORTLESS_PORT=<number> Override the default proxy port (e.g. in .bashrc)
545
+ PORTLESS_STATE_DIR=<path> Override the state directory
546
+ PORTLESS=0 | PORTLESS=skip Run command directly without proxy
421
547
 
422
548
  ${chalk.bold("Skip portless:")}
423
549
  PORTLESS=0 pnpm dev # Runs command directly without proxy
@@ -426,88 +552,134 @@ ${chalk.bold("Skip portless:")}
426
552
  process.exit(0);
427
553
  }
428
554
  if (args[0] === "--version" || args[0] === "-v") {
429
- console.log("0.2.2");
555
+ console.log("0.3.0");
430
556
  process.exit(0);
431
557
  }
432
558
  if (args[0] === "list") {
433
- listRoutes();
559
+ const { dir: dir2, port: port2 } = await discoverState();
560
+ const store2 = new RouteStore(dir2, {
561
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
562
+ });
563
+ listRoutes(store2, port2);
434
564
  return;
435
565
  }
436
566
  if (args[0] === "proxy") {
437
567
  if (args[1] === "stop") {
438
- await stopProxy();
568
+ const { dir: dir2, port: port2 } = await discoverState();
569
+ const store3 = new RouteStore(dir2, {
570
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
571
+ });
572
+ await stopProxy(store3, port2);
439
573
  return;
440
574
  }
441
- const isDaemon = args.includes("--daemon");
442
- let proxyPort = DEFAULT_PROXY_PORT;
443
- const portFlagIndex = args.indexOf("--port");
575
+ if (args[1] !== "start") {
576
+ console.log(`
577
+ ${chalk.bold("Usage: portless proxy <command>")}
578
+
579
+ ${chalk.cyan("portless proxy start")} Start the proxy (daemon)
580
+ ${chalk.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
581
+ ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
582
+ ${chalk.cyan("portless proxy stop")} Stop the proxy
583
+ `);
584
+ process.exit(args[1] ? 1 : 0);
585
+ }
586
+ const isForeground = args.includes("--foreground");
587
+ let proxyPort = getDefaultPort();
588
+ let portFlagIndex = args.indexOf("--port");
589
+ if (portFlagIndex === -1) portFlagIndex = args.indexOf("-p");
444
590
  if (portFlagIndex !== -1) {
445
591
  const portValue = args[portFlagIndex + 1];
446
592
  if (!portValue || portValue.startsWith("-")) {
447
- console.error(chalk.red("Error: --port requires a port number"));
448
- console.log(chalk.blue("Usage: portless proxy --port 8080"));
593
+ console.error(chalk.red("Error: --port / -p requires a port number."));
594
+ console.error(chalk.blue("Usage:"));
595
+ console.error(chalk.cyan(" portless proxy start -p 8080"));
449
596
  process.exit(1);
450
597
  }
451
598
  proxyPort = parseInt(portValue, 10);
452
599
  if (isNaN(proxyPort) || proxyPort < 1 || proxyPort > 65535) {
453
600
  console.error(chalk.red(`Error: Invalid port number: ${portValue}`));
454
- console.log(chalk.blue("Port must be between 1 and 65535"));
601
+ console.error(chalk.blue("Port must be between 1 and 65535."));
455
602
  process.exit(1);
456
603
  }
457
604
  }
605
+ const stateDir = resolveStateDir(proxyPort);
606
+ const store2 = new RouteStore(stateDir, {
607
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
608
+ });
458
609
  if (await isProxyRunning(proxyPort)) {
459
- if (!isDaemon) {
460
- console.log(chalk.yellow("Proxy is already running."));
461
- console.log(chalk.blue("To restart: portless proxy stop && sudo portless proxy"));
610
+ if (isForeground) {
611
+ return;
462
612
  }
613
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
614
+ const sudoPrefix = needsSudo ? "sudo " : "";
615
+ console.log(chalk.yellow(`Proxy is already running on port ${proxyPort}.`));
616
+ console.log(
617
+ chalk.blue(`To restart: portless proxy stop && ${sudoPrefix}portless proxy start`)
618
+ );
463
619
  return;
464
620
  }
465
- if (proxyPort < 1024 && process.getuid() !== 0) {
466
- console.error(chalk.red(`Error: Proxy requires sudo for port ${proxyPort}`));
467
- console.log(chalk.blue("Usage: sudo portless proxy"));
621
+ if (proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
622
+ console.error(chalk.red(`Error: Port ${proxyPort} requires sudo.`));
623
+ console.error(chalk.blue("Either run with sudo:"));
624
+ console.error(chalk.cyan(" sudo portless proxy start -p 80"));
625
+ console.error(chalk.blue("Or use the default port (no sudo needed):"));
626
+ console.error(chalk.cyan(" portless proxy start"));
468
627
  process.exit(1);
469
628
  }
470
- if (isDaemon) {
471
- store.ensureDir();
472
- const logPath = path.join(PORTLESS_DIR, "proxy.log");
473
- const logFd = fs.openSync(logPath, "a");
629
+ if (isForeground) {
630
+ console.log(chalk.blue.bold("\nportless proxy\n"));
631
+ startProxyServer(store2, proxyPort);
632
+ return;
633
+ }
634
+ store2.ensureDir();
635
+ const logPath = path2.join(stateDir, "proxy.log");
636
+ const logFd = fs2.openSync(logPath, "a");
637
+ try {
474
638
  try {
475
- fs.chmodSync(logPath, 420);
639
+ fs2.chmodSync(logPath, FILE_MODE);
476
640
  } catch {
477
641
  }
478
- const daemonArgs = [process.argv[1], "proxy"];
642
+ const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
479
643
  if (portFlagIndex !== -1) {
480
644
  daemonArgs.push("--port", proxyPort.toString());
481
645
  }
482
- const child = spawn(process.execPath, daemonArgs, {
646
+ const child = spawn2(process.execPath, daemonArgs, {
483
647
  detached: true,
484
648
  stdio: ["ignore", logFd, logFd],
485
649
  env: process.env
486
650
  });
487
651
  child.unref();
488
- fs.closeSync(logFd);
489
- if (!await waitForProxy(proxyPort)) {
490
- console.error(chalk.red("Proxy failed to start"));
491
- if (fs.existsSync(logPath)) {
492
- console.log(chalk.gray(`Check logs: ${logPath}`));
493
- }
494
- process.exit(1);
652
+ } finally {
653
+ fs2.closeSync(logFd);
654
+ }
655
+ if (!await waitForProxy(proxyPort)) {
656
+ console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
657
+ console.error(chalk.blue("Try starting the proxy in the foreground to see the error:"));
658
+ const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
659
+ console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start --foreground`));
660
+ if (fs2.existsSync(logPath)) {
661
+ console.error(chalk.gray(`Logs: ${logPath}`));
495
662
  }
496
- return;
663
+ process.exit(1);
497
664
  }
498
- console.log(chalk.blue.bold("\nportless proxy\n"));
499
- startProxyServer(proxyPort);
665
+ console.log(chalk.green(`Proxy started on port ${proxyPort}`));
500
666
  return;
501
667
  }
502
668
  const name = args[0];
503
669
  const commandArgs = args.slice(1);
504
670
  if (commandArgs.length === 0) {
505
- console.error(chalk.red("Error: No command provided"));
506
- console.log(chalk.blue("Usage: portless <name> <command...>"));
507
- console.log(chalk.blue("Example: portless myapp next dev"));
671
+ console.error(chalk.red("Error: No command provided."));
672
+ console.error(chalk.blue("Usage:"));
673
+ console.error(chalk.cyan(" portless <name> <command...>"));
674
+ console.error(chalk.blue("Example:"));
675
+ console.error(chalk.cyan(" portless myapp next dev"));
508
676
  process.exit(1);
509
677
  }
510
- await runApp(name, commandArgs);
678
+ const { dir, port } = await discoverState();
679
+ const store = new RouteStore(dir, {
680
+ onWarning: (msg) => console.warn(chalk.yellow(msg))
681
+ });
682
+ await runApp(store, port, dir, name, commandArgs);
511
683
  }
512
684
  main().catch((err) => {
513
685
  const message = err instanceof Error ? err.message : String(err);
package/dist/index.d.ts CHANGED
@@ -8,18 +8,27 @@ interface RouteInfo {
8
8
  interface ProxyServerOptions {
9
9
  /** Called on each request to get the current route table. */
10
10
  getRoutes: () => RouteInfo[];
11
+ /** The port the proxy is listening on (used to build correct URLs). */
12
+ proxyPort: number;
11
13
  /** Optional error logger; defaults to console.error. */
12
14
  onError?: (message: string) => void;
13
15
  }
14
16
 
17
+ /** Response header used to identify a portless proxy (for health checks). */
18
+ declare const PORTLESS_HEADER = "X-Portless";
15
19
  /**
16
20
  * Create an HTTP proxy server that routes requests based on the Host header.
17
21
  *
22
+ * Uses Node's built-in http module for proxying (no external dependencies).
18
23
  * The `getRoutes` callback is invoked on every request so callers can provide
19
24
  * either a static list or a live-updating one.
20
25
  */
21
26
  declare function createProxyServer(options: ProxyServerOptions): http.Server;
22
27
 
28
+ /** File permission mode for route and state files. */
29
+ declare const FILE_MODE = 420;
30
+ /** Directory permission mode for the state directory. */
31
+ declare const DIR_MODE = 493;
23
32
  interface RouteMapping extends RouteInfo {
24
33
  pid: number;
25
34
  }
@@ -32,6 +41,7 @@ declare class RouteStore {
32
41
  private readonly routesPath;
33
42
  private readonly lockPath;
34
43
  readonly pidPath: string;
44
+ readonly portFilePath: string;
35
45
  private readonly onWarning;
36
46
  constructor(dir: string, options?: {
37
47
  onWarning?: (message: string) => void;
@@ -40,8 +50,8 @@ declare class RouteStore {
40
50
  getRoutesPath(): string;
41
51
  private static readonly sleepBuffer;
42
52
  private syncSleep;
43
- acquireLock(maxRetries?: number, retryDelayMs?: number): boolean;
44
- releaseLock(): void;
53
+ private acquireLock;
54
+ private releaseLock;
45
55
  private isProcessAlive;
46
56
  /**
47
57
  * Load routes from disk, filtering out stale entries whose owning process
@@ -61,10 +71,14 @@ declare function isErrnoException(err: unknown): err is NodeJS.ErrnoException;
61
71
  * Escape HTML special characters to prevent XSS.
62
72
  */
63
73
  declare function escapeHtml(str: string): string;
74
+ /**
75
+ * Format a .localhost URL, including the port only when it is not 80 (standard HTTP).
76
+ */
77
+ declare function formatUrl(hostname: string, proxyPort: number): string;
64
78
  /**
65
79
  * Parse and normalize a hostname input for use as a .localhost subdomain.
66
80
  * Strips protocol prefixes, validates characters, and appends .localhost if needed.
67
81
  */
68
82
  declare function parseHostname(input: string): string;
69
83
 
70
- export { type ProxyServerOptions, type RouteInfo, type RouteMapping, RouteStore, createProxyServer, escapeHtml, isErrnoException, parseHostname };
84
+ export { DIR_MODE, FILE_MODE, PORTLESS_HEADER, type ProxyServerOptions, type RouteInfo, type RouteMapping, RouteStore, createProxyServer, escapeHtml, formatUrl, isErrnoException, parseHostname };
package/dist/index.js CHANGED
@@ -1,14 +1,22 @@
1
1
  import {
2
+ DIR_MODE,
3
+ FILE_MODE,
4
+ PORTLESS_HEADER,
2
5
  RouteStore,
3
6
  createProxyServer,
4
7
  escapeHtml,
8
+ formatUrl,
5
9
  isErrnoException,
6
10
  parseHostname
7
- } from "./chunk-SE7KL62V.js";
11
+ } from "./chunk-Y5OVKUR4.js";
8
12
  export {
13
+ DIR_MODE,
14
+ FILE_MODE,
15
+ PORTLESS_HEADER,
9
16
  RouteStore,
10
17
  createProxyServer,
11
18
  escapeHtml,
19
+ formatUrl,
12
20
  isErrnoException,
13
21
  parseHostname
14
22
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portless",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Replace port numbers with stable, named .localhost URLs. For humans and agents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -57,12 +57,10 @@
57
57
  "url": "https://github.com/vercel-labs/portless/issues"
58
58
  },
59
59
  "dependencies": {
60
- "chalk": "^5.3.0",
61
- "http-proxy": "^1.18.1"
60
+ "chalk": "^5.3.0"
62
61
  },
63
62
  "devDependencies": {
64
63
  "@eslint/js": "^9.39.2",
65
- "@types/http-proxy": "^1.17.14",
66
64
  "@types/node": "^20.11.0",
67
65
  "@vitest/coverage-v8": "^4.0.18",
68
66
  "eslint": "^9.39.2",