peakroute 0.5.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.
@@ -0,0 +1,846 @@
1
+ // src/platform.ts
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ var IS_WINDOWS = process.platform === "win32";
5
+ var IS_MACOS = process.platform === "darwin";
6
+ var IS_LINUX = process.platform === "linux";
7
+ var SYSTEM_STATE_DIR = IS_WINDOWS ? path.join(os.tmpdir(), "peakroute") : "/tmp/peakroute";
8
+ var USER_STATE_DIR = path.join(os.homedir(), ".peakroute");
9
+ var PRIVILEGED_PORT_THRESHOLD = 1024;
10
+
11
+ // src/utils.ts
12
+ import * as fs from "fs";
13
+ function chmodSafe(path4, mode) {
14
+ if (IS_WINDOWS) return;
15
+ try {
16
+ fs.chmodSync(path4, mode);
17
+ } catch {
18
+ }
19
+ }
20
+ async function chmodSafeAsync(path4, mode) {
21
+ if (IS_WINDOWS) return;
22
+ try {
23
+ await fs.promises.chmod(path4, mode);
24
+ } catch {
25
+ }
26
+ }
27
+ function fixOwnership(...paths) {
28
+ if (IS_WINDOWS) return;
29
+ const uid = process.env.SUDO_UID;
30
+ const gid = process.env.SUDO_GID;
31
+ if (!uid || process.getuid?.() !== 0) return;
32
+ for (const p of paths) {
33
+ try {
34
+ fs.chownSync(p, parseInt(uid, 10), parseInt(gid || uid, 10));
35
+ } catch {
36
+ }
37
+ }
38
+ }
39
+ function isErrnoException(err) {
40
+ return err instanceof Error && "code" in err && typeof err.code === "string";
41
+ }
42
+ function escapeHtml(str) {
43
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
44
+ }
45
+ function formatUrl(hostname, proxyPort, tls = false) {
46
+ const proto = tls ? "https" : "http";
47
+ const defaultPort = tls ? 443 : 80;
48
+ return proxyPort === defaultPort ? `${proto}://${hostname}` : `${proto}://${hostname}:${proxyPort}`;
49
+ }
50
+ function parseHostname(input) {
51
+ let hostname = input.trim().replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
52
+ if (!hostname || hostname === ".localhost") {
53
+ throw new Error("Hostname cannot be empty");
54
+ }
55
+ if (!hostname.endsWith(".localhost")) {
56
+ hostname = `${hostname}.localhost`;
57
+ }
58
+ const name = hostname.replace(/\.localhost$/, "");
59
+ if (name.includes("..")) {
60
+ throw new Error(`Invalid hostname "${name}": consecutive dots are not allowed`);
61
+ }
62
+ if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) {
63
+ throw new Error(
64
+ `Invalid hostname "${name}": must contain only lowercase letters, digits, hyphens, and dots`
65
+ );
66
+ }
67
+ return hostname;
68
+ }
69
+
70
+ // src/proxy.ts
71
+ import * as http from "http";
72
+ import * as http2 from "http2";
73
+ import * as net from "net";
74
+ var PEAKROUTE_HEADER = "X-Peakroute";
75
+ var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
76
+ "connection",
77
+ "keep-alive",
78
+ "proxy-connection",
79
+ "transfer-encoding",
80
+ "upgrade"
81
+ ]);
82
+ function getRequestHost(req) {
83
+ const authority = req.headers[":authority"];
84
+ if (typeof authority === "string" && authority) return authority;
85
+ return req.headers.host || "";
86
+ }
87
+ function buildForwardedHeaders(req, tls) {
88
+ const headers = {};
89
+ const remoteAddress = req.socket.remoteAddress || "127.0.0.1";
90
+ const proto = tls ? "https" : "http";
91
+ const defaultPort = tls ? "443" : "80";
92
+ const hostHeader = getRequestHost(req);
93
+ headers["x-forwarded-for"] = req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remoteAddress}` : remoteAddress;
94
+ headers["x-forwarded-proto"] = req.headers["x-forwarded-proto"] || proto;
95
+ headers["x-forwarded-host"] = req.headers["x-forwarded-host"] || hostHeader;
96
+ headers["x-forwarded-port"] = req.headers["x-forwarded-port"] || hostHeader.split(":")[1] || defaultPort;
97
+ return headers;
98
+ }
99
+ var PEAKROUTE_HOPS_HEADER = "x-peakroute-hops";
100
+ var MAX_PROXY_HOPS = 5;
101
+ function createProxyServer(options) {
102
+ const { getRoutes, proxyPort, onError = (msg) => console.error(msg), tls } = options;
103
+ const isTls = !!tls;
104
+ const handleRequest = (req, res) => {
105
+ res.setHeader(PEAKROUTE_HEADER, "1");
106
+ const routes = getRoutes();
107
+ const host = getRequestHost(req).split(":")[0];
108
+ if (!host) {
109
+ res.writeHead(400, { "Content-Type": "text/plain" });
110
+ res.end("Missing Host header");
111
+ return;
112
+ }
113
+ const hops = parseInt(req.headers[PEAKROUTE_HOPS_HEADER], 10) || 0;
114
+ if (hops >= MAX_PROXY_HOPS) {
115
+ onError(
116
+ `Loop detected for ${host}: request has passed through peakroute ${hops} times. This usually means a backend is proxying back through peakroute without rewriting the Host header. If you use Vite/webpack proxy, set changeOrigin: true.`
117
+ );
118
+ res.writeHead(508, { "Content-Type": "text/plain" });
119
+ res.end(
120
+ `Loop Detected: this request has passed through peakroute ${hops} times.
121
+
122
+ This usually means a dev server (Vite, webpack, etc.) is proxying
123
+ requests back through peakroute without rewriting the Host header.
124
+
125
+ Fix: add changeOrigin: true to your proxy config, e.g.:
126
+
127
+ proxy: {
128
+ "/api": {
129
+ target: "http://<backend>.localhost:<port>",
130
+ changeOrigin: true,
131
+ },
132
+ }
133
+ `
134
+ );
135
+ return;
136
+ }
137
+ const route = routes.find((r) => r.hostname === host);
138
+ if (!route) {
139
+ const safeHost = escapeHtml(host);
140
+ res.writeHead(404, { "Content-Type": "text/html" });
141
+ res.end(`
142
+ <html>
143
+ <head><title>peakroute - Not Found</title></head>
144
+ <body style="font-family: system-ui; padding: 40px; max-width: 600px; margin: 0 auto;">
145
+ <h1>Not Found</h1>
146
+ <p>No app registered for <strong>${safeHost}</strong></p>
147
+ ${routes.length > 0 ? `
148
+ <h2>Active apps:</h2>
149
+ <ul>
150
+ ${routes.map((r) => `<li><a href="${escapeHtml(formatUrl(r.hostname, proxyPort, isTls))}">${escapeHtml(r.hostname)}</a> - localhost:${escapeHtml(String(r.port))}</li>`).join("")}
151
+ </ul>
152
+ ` : "<p><em>No apps running.</em></p>"}
153
+ <p>Start an app with: <code>peakroute ${safeHost.replace(".localhost", "")} your-command</code></p>
154
+ </body>
155
+ </html>
156
+ `);
157
+ return;
158
+ }
159
+ const forwardedHeaders = buildForwardedHeaders(req, isTls);
160
+ const proxyReqHeaders = { ...req.headers };
161
+ for (const [key, value] of Object.entries(forwardedHeaders)) {
162
+ proxyReqHeaders[key] = value;
163
+ }
164
+ proxyReqHeaders[PEAKROUTE_HOPS_HEADER] = String(hops + 1);
165
+ for (const key of Object.keys(proxyReqHeaders)) {
166
+ if (key.startsWith(":")) {
167
+ delete proxyReqHeaders[key];
168
+ }
169
+ }
170
+ const proxyReq = http.request(
171
+ {
172
+ hostname: "127.0.0.1",
173
+ port: route.port,
174
+ path: req.url,
175
+ method: req.method,
176
+ headers: proxyReqHeaders
177
+ },
178
+ (proxyRes) => {
179
+ const responseHeaders = { ...proxyRes.headers };
180
+ if (isTls) {
181
+ for (const h of HOP_BY_HOP_HEADERS) {
182
+ delete responseHeaders[h];
183
+ }
184
+ }
185
+ res.writeHead(proxyRes.statusCode || 502, responseHeaders);
186
+ proxyRes.pipe(res);
187
+ }
188
+ );
189
+ proxyReq.on("error", (err) => {
190
+ onError(`Proxy error for ${getRequestHost(req)}: ${err.message}`);
191
+ if (!res.headersSent) {
192
+ const errWithCode = err;
193
+ 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.";
194
+ res.writeHead(502, { "Content-Type": "text/plain" });
195
+ res.end(message);
196
+ }
197
+ });
198
+ res.on("close", () => {
199
+ if (!proxyReq.destroyed) {
200
+ proxyReq.destroy();
201
+ }
202
+ });
203
+ req.on("error", () => {
204
+ if (!proxyReq.destroyed) {
205
+ proxyReq.destroy();
206
+ }
207
+ });
208
+ req.pipe(proxyReq);
209
+ };
210
+ const handleUpgrade = (req, socket, head) => {
211
+ const hops = parseInt(req.headers[PEAKROUTE_HOPS_HEADER], 10) || 0;
212
+ if (hops >= MAX_PROXY_HOPS) {
213
+ const host2 = getRequestHost(req).split(":")[0];
214
+ onError(
215
+ `WebSocket loop detected for ${host2}: request has passed through peakroute ${hops} times. Set changeOrigin: true in your proxy config.`
216
+ );
217
+ socket.end(
218
+ "HTTP/1.1 508 Loop Detected\r\nContent-Type: text/plain\r\n\r\nLoop Detected: request has passed through peakroute too many times.\nAdd changeOrigin: true to your dev server proxy config.\n"
219
+ );
220
+ return;
221
+ }
222
+ const routes = getRoutes();
223
+ const host = getRequestHost(req).split(":")[0];
224
+ const route = routes.find((r) => r.hostname === host);
225
+ if (!route) {
226
+ socket.destroy();
227
+ return;
228
+ }
229
+ const forwardedHeaders = buildForwardedHeaders(req, isTls);
230
+ const proxyReqHeaders = { ...req.headers };
231
+ for (const [key, value] of Object.entries(forwardedHeaders)) {
232
+ proxyReqHeaders[key] = value;
233
+ }
234
+ proxyReqHeaders[PEAKROUTE_HOPS_HEADER] = String(hops + 1);
235
+ for (const key of Object.keys(proxyReqHeaders)) {
236
+ if (key.startsWith(":")) {
237
+ delete proxyReqHeaders[key];
238
+ }
239
+ }
240
+ const proxyReq = http.request({
241
+ hostname: "127.0.0.1",
242
+ port: route.port,
243
+ path: req.url,
244
+ method: req.method,
245
+ headers: proxyReqHeaders
246
+ });
247
+ proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
248
+ let response = `HTTP/1.1 101 Switching Protocols\r
249
+ `;
250
+ for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) {
251
+ response += `${proxyRes.rawHeaders[i]}: ${proxyRes.rawHeaders[i + 1]}\r
252
+ `;
253
+ }
254
+ response += "\r\n";
255
+ socket.write(response);
256
+ if (proxyHead.length > 0) {
257
+ socket.write(proxyHead);
258
+ }
259
+ proxySocket.pipe(socket);
260
+ socket.pipe(proxySocket);
261
+ proxySocket.on("error", () => socket.destroy());
262
+ socket.on("error", () => proxySocket.destroy());
263
+ });
264
+ proxyReq.on("error", (err) => {
265
+ onError(`WebSocket proxy error for ${getRequestHost(req)}: ${err.message}`);
266
+ socket.destroy();
267
+ });
268
+ proxyReq.on("response", (res) => {
269
+ if (!socket.destroyed) {
270
+ let response = `HTTP/1.1 ${res.statusCode} ${res.statusMessage}\r
271
+ `;
272
+ for (let i = 0; i < res.rawHeaders.length; i += 2) {
273
+ response += `${res.rawHeaders[i]}: ${res.rawHeaders[i + 1]}\r
274
+ `;
275
+ }
276
+ response += "\r\n";
277
+ socket.write(response);
278
+ res.pipe(socket);
279
+ }
280
+ });
281
+ if (head.length > 0) {
282
+ proxyReq.write(head);
283
+ }
284
+ proxyReq.end();
285
+ };
286
+ if (tls) {
287
+ const h2Server = http2.createSecureServer({
288
+ cert: tls.cert,
289
+ key: tls.key,
290
+ allowHTTP1: true,
291
+ ...tls.SNICallback ? { SNICallback: tls.SNICallback } : {}
292
+ });
293
+ h2Server.on("request", (req, res) => {
294
+ handleRequest(req, res);
295
+ });
296
+ h2Server.on("upgrade", (req, socket, head) => {
297
+ handleUpgrade(req, socket, head);
298
+ });
299
+ const plainServer = http.createServer(handleRequest);
300
+ plainServer.on("upgrade", handleUpgrade);
301
+ const wrapper = net.createServer((socket) => {
302
+ socket.once("readable", () => {
303
+ const buf = socket.read(1);
304
+ if (!buf) {
305
+ socket.destroy();
306
+ return;
307
+ }
308
+ socket.unshift(buf);
309
+ if (buf[0] === 22) {
310
+ h2Server.emit("connection", socket);
311
+ } else {
312
+ plainServer.emit("connection", socket);
313
+ }
314
+ });
315
+ });
316
+ const origClose = wrapper.close.bind(wrapper);
317
+ wrapper.close = function(cb) {
318
+ h2Server.close();
319
+ plainServer.close();
320
+ return origClose(cb);
321
+ };
322
+ return wrapper;
323
+ }
324
+ const httpServer = http.createServer(handleRequest);
325
+ httpServer.on("upgrade", handleUpgrade);
326
+ return httpServer;
327
+ }
328
+
329
+ // src/cli-utils.ts
330
+ import * as fs2 from "fs";
331
+ import * as http3 from "http";
332
+ import * as https from "https";
333
+ import * as net2 from "net";
334
+ import * as path2 from "path";
335
+ import * as readline from "readline";
336
+ import { execSync, spawn } from "child_process";
337
+ var DEFAULT_PROXY_PORT = 1355;
338
+ var MIN_APP_PORT = 4e3;
339
+ var MAX_APP_PORT = 4999;
340
+ var RANDOM_PORT_ATTEMPTS = 50;
341
+ var SOCKET_TIMEOUT_MS = 500;
342
+ var LSOF_TIMEOUT_MS = 5e3;
343
+ var WAIT_FOR_PROXY_MAX_ATTEMPTS = 20;
344
+ var WAIT_FOR_PROXY_INTERVAL_MS = 250;
345
+ var SIGNAL_CODES = {
346
+ SIGHUP: 1,
347
+ SIGINT: 2,
348
+ SIGQUIT: 3,
349
+ SIGABRT: 6,
350
+ SIGKILL: 9,
351
+ SIGTERM: 15
352
+ };
353
+ function getDefaultPort() {
354
+ const envPort = process.env.PEAKROUTE_PORT;
355
+ if (envPort) {
356
+ const port = parseInt(envPort, 10);
357
+ if (!isNaN(port) && port >= 1 && port <= 65535) return port;
358
+ }
359
+ return DEFAULT_PROXY_PORT;
360
+ }
361
+ function resolveStateDir(port) {
362
+ if (process.env.PEAKROUTE_STATE_DIR) return process.env.PEAKROUTE_STATE_DIR;
363
+ if (IS_WINDOWS) return USER_STATE_DIR;
364
+ return port < PRIVILEGED_PORT_THRESHOLD ? SYSTEM_STATE_DIR : USER_STATE_DIR;
365
+ }
366
+ function readPortFromDir(dir) {
367
+ try {
368
+ const raw = fs2.readFileSync(path2.join(dir, "proxy.port"), "utf-8").trim();
369
+ const port = parseInt(raw, 10);
370
+ return isNaN(port) ? null : port;
371
+ } catch {
372
+ return null;
373
+ }
374
+ }
375
+ var TLS_MARKER_FILE = "proxy.tls";
376
+ function readTlsMarker(dir) {
377
+ try {
378
+ return fs2.existsSync(path2.join(dir, TLS_MARKER_FILE));
379
+ } catch {
380
+ return false;
381
+ }
382
+ }
383
+ function writeTlsMarker(dir, enabled) {
384
+ const markerPath = path2.join(dir, TLS_MARKER_FILE);
385
+ if (enabled) {
386
+ fs2.writeFileSync(markerPath, "1", { mode: 420 });
387
+ } else {
388
+ try {
389
+ fs2.unlinkSync(markerPath);
390
+ } catch {
391
+ }
392
+ }
393
+ }
394
+ function isHttpsEnvEnabled() {
395
+ const val = process.env.PEAKROUTE_HTTPS;
396
+ return val === "1" || val === "true";
397
+ }
398
+ async function discoverState() {
399
+ if (process.env.PEAKROUTE_STATE_DIR) {
400
+ const dir = process.env.PEAKROUTE_STATE_DIR;
401
+ const port = readPortFromDir(dir) ?? getDefaultPort();
402
+ const tls = readTlsMarker(dir);
403
+ return { dir, port, tls };
404
+ }
405
+ const userPort = readPortFromDir(USER_STATE_DIR);
406
+ if (userPort !== null) {
407
+ const tls = readTlsMarker(USER_STATE_DIR);
408
+ if (await isProxyRunning(userPort, tls)) {
409
+ return { dir: USER_STATE_DIR, port: userPort, tls };
410
+ }
411
+ }
412
+ const systemPort = readPortFromDir(SYSTEM_STATE_DIR);
413
+ if (systemPort !== null) {
414
+ const tls = readTlsMarker(SYSTEM_STATE_DIR);
415
+ if (await isProxyRunning(systemPort, tls)) {
416
+ return { dir: SYSTEM_STATE_DIR, port: systemPort, tls };
417
+ }
418
+ }
419
+ const defaultPort = getDefaultPort();
420
+ return { dir: resolveStateDir(defaultPort), port: defaultPort, tls: false };
421
+ }
422
+ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
423
+ if (minPort > maxPort) {
424
+ throw new Error(`minPort (${minPort}) must be <= maxPort (${maxPort})`);
425
+ }
426
+ const tryPort = (port) => {
427
+ return new Promise((resolve) => {
428
+ const server = net2.createServer();
429
+ server.listen(port, () => {
430
+ server.close(() => resolve(true));
431
+ });
432
+ server.on("error", () => resolve(false));
433
+ });
434
+ };
435
+ for (let i = 0; i < RANDOM_PORT_ATTEMPTS; i++) {
436
+ const port = minPort + Math.floor(Math.random() * (maxPort - minPort + 1));
437
+ if (await tryPort(port)) {
438
+ return port;
439
+ }
440
+ }
441
+ for (let port = minPort; port <= maxPort; port++) {
442
+ if (await tryPort(port)) {
443
+ return port;
444
+ }
445
+ }
446
+ throw new Error(`No free port found in range ${minPort}-${maxPort}`);
447
+ }
448
+ function isProxyRunning(port, tls = false) {
449
+ return new Promise((resolve) => {
450
+ const requestFn = tls ? https.request : http3.request;
451
+ const req = requestFn(
452
+ {
453
+ hostname: "127.0.0.1",
454
+ port,
455
+ path: "/",
456
+ method: "HEAD",
457
+ timeout: SOCKET_TIMEOUT_MS,
458
+ ...tls ? { rejectUnauthorized: false } : {}
459
+ },
460
+ (res) => {
461
+ res.resume();
462
+ resolve(res.headers[PEAKROUTE_HEADER.toLowerCase()] === "1");
463
+ }
464
+ );
465
+ req.on("error", () => resolve(false));
466
+ req.on("timeout", () => {
467
+ req.destroy();
468
+ resolve(false);
469
+ });
470
+ req.end();
471
+ });
472
+ }
473
+ function parsePidFromNetstat(output, port) {
474
+ const lines = output.split("\n");
475
+ for (const line of lines) {
476
+ const parts = line.trim().split(/\s+/);
477
+ if (parts.length < 5) continue;
478
+ const localAddr = parts[1];
479
+ const addrMatch = localAddr.match(/:(\d+)$/);
480
+ if (!addrMatch) continue;
481
+ const foundPort = parseInt(addrMatch[1], 10);
482
+ if (foundPort === port && parts[3] === "LISTENING") {
483
+ const pid = parseInt(parts[4], 10);
484
+ if (!isNaN(pid)) return pid;
485
+ }
486
+ }
487
+ return null;
488
+ }
489
+ function findPidOnPort(port) {
490
+ if (IS_WINDOWS) {
491
+ try {
492
+ const output = execSync(`netstat -ano -p tcp`, {
493
+ encoding: "utf-8",
494
+ timeout: LSOF_TIMEOUT_MS
495
+ });
496
+ return parsePidFromNetstat(output, port);
497
+ } catch {
498
+ return null;
499
+ }
500
+ }
501
+ try {
502
+ const output = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
503
+ encoding: "utf-8",
504
+ timeout: LSOF_TIMEOUT_MS
505
+ });
506
+ const pid = parseInt(output.trim().split("\n")[0], 10);
507
+ return isNaN(pid) ? null : pid;
508
+ } catch {
509
+ return null;
510
+ }
511
+ }
512
+ async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS, tls = false) {
513
+ for (let i = 0; i < maxAttempts; i++) {
514
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
515
+ if (await isProxyRunning(port, tls)) {
516
+ return true;
517
+ }
518
+ }
519
+ return false;
520
+ }
521
+ function shellEscape(arg) {
522
+ return `'${arg.replace(/'/g, "'\\''")}'`;
523
+ }
524
+ function collectBinPaths(cwd) {
525
+ const dirs = [];
526
+ let dir = cwd;
527
+ for (; ; ) {
528
+ const bin = path2.join(dir, "node_modules", ".bin");
529
+ if (fs2.existsSync(bin)) {
530
+ dirs.push(bin);
531
+ }
532
+ const parent = path2.dirname(dir);
533
+ if (parent === dir) break;
534
+ dir = parent;
535
+ }
536
+ return dirs;
537
+ }
538
+ function augmentedPath(env) {
539
+ const base = (env ?? process.env).PATH ?? "";
540
+ const bins = collectBinPaths(process.cwd());
541
+ return bins.length > 0 ? bins.join(path2.delimiter) + path2.delimiter + base : base;
542
+ }
543
+ function spawnCommand(commandArgs, options) {
544
+ const env = { ...options?.env ?? process.env, PATH: augmentedPath(options?.env) };
545
+ let child;
546
+ if (IS_WINDOWS) {
547
+ child = spawn(commandArgs[0], commandArgs.slice(1), {
548
+ stdio: "inherit",
549
+ env,
550
+ shell: true,
551
+ windowsHide: true
552
+ // Esconde janela do console
553
+ });
554
+ } else {
555
+ const shellCmd = commandArgs.map(shellEscape).join(" ");
556
+ child = spawn("/bin/sh", ["-c", shellCmd], {
557
+ stdio: "inherit",
558
+ env
559
+ });
560
+ }
561
+ let exiting = false;
562
+ const cleanup = () => {
563
+ process.removeListener("SIGINT", onSigInt);
564
+ process.removeListener("SIGTERM", onSigTerm);
565
+ options?.onCleanup?.();
566
+ };
567
+ const handleSignal = (signal) => {
568
+ if (exiting) return;
569
+ exiting = true;
570
+ child.kill(signal);
571
+ cleanup();
572
+ process.exit(128 + (SIGNAL_CODES[signal] || 15));
573
+ };
574
+ const onSigInt = () => handleSignal("SIGINT");
575
+ const onSigTerm = () => handleSignal("SIGTERM");
576
+ process.on("SIGINT", onSigInt);
577
+ process.on("SIGTERM", onSigTerm);
578
+ child.on("error", (err) => {
579
+ if (exiting) return;
580
+ exiting = true;
581
+ console.error(`Failed to run command: ${err.message}`);
582
+ if (err.code === "ENOENT") {
583
+ console.error(`Is "${commandArgs[0]}" installed and in your PATH?`);
584
+ }
585
+ cleanup();
586
+ process.exit(1);
587
+ });
588
+ child.on("exit", (code, signal) => {
589
+ if (exiting) return;
590
+ exiting = true;
591
+ cleanup();
592
+ if (signal) {
593
+ process.exit(128 + (SIGNAL_CODES[signal] || 15));
594
+ }
595
+ process.exit(code ?? 1);
596
+ });
597
+ }
598
+ var FRAMEWORKS_NEEDING_PORT = {
599
+ vite: { strictPort: true },
600
+ "react-router": { strictPort: true },
601
+ astro: { strictPort: false },
602
+ ng: { strictPort: false }
603
+ };
604
+ function injectFrameworkFlags(commandArgs, port) {
605
+ const cmd = commandArgs[0];
606
+ if (!cmd) return;
607
+ const basename2 = path2.basename(cmd);
608
+ const framework = FRAMEWORKS_NEEDING_PORT[basename2];
609
+ if (!framework) return;
610
+ if (!commandArgs.includes("--port")) {
611
+ commandArgs.push("--port", port.toString());
612
+ if (framework.strictPort) {
613
+ commandArgs.push("--strictPort");
614
+ }
615
+ }
616
+ if (!commandArgs.includes("--host")) {
617
+ commandArgs.push("--host", "127.0.0.1");
618
+ }
619
+ }
620
+ function prompt(question) {
621
+ const rl = readline.createInterface({
622
+ input: process.stdin,
623
+ output: process.stdout
624
+ });
625
+ return new Promise((resolve) => {
626
+ rl.on("close", () => resolve(""));
627
+ rl.question(question, (answer) => {
628
+ rl.close();
629
+ resolve(answer.trim().toLowerCase());
630
+ });
631
+ });
632
+ }
633
+
634
+ // src/routes.ts
635
+ import * as fs3 from "fs";
636
+ import * as path3 from "path";
637
+ var STALE_LOCK_THRESHOLD_MS = 1e4;
638
+ var LOCK_MAX_RETRIES = 20;
639
+ var LOCK_RETRY_DELAY_MS = 50;
640
+ var FILE_MODE = 420;
641
+ var DIR_MODE = 493;
642
+ var SYSTEM_DIR_MODE = 1023;
643
+ var SYSTEM_FILE_MODE = 438;
644
+ function isValidRoute(value) {
645
+ return typeof value === "object" && value !== null && typeof value.hostname === "string" && typeof value.port === "number" && typeof value.pid === "number";
646
+ }
647
+ var RouteConflictError = class extends Error {
648
+ hostname;
649
+ existingPid;
650
+ constructor(hostname, existingPid) {
651
+ super(
652
+ `"${hostname}" is already registered by a running process (PID ${existingPid}). Use --force to override.`
653
+ );
654
+ this.name = "RouteConflictError";
655
+ this.hostname = hostname;
656
+ this.existingPid = existingPid;
657
+ }
658
+ };
659
+ var RouteStore = class _RouteStore {
660
+ /** The state directory path. */
661
+ dir;
662
+ routesPath;
663
+ lockPath;
664
+ pidPath;
665
+ portFilePath;
666
+ onWarning;
667
+ constructor(dir, options) {
668
+ this.dir = dir;
669
+ this.routesPath = path3.join(dir, "routes.json");
670
+ this.lockPath = path3.join(dir, "routes.lock");
671
+ this.pidPath = path3.join(dir, "proxy.pid");
672
+ this.portFilePath = path3.join(dir, "proxy.port");
673
+ this.onWarning = options?.onWarning;
674
+ }
675
+ isSystemDir() {
676
+ return this.dir === SYSTEM_STATE_DIR;
677
+ }
678
+ get dirMode() {
679
+ return this.isSystemDir() ? SYSTEM_DIR_MODE : DIR_MODE;
680
+ }
681
+ get fileMode() {
682
+ return this.isSystemDir() ? SYSTEM_FILE_MODE : FILE_MODE;
683
+ }
684
+ ensureDir() {
685
+ if (!fs3.existsSync(this.dir)) {
686
+ fs3.mkdirSync(this.dir, { recursive: true, mode: this.dirMode });
687
+ }
688
+ try {
689
+ fs3.chmodSync(this.dir, this.dirMode);
690
+ } catch {
691
+ }
692
+ fixOwnership(this.dir);
693
+ }
694
+ getRoutesPath() {
695
+ return this.routesPath;
696
+ }
697
+ // -- Locking ---------------------------------------------------------------
698
+ static sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
699
+ syncSleep(ms) {
700
+ Atomics.wait(_RouteStore.sleepBuffer, 0, 0, ms);
701
+ }
702
+ acquireLock(maxRetries = LOCK_MAX_RETRIES, retryDelayMs = LOCK_RETRY_DELAY_MS) {
703
+ for (let i = 0; i < maxRetries; i++) {
704
+ try {
705
+ fs3.mkdirSync(this.lockPath);
706
+ return true;
707
+ } catch (err) {
708
+ if (isErrnoException(err) && err.code === "EEXIST") {
709
+ try {
710
+ const stat = fs3.statSync(this.lockPath);
711
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_THRESHOLD_MS) {
712
+ fs3.rmSync(this.lockPath, { recursive: true });
713
+ continue;
714
+ }
715
+ } catch {
716
+ continue;
717
+ }
718
+ this.syncSleep(retryDelayMs);
719
+ } else {
720
+ return false;
721
+ }
722
+ }
723
+ }
724
+ return false;
725
+ }
726
+ releaseLock() {
727
+ try {
728
+ fs3.rmSync(this.lockPath, { recursive: true });
729
+ } catch {
730
+ }
731
+ }
732
+ // -- Route I/O -------------------------------------------------------------
733
+ isProcessAlive(pid) {
734
+ try {
735
+ process.kill(pid, 0);
736
+ return true;
737
+ } catch {
738
+ return false;
739
+ }
740
+ }
741
+ /**
742
+ * Load routes from disk, filtering out stale entries whose owning process
743
+ * is no longer alive. Stale-route cleanup is only persisted when the caller
744
+ * already holds the lock (i.e. inside addRoute/removeRoute) to avoid
745
+ * unprotected concurrent writes.
746
+ */
747
+ loadRoutes(persistCleanup = false) {
748
+ if (!fs3.existsSync(this.routesPath)) {
749
+ return [];
750
+ }
751
+ try {
752
+ const raw = fs3.readFileSync(this.routesPath, "utf-8");
753
+ let parsed;
754
+ try {
755
+ parsed = JSON.parse(raw);
756
+ } catch {
757
+ this.onWarning?.(`Corrupted routes file (invalid JSON): ${this.routesPath}`);
758
+ return [];
759
+ }
760
+ if (!Array.isArray(parsed)) {
761
+ this.onWarning?.(`Corrupted routes file (expected array): ${this.routesPath}`);
762
+ return [];
763
+ }
764
+ const routes = parsed.filter(isValidRoute);
765
+ const alive = routes.filter((r) => this.isProcessAlive(r.pid));
766
+ if (persistCleanup && alive.length !== routes.length) {
767
+ try {
768
+ fs3.writeFileSync(this.routesPath, JSON.stringify(alive, null, 2), {
769
+ mode: this.fileMode
770
+ });
771
+ } catch {
772
+ }
773
+ }
774
+ return alive;
775
+ } catch {
776
+ return [];
777
+ }
778
+ }
779
+ saveRoutes(routes) {
780
+ fs3.writeFileSync(this.routesPath, JSON.stringify(routes, null, 2), { mode: this.fileMode });
781
+ fixOwnership(this.routesPath);
782
+ }
783
+ addRoute(hostname, port, pid, force = false) {
784
+ this.ensureDir();
785
+ if (!this.acquireLock()) {
786
+ throw new Error("Failed to acquire route lock");
787
+ }
788
+ try {
789
+ const routes = this.loadRoutes(true);
790
+ const existing = routes.find((r) => r.hostname === hostname);
791
+ if (existing && existing.pid !== pid && this.isProcessAlive(existing.pid) && !force) {
792
+ throw new RouteConflictError(hostname, existing.pid);
793
+ }
794
+ const filtered = routes.filter((r) => r.hostname !== hostname);
795
+ filtered.push({ hostname, port, pid });
796
+ this.saveRoutes(filtered);
797
+ } finally {
798
+ this.releaseLock();
799
+ }
800
+ }
801
+ removeRoute(hostname) {
802
+ this.ensureDir();
803
+ if (!this.acquireLock()) {
804
+ throw new Error("Failed to acquire route lock");
805
+ }
806
+ try {
807
+ const routes = this.loadRoutes(true).filter((r) => r.hostname !== hostname);
808
+ this.saveRoutes(routes);
809
+ } finally {
810
+ this.releaseLock();
811
+ }
812
+ }
813
+ };
814
+
815
+ export {
816
+ IS_WINDOWS,
817
+ PRIVILEGED_PORT_THRESHOLD,
818
+ chmodSafe,
819
+ chmodSafeAsync,
820
+ fixOwnership,
821
+ isErrnoException,
822
+ escapeHtml,
823
+ formatUrl,
824
+ parseHostname,
825
+ PEAKROUTE_HEADER,
826
+ createProxyServer,
827
+ getDefaultPort,
828
+ resolveStateDir,
829
+ readTlsMarker,
830
+ writeTlsMarker,
831
+ isHttpsEnvEnabled,
832
+ discoverState,
833
+ findFreePort,
834
+ isProxyRunning,
835
+ findPidOnPort,
836
+ waitForProxy,
837
+ spawnCommand,
838
+ injectFrameworkFlags,
839
+ prompt,
840
+ FILE_MODE,
841
+ DIR_MODE,
842
+ SYSTEM_DIR_MODE,
843
+ SYSTEM_FILE_MODE,
844
+ RouteConflictError,
845
+ RouteStore
846
+ };