portless 0.4.1 → 0.4.2
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/dist/chunk-JMRTQAVX.js +783 -0
- package/dist/cli.js +98 -306
- package/dist/index.d.ts +24 -3
- package/dist/index.js +9 -1
- package/package.json +13 -12
- package/LICENSE +0 -201
- package/dist/chunk-VRBD6YAY.js +0 -412
package/dist/chunk-VRBD6YAY.js
DELETED
|
@@ -1,412 +0,0 @@
|
|
|
1
|
-
// src/utils.ts
|
|
2
|
-
function isErrnoException(err) {
|
|
3
|
-
return err instanceof Error && "code" in err && typeof err.code === "string";
|
|
4
|
-
}
|
|
5
|
-
function escapeHtml(str) {
|
|
6
|
-
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7
|
-
}
|
|
8
|
-
function formatUrl(hostname, proxyPort, tls = false) {
|
|
9
|
-
const proto = tls ? "https" : "http";
|
|
10
|
-
const defaultPort = tls ? 443 : 80;
|
|
11
|
-
return proxyPort === defaultPort ? `${proto}://${hostname}` : `${proto}://${hostname}:${proxyPort}`;
|
|
12
|
-
}
|
|
13
|
-
function parseHostname(input) {
|
|
14
|
-
let hostname = input.trim().replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
|
|
15
|
-
if (!hostname || hostname === ".localhost") {
|
|
16
|
-
throw new Error("Hostname cannot be empty");
|
|
17
|
-
}
|
|
18
|
-
if (!hostname.endsWith(".localhost")) {
|
|
19
|
-
hostname = `${hostname}.localhost`;
|
|
20
|
-
}
|
|
21
|
-
const name = hostname.replace(/\.localhost$/, "");
|
|
22
|
-
if (name.includes("..")) {
|
|
23
|
-
throw new Error(`Invalid hostname "${name}": consecutive dots are not allowed`);
|
|
24
|
-
}
|
|
25
|
-
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) {
|
|
26
|
-
throw new Error(
|
|
27
|
-
`Invalid hostname "${name}": must contain only lowercase letters, digits, hyphens, and dots`
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
return hostname;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// src/proxy.ts
|
|
34
|
-
import * as http from "http";
|
|
35
|
-
import * as http2 from "http2";
|
|
36
|
-
import * as net from "net";
|
|
37
|
-
var PORTLESS_HEADER = "X-Portless";
|
|
38
|
-
var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
39
|
-
"connection",
|
|
40
|
-
"keep-alive",
|
|
41
|
-
"proxy-connection",
|
|
42
|
-
"transfer-encoding",
|
|
43
|
-
"upgrade"
|
|
44
|
-
]);
|
|
45
|
-
function getRequestHost(req) {
|
|
46
|
-
const authority = req.headers[":authority"];
|
|
47
|
-
if (typeof authority === "string" && authority) return authority;
|
|
48
|
-
return req.headers.host || "";
|
|
49
|
-
}
|
|
50
|
-
function buildForwardedHeaders(req, tls) {
|
|
51
|
-
const headers = {};
|
|
52
|
-
const remoteAddress = req.socket.remoteAddress || "127.0.0.1";
|
|
53
|
-
const proto = tls ? "https" : "http";
|
|
54
|
-
const defaultPort = tls ? "443" : "80";
|
|
55
|
-
const hostHeader = getRequestHost(req);
|
|
56
|
-
headers["x-forwarded-for"] = req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remoteAddress}` : remoteAddress;
|
|
57
|
-
headers["x-forwarded-proto"] = req.headers["x-forwarded-proto"] || proto;
|
|
58
|
-
headers["x-forwarded-host"] = req.headers["x-forwarded-host"] || hostHeader;
|
|
59
|
-
headers["x-forwarded-port"] = req.headers["x-forwarded-port"] || hostHeader.split(":")[1] || defaultPort;
|
|
60
|
-
return headers;
|
|
61
|
-
}
|
|
62
|
-
function createProxyServer(options) {
|
|
63
|
-
const { getRoutes, proxyPort, onError = (msg) => console.error(msg), tls } = options;
|
|
64
|
-
const isTls = !!tls;
|
|
65
|
-
const handleRequest = (req, res) => {
|
|
66
|
-
res.setHeader(PORTLESS_HEADER, "1");
|
|
67
|
-
const routes = getRoutes();
|
|
68
|
-
const host = getRequestHost(req).split(":")[0];
|
|
69
|
-
if (!host) {
|
|
70
|
-
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
71
|
-
res.end("Missing Host header");
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
const route = routes.find((r) => r.hostname === host);
|
|
75
|
-
if (!route) {
|
|
76
|
-
const safeHost = escapeHtml(host);
|
|
77
|
-
res.writeHead(404, { "Content-Type": "text/html" });
|
|
78
|
-
res.end(`
|
|
79
|
-
<html>
|
|
80
|
-
<head><title>portless - Not Found</title></head>
|
|
81
|
-
<body style="font-family: system-ui; padding: 40px; max-width: 600px; margin: 0 auto;">
|
|
82
|
-
<h1>Not Found</h1>
|
|
83
|
-
<p>No app registered for <strong>${safeHost}</strong></p>
|
|
84
|
-
${routes.length > 0 ? `
|
|
85
|
-
<h2>Active apps:</h2>
|
|
86
|
-
<ul>
|
|
87
|
-
${routes.map((r) => `<li><a href="${escapeHtml(formatUrl(r.hostname, proxyPort, isTls))}">${escapeHtml(r.hostname)}</a> - localhost:${escapeHtml(String(r.port))}</li>`).join("")}
|
|
88
|
-
</ul>
|
|
89
|
-
` : "<p><em>No apps running.</em></p>"}
|
|
90
|
-
<p>Start an app with: <code>portless ${safeHost.replace(".localhost", "")} your-command</code></p>
|
|
91
|
-
</body>
|
|
92
|
-
</html>
|
|
93
|
-
`);
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
const forwardedHeaders = buildForwardedHeaders(req, isTls);
|
|
97
|
-
const proxyReqHeaders = { ...req.headers };
|
|
98
|
-
for (const [key, value] of Object.entries(forwardedHeaders)) {
|
|
99
|
-
proxyReqHeaders[key] = value;
|
|
100
|
-
}
|
|
101
|
-
for (const key of Object.keys(proxyReqHeaders)) {
|
|
102
|
-
if (key.startsWith(":")) {
|
|
103
|
-
delete proxyReqHeaders[key];
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
const proxyReq = http.request(
|
|
107
|
-
{
|
|
108
|
-
hostname: "127.0.0.1",
|
|
109
|
-
port: route.port,
|
|
110
|
-
path: req.url,
|
|
111
|
-
method: req.method,
|
|
112
|
-
headers: proxyReqHeaders
|
|
113
|
-
},
|
|
114
|
-
(proxyRes) => {
|
|
115
|
-
const responseHeaders = { ...proxyRes.headers };
|
|
116
|
-
if (isTls) {
|
|
117
|
-
for (const h of HOP_BY_HOP_HEADERS) {
|
|
118
|
-
delete responseHeaders[h];
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
res.writeHead(proxyRes.statusCode || 502, responseHeaders);
|
|
122
|
-
proxyRes.pipe(res);
|
|
123
|
-
}
|
|
124
|
-
);
|
|
125
|
-
proxyReq.on("error", (err) => {
|
|
126
|
-
onError(`Proxy error for ${getRequestHost(req)}: ${err.message}`);
|
|
127
|
-
if (!res.headersSent) {
|
|
128
|
-
const errWithCode = err;
|
|
129
|
-
const message = errWithCode.code === "ECONNREFUSED" ? "Bad Gateway: the target app is not responding. It may have crashed." : "Bad Gateway: the target app may not be running.";
|
|
130
|
-
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
131
|
-
res.end(message);
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
res.on("close", () => {
|
|
135
|
-
if (!proxyReq.destroyed) {
|
|
136
|
-
proxyReq.destroy();
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
req.on("error", () => {
|
|
140
|
-
if (!proxyReq.destroyed) {
|
|
141
|
-
proxyReq.destroy();
|
|
142
|
-
}
|
|
143
|
-
});
|
|
144
|
-
req.pipe(proxyReq);
|
|
145
|
-
};
|
|
146
|
-
const handleUpgrade = (req, socket, head) => {
|
|
147
|
-
const routes = getRoutes();
|
|
148
|
-
const host = getRequestHost(req).split(":")[0];
|
|
149
|
-
const route = routes.find((r) => r.hostname === host);
|
|
150
|
-
if (!route) {
|
|
151
|
-
socket.destroy();
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
const forwardedHeaders = buildForwardedHeaders(req, isTls);
|
|
155
|
-
const proxyReqHeaders = { ...req.headers };
|
|
156
|
-
for (const [key, value] of Object.entries(forwardedHeaders)) {
|
|
157
|
-
proxyReqHeaders[key] = value;
|
|
158
|
-
}
|
|
159
|
-
for (const key of Object.keys(proxyReqHeaders)) {
|
|
160
|
-
if (key.startsWith(":")) {
|
|
161
|
-
delete proxyReqHeaders[key];
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
const proxyReq = http.request({
|
|
165
|
-
hostname: "127.0.0.1",
|
|
166
|
-
port: route.port,
|
|
167
|
-
path: req.url,
|
|
168
|
-
method: req.method,
|
|
169
|
-
headers: proxyReqHeaders
|
|
170
|
-
});
|
|
171
|
-
proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
|
|
172
|
-
let response = `HTTP/1.1 101 Switching Protocols\r
|
|
173
|
-
`;
|
|
174
|
-
for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) {
|
|
175
|
-
response += `${proxyRes.rawHeaders[i]}: ${proxyRes.rawHeaders[i + 1]}\r
|
|
176
|
-
`;
|
|
177
|
-
}
|
|
178
|
-
response += "\r\n";
|
|
179
|
-
socket.write(response);
|
|
180
|
-
if (proxyHead.length > 0) {
|
|
181
|
-
socket.write(proxyHead);
|
|
182
|
-
}
|
|
183
|
-
proxySocket.pipe(socket);
|
|
184
|
-
socket.pipe(proxySocket);
|
|
185
|
-
proxySocket.on("error", () => socket.destroy());
|
|
186
|
-
socket.on("error", () => proxySocket.destroy());
|
|
187
|
-
});
|
|
188
|
-
proxyReq.on("error", (err) => {
|
|
189
|
-
onError(`WebSocket proxy error for ${getRequestHost(req)}: ${err.message}`);
|
|
190
|
-
socket.destroy();
|
|
191
|
-
});
|
|
192
|
-
proxyReq.on("response", (res) => {
|
|
193
|
-
if (!socket.destroyed) {
|
|
194
|
-
let response = `HTTP/1.1 ${res.statusCode} ${res.statusMessage}\r
|
|
195
|
-
`;
|
|
196
|
-
for (let i = 0; i < res.rawHeaders.length; i += 2) {
|
|
197
|
-
response += `${res.rawHeaders[i]}: ${res.rawHeaders[i + 1]}\r
|
|
198
|
-
`;
|
|
199
|
-
}
|
|
200
|
-
response += "\r\n";
|
|
201
|
-
socket.write(response);
|
|
202
|
-
res.pipe(socket);
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
if (head.length > 0) {
|
|
206
|
-
proxyReq.write(head);
|
|
207
|
-
}
|
|
208
|
-
proxyReq.end();
|
|
209
|
-
};
|
|
210
|
-
if (tls) {
|
|
211
|
-
const h2Server = http2.createSecureServer({
|
|
212
|
-
cert: tls.cert,
|
|
213
|
-
key: tls.key,
|
|
214
|
-
allowHTTP1: true,
|
|
215
|
-
...tls.SNICallback ? { SNICallback: tls.SNICallback } : {}
|
|
216
|
-
});
|
|
217
|
-
h2Server.on("request", (req, res) => {
|
|
218
|
-
handleRequest(req, res);
|
|
219
|
-
});
|
|
220
|
-
h2Server.on("upgrade", (req, socket, head) => {
|
|
221
|
-
handleUpgrade(req, socket, head);
|
|
222
|
-
});
|
|
223
|
-
const plainServer = http.createServer(handleRequest);
|
|
224
|
-
plainServer.on("upgrade", handleUpgrade);
|
|
225
|
-
const wrapper = net.createServer((socket) => {
|
|
226
|
-
socket.once("readable", () => {
|
|
227
|
-
const buf = socket.read(1);
|
|
228
|
-
if (!buf) {
|
|
229
|
-
socket.destroy();
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
socket.unshift(buf);
|
|
233
|
-
if (buf[0] === 22) {
|
|
234
|
-
h2Server.emit("connection", socket);
|
|
235
|
-
} else {
|
|
236
|
-
plainServer.emit("connection", socket);
|
|
237
|
-
}
|
|
238
|
-
});
|
|
239
|
-
});
|
|
240
|
-
const origClose = wrapper.close.bind(wrapper);
|
|
241
|
-
wrapper.close = function(cb) {
|
|
242
|
-
h2Server.close();
|
|
243
|
-
plainServer.close();
|
|
244
|
-
return origClose(cb);
|
|
245
|
-
};
|
|
246
|
-
return wrapper;
|
|
247
|
-
}
|
|
248
|
-
const httpServer = http.createServer(handleRequest);
|
|
249
|
-
httpServer.on("upgrade", handleUpgrade);
|
|
250
|
-
return httpServer;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// src/routes.ts
|
|
254
|
-
import * as fs from "fs";
|
|
255
|
-
import * as path from "path";
|
|
256
|
-
var STALE_LOCK_THRESHOLD_MS = 1e4;
|
|
257
|
-
var LOCK_MAX_RETRIES = 20;
|
|
258
|
-
var LOCK_RETRY_DELAY_MS = 50;
|
|
259
|
-
var FILE_MODE = 420;
|
|
260
|
-
var DIR_MODE = 493;
|
|
261
|
-
function isValidRoute(value) {
|
|
262
|
-
return typeof value === "object" && value !== null && typeof value.hostname === "string" && typeof value.port === "number" && typeof value.pid === "number";
|
|
263
|
-
}
|
|
264
|
-
var RouteStore = class _RouteStore {
|
|
265
|
-
/** The state directory path. */
|
|
266
|
-
dir;
|
|
267
|
-
routesPath;
|
|
268
|
-
lockPath;
|
|
269
|
-
pidPath;
|
|
270
|
-
portFilePath;
|
|
271
|
-
onWarning;
|
|
272
|
-
constructor(dir, options) {
|
|
273
|
-
this.dir = dir;
|
|
274
|
-
this.routesPath = path.join(dir, "routes.json");
|
|
275
|
-
this.lockPath = path.join(dir, "routes.lock");
|
|
276
|
-
this.pidPath = path.join(dir, "proxy.pid");
|
|
277
|
-
this.portFilePath = path.join(dir, "proxy.port");
|
|
278
|
-
this.onWarning = options?.onWarning;
|
|
279
|
-
}
|
|
280
|
-
ensureDir() {
|
|
281
|
-
if (!fs.existsSync(this.dir)) {
|
|
282
|
-
fs.mkdirSync(this.dir, { recursive: true, mode: DIR_MODE });
|
|
283
|
-
}
|
|
284
|
-
try {
|
|
285
|
-
fs.chmodSync(this.dir, DIR_MODE);
|
|
286
|
-
} catch {
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
getRoutesPath() {
|
|
290
|
-
return this.routesPath;
|
|
291
|
-
}
|
|
292
|
-
// -- Locking ---------------------------------------------------------------
|
|
293
|
-
static sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
294
|
-
syncSleep(ms) {
|
|
295
|
-
Atomics.wait(_RouteStore.sleepBuffer, 0, 0, ms);
|
|
296
|
-
}
|
|
297
|
-
acquireLock(maxRetries = LOCK_MAX_RETRIES, retryDelayMs = LOCK_RETRY_DELAY_MS) {
|
|
298
|
-
for (let i = 0; i < maxRetries; i++) {
|
|
299
|
-
try {
|
|
300
|
-
fs.mkdirSync(this.lockPath);
|
|
301
|
-
return true;
|
|
302
|
-
} catch (err) {
|
|
303
|
-
if (isErrnoException(err) && err.code === "EEXIST") {
|
|
304
|
-
try {
|
|
305
|
-
const stat = fs.statSync(this.lockPath);
|
|
306
|
-
if (Date.now() - stat.mtimeMs > STALE_LOCK_THRESHOLD_MS) {
|
|
307
|
-
fs.rmSync(this.lockPath, { recursive: true });
|
|
308
|
-
continue;
|
|
309
|
-
}
|
|
310
|
-
} catch {
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
this.syncSleep(retryDelayMs);
|
|
314
|
-
} else {
|
|
315
|
-
return false;
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
return false;
|
|
320
|
-
}
|
|
321
|
-
releaseLock() {
|
|
322
|
-
try {
|
|
323
|
-
fs.rmSync(this.lockPath, { recursive: true });
|
|
324
|
-
} catch {
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
// -- Route I/O -------------------------------------------------------------
|
|
328
|
-
isProcessAlive(pid) {
|
|
329
|
-
try {
|
|
330
|
-
process.kill(pid, 0);
|
|
331
|
-
return true;
|
|
332
|
-
} catch {
|
|
333
|
-
return false;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
/**
|
|
337
|
-
* Load routes from disk, filtering out stale entries whose owning process
|
|
338
|
-
* is no longer alive. Stale-route cleanup is only persisted when the caller
|
|
339
|
-
* already holds the lock (i.e. inside addRoute/removeRoute) to avoid
|
|
340
|
-
* unprotected concurrent writes.
|
|
341
|
-
*/
|
|
342
|
-
loadRoutes(persistCleanup = false) {
|
|
343
|
-
if (!fs.existsSync(this.routesPath)) {
|
|
344
|
-
return [];
|
|
345
|
-
}
|
|
346
|
-
try {
|
|
347
|
-
const raw = fs.readFileSync(this.routesPath, "utf-8");
|
|
348
|
-
let parsed;
|
|
349
|
-
try {
|
|
350
|
-
parsed = JSON.parse(raw);
|
|
351
|
-
} catch {
|
|
352
|
-
this.onWarning?.(`Corrupted routes file (invalid JSON): ${this.routesPath}`);
|
|
353
|
-
return [];
|
|
354
|
-
}
|
|
355
|
-
if (!Array.isArray(parsed)) {
|
|
356
|
-
this.onWarning?.(`Corrupted routes file (expected array): ${this.routesPath}`);
|
|
357
|
-
return [];
|
|
358
|
-
}
|
|
359
|
-
const routes = parsed.filter(isValidRoute);
|
|
360
|
-
const alive = routes.filter((r) => this.isProcessAlive(r.pid));
|
|
361
|
-
if (persistCleanup && alive.length !== routes.length) {
|
|
362
|
-
try {
|
|
363
|
-
fs.writeFileSync(this.routesPath, JSON.stringify(alive, null, 2), { mode: FILE_MODE });
|
|
364
|
-
} catch {
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
return alive;
|
|
368
|
-
} catch {
|
|
369
|
-
return [];
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
saveRoutes(routes) {
|
|
373
|
-
fs.writeFileSync(this.routesPath, JSON.stringify(routes, null, 2), { mode: FILE_MODE });
|
|
374
|
-
}
|
|
375
|
-
addRoute(hostname, port, pid) {
|
|
376
|
-
this.ensureDir();
|
|
377
|
-
if (!this.acquireLock()) {
|
|
378
|
-
throw new Error("Failed to acquire route lock");
|
|
379
|
-
}
|
|
380
|
-
try {
|
|
381
|
-
const routes = this.loadRoutes(true).filter((r) => r.hostname !== hostname);
|
|
382
|
-
routes.push({ hostname, port, pid });
|
|
383
|
-
this.saveRoutes(routes);
|
|
384
|
-
} finally {
|
|
385
|
-
this.releaseLock();
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
removeRoute(hostname) {
|
|
389
|
-
this.ensureDir();
|
|
390
|
-
if (!this.acquireLock()) {
|
|
391
|
-
throw new Error("Failed to acquire route lock");
|
|
392
|
-
}
|
|
393
|
-
try {
|
|
394
|
-
const routes = this.loadRoutes(true).filter((r) => r.hostname !== hostname);
|
|
395
|
-
this.saveRoutes(routes);
|
|
396
|
-
} finally {
|
|
397
|
-
this.releaseLock();
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
};
|
|
401
|
-
|
|
402
|
-
export {
|
|
403
|
-
isErrnoException,
|
|
404
|
-
escapeHtml,
|
|
405
|
-
formatUrl,
|
|
406
|
-
parseHostname,
|
|
407
|
-
PORTLESS_HEADER,
|
|
408
|
-
createProxyServer,
|
|
409
|
-
FILE_MODE,
|
|
410
|
-
DIR_MODE,
|
|
411
|
-
RouteStore
|
|
412
|
-
};
|