portless 0.2.1 → 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
  };