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 +45 -30
- package/dist/{chunk-SE7KL62V.js → chunk-Y5OVKUR4.js} +122 -30
- package/dist/cli.js +343 -160
- package/dist/index.d.ts +17 -3
- package/dist/index.js +9 -1
- package/package.json +19 -18
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Replace port numbers with stable, named .localhost URLs. For humans and agents.
|
|
|
4
4
|
|
|
5
5
|
```diff
|
|
6
6
|
- "dev": "next dev" # http://localhost:3000
|
|
7
|
-
+ "dev": "portless myapp next dev" # http://myapp.localhost
|
|
7
|
+
+ "dev": "portless myapp next dev" # http://myapp.localhost:1355
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
## Quick Start
|
|
@@ -13,29 +13,29 @@ Replace port numbers with stable, named .localhost URLs. For humans and agents.
|
|
|
13
13
|
# Install
|
|
14
14
|
npm install -g portless
|
|
15
15
|
|
|
16
|
-
# Start the proxy (once,
|
|
17
|
-
|
|
16
|
+
# Start the proxy (once, no sudo needed)
|
|
17
|
+
portless proxy start
|
|
18
18
|
|
|
19
|
-
# Run your app
|
|
19
|
+
# Run your app (auto-starts the proxy if needed)
|
|
20
20
|
portless myapp next dev
|
|
21
|
-
#
|
|
21
|
+
# -> http://myapp.localhost:1355
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
>
|
|
24
|
+
> The proxy auto-starts when you run an app. You can also start it explicitly with `portless proxy start`.
|
|
25
25
|
|
|
26
26
|
## Why
|
|
27
27
|
|
|
28
28
|
Local dev with port numbers is fragile:
|
|
29
29
|
|
|
30
|
-
- **Port conflicts**
|
|
31
|
-
- **Memorizing ports**
|
|
32
|
-
- **Refreshing shows the wrong app**
|
|
33
|
-
- **Monorepo multiplier**
|
|
34
|
-
- **Agents test the wrong port**
|
|
35
|
-
- **Cookie and storage clashes**
|
|
36
|
-
- **Hardcoded ports in config**
|
|
37
|
-
- **Sharing URLs with teammates**
|
|
38
|
-
- **Browser history is useless**
|
|
30
|
+
- **Port conflicts** -- two projects default to the same port and you get `EADDRINUSE`
|
|
31
|
+
- **Memorizing ports** -- was the API on 3001 or 8080?
|
|
32
|
+
- **Refreshing shows the wrong app** -- stop one server, start another on the same port, and your open tab now shows something completely different
|
|
33
|
+
- **Monorepo multiplier** -- every problem above scales with each service in the repo
|
|
34
|
+
- **Agents test the wrong port** -- AI coding agents guess or hardcode the wrong port, especially in monorepos
|
|
35
|
+
- **Cookie and storage clashes** -- cookies set on `localhost` bleed across apps on different ports; localStorage is lost when ports shift
|
|
36
|
+
- **Hardcoded ports in config** -- CORS allowlists, OAuth redirect URIs, and `.env` files all break when ports change
|
|
37
|
+
- **Sharing URLs with teammates** -- "what port is that on?" becomes a Slack question
|
|
38
|
+
- **Browser history is useless** -- your history for `localhost:3000` is a jumble of unrelated projects
|
|
39
39
|
|
|
40
40
|
Portless fixes all of this by giving each dev server a stable, named `.localhost` URL that both humans and agents can rely on.
|
|
41
41
|
|
|
@@ -44,14 +44,14 @@ Portless fixes all of this by giving each dev server a stable, named `.localhost
|
|
|
44
44
|
```bash
|
|
45
45
|
# Basic
|
|
46
46
|
portless myapp next dev
|
|
47
|
-
#
|
|
47
|
+
# -> http://myapp.localhost:1355
|
|
48
48
|
|
|
49
49
|
# Subdomains
|
|
50
50
|
portless api.myapp pnpm start
|
|
51
|
-
#
|
|
51
|
+
# -> http://api.myapp.localhost:1355
|
|
52
52
|
|
|
53
53
|
portless docs.myapp next dev
|
|
54
|
-
#
|
|
54
|
+
# -> http://docs.myapp.localhost:1355
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
### In package.json
|
|
@@ -64,32 +64,32 @@ portless docs.myapp next dev
|
|
|
64
64
|
}
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
The proxy auto-starts when you run an app. Or start it explicitly: `portless proxy start`.
|
|
68
68
|
|
|
69
69
|
## How It Works
|
|
70
70
|
|
|
71
71
|
```mermaid
|
|
72
72
|
flowchart TD
|
|
73
|
-
Browser["Browser\nmyapp.localhost"]
|
|
74
|
-
Proxy["portless proxy\n(port
|
|
73
|
+
Browser["Browser\nmyapp.localhost:1355"]
|
|
74
|
+
Proxy["portless proxy\n(port 1355)"]
|
|
75
75
|
App1[":4123\nmyapp"]
|
|
76
76
|
App2[":4567\napi"]
|
|
77
77
|
|
|
78
|
-
Browser -->|port
|
|
78
|
+
Browser -->|port 1355| Proxy
|
|
79
79
|
Proxy --> App1
|
|
80
80
|
Proxy --> App2
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
1. **Start the proxy** --
|
|
83
|
+
1. **Start the proxy** -- auto-starts when you run an app, or start explicitly with `portless proxy start`
|
|
84
84
|
2. **Run apps** -- `portless <name> <command>` assigns a free port and registers with the proxy
|
|
85
|
-
3. **Access via URL** -- `http://<name>.localhost` routes through the proxy to your app
|
|
85
|
+
3. **Access via URL** -- `http://<name>.localhost:1355` routes through the proxy to your app
|
|
86
86
|
|
|
87
87
|
Apps are assigned a random port (4000-4999) via the `PORT` environment variable. Most frameworks (Next.js, Vite, etc.) respect this automatically.
|
|
88
88
|
|
|
89
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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>
|
|
106
|
-
# Ports
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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:
|
|
209
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: DIR_MODE });
|
|
121
210
|
}
|
|
122
211
|
try {
|
|
123
|
-
fs.chmodSync(this.dir,
|
|
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 =
|
|
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 >
|
|
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:
|
|
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.
|
|
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
|
};
|