@elisoncampos/local-router 0.1.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.
Files changed (3) hide show
  1. package/README.md +115 -0
  2. package/dist/cli.js +1956 -0
  3. package/package.json +50 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1956 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import chalk from "chalk";
5
+ import * as fs8 from "fs";
6
+ import * as path7 from "path";
7
+ import { spawn as spawn2, spawnSync } from "child_process";
8
+
9
+ // src/certs.ts
10
+ import * as crypto from "crypto";
11
+ import { execFile as execFileCb, execFileSync } from "child_process";
12
+ import * as fs2 from "fs";
13
+ import * as path from "path";
14
+ import * as tls from "tls";
15
+ import { promisify } from "util";
16
+
17
+ // src/utils.ts
18
+ import * as fs from "fs";
19
+ function fixOwnership(...paths) {
20
+ if (process.platform === "win32") return;
21
+ const uid = process.env.SUDO_UID;
22
+ const gid = process.env.SUDO_GID;
23
+ if (!uid || process.getuid?.() !== 0) return;
24
+ for (const targetPath of paths) {
25
+ try {
26
+ fs.chownSync(targetPath, Number(uid), Number(gid ?? uid));
27
+ } catch {
28
+ }
29
+ }
30
+ }
31
+ function isErrnoException(error) {
32
+ return error instanceof Error && "code" in error;
33
+ }
34
+ function escapeHtml(value) {
35
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
36
+ }
37
+ function formatUrl(hostname, https2, port) {
38
+ const defaultPort = https2 ? 443 : 80;
39
+ if (!port || port === defaultPort) {
40
+ return `${https2 ? "https" : "http"}://${hostname}`;
41
+ }
42
+ return `${https2 ? "https" : "http"}://${hostname}:${port}`;
43
+ }
44
+ function removeProtocol(hostname) {
45
+ return hostname.replace(/^https?:\/\//i, "").split("/")[0].trim();
46
+ }
47
+ function normalizeExplicitHostname(hostname) {
48
+ const normalized = removeProtocol(hostname).toLowerCase();
49
+ if (!normalized) {
50
+ throw new Error("Hostname cannot be empty.");
51
+ }
52
+ if (normalized.includes("..")) {
53
+ throw new Error(`Invalid hostname "${normalized}": consecutive dots are not allowed.`);
54
+ }
55
+ if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(normalized)) {
56
+ throw new Error(
57
+ `Invalid hostname "${normalized}": use lowercase letters, digits, dots, and hyphens only.`
58
+ );
59
+ }
60
+ const labels = normalized.split(".");
61
+ for (const label of labels) {
62
+ if (label.length > 63) {
63
+ throw new Error(
64
+ `Invalid hostname "${normalized}": label "${label}" exceeds the 63-character DNS limit.`
65
+ );
66
+ }
67
+ }
68
+ return normalized;
69
+ }
70
+
71
+ // src/certs.ts
72
+ var CA_VALIDITY_DAYS = 3650;
73
+ var SERVER_VALIDITY_DAYS = 365;
74
+ var EXPIRY_BUFFER_MS = 7 * 24 * 60 * 60 * 1e3;
75
+ var CA_COMMON_NAME = "local-router Local CA";
76
+ var OPENSSL_TIMEOUT_MS = 15e3;
77
+ var CA_KEY_FILE = "ca-key.pem";
78
+ var CA_CERT_FILE = "ca.pem";
79
+ var SERVER_KEY_FILE = "server-key.pem";
80
+ var SERVER_CERT_FILE = "server.pem";
81
+ var HOST_CERTS_DIR = "host-certs";
82
+ var MAX_CN_LENGTH = 64;
83
+ var execFileAsync = promisify(execFileCb);
84
+ function fileExists(filePath) {
85
+ try {
86
+ fs2.accessSync(filePath, fs2.constants.R_OK);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+ function openssl(args, options) {
93
+ try {
94
+ return execFileSync("openssl", args, {
95
+ encoding: "utf-8",
96
+ input: options?.input,
97
+ timeout: OPENSSL_TIMEOUT_MS,
98
+ stdio: ["pipe", "pipe", "pipe"]
99
+ });
100
+ } catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error);
102
+ throw new Error(
103
+ `openssl failed: ${message}
104
+
105
+ Install or expose openssl in PATH before using HTTPS.`
106
+ );
107
+ }
108
+ }
109
+ async function opensslAsync(args) {
110
+ try {
111
+ const { stdout } = await execFileAsync("openssl", args, {
112
+ encoding: "utf-8",
113
+ timeout: OPENSSL_TIMEOUT_MS
114
+ });
115
+ return stdout;
116
+ } catch (error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ throw new Error(
119
+ `openssl failed: ${message}
120
+
121
+ Install or expose openssl in PATH before using HTTPS.`
122
+ );
123
+ }
124
+ }
125
+ function isCertValid(certPath) {
126
+ try {
127
+ const cert = new crypto.X509Certificate(fs2.readFileSync(certPath, "utf-8"));
128
+ const expiry = new Date(cert.validTo).getTime();
129
+ return Date.now() + EXPIRY_BUFFER_MS < expiry;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+ function isCertSignatureStrong(certPath) {
135
+ try {
136
+ const text = openssl(["x509", "-in", certPath, "-noout", "-text"]);
137
+ const match = text.match(/Signature Algorithm:\s*(\S+)/i);
138
+ return !!match && !match[1].toLowerCase().includes("sha1");
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+ function generateCA(stateDir) {
144
+ const keyPath = path.join(stateDir, CA_KEY_FILE);
145
+ const certPath = path.join(stateDir, CA_CERT_FILE);
146
+ openssl(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", keyPath]);
147
+ openssl([
148
+ "req",
149
+ "-new",
150
+ "-x509",
151
+ "-sha256",
152
+ "-key",
153
+ keyPath,
154
+ "-out",
155
+ certPath,
156
+ "-days",
157
+ String(CA_VALIDITY_DAYS),
158
+ "-subj",
159
+ `/CN=${CA_COMMON_NAME}`,
160
+ "-addext",
161
+ "basicConstraints=critical,CA:TRUE",
162
+ "-addext",
163
+ "keyUsage=critical,keyCertSign,cRLSign"
164
+ ]);
165
+ fs2.chmodSync(keyPath, 384);
166
+ fs2.chmodSync(certPath, 420);
167
+ fixOwnership(keyPath, certPath);
168
+ return { certPath, keyPath };
169
+ }
170
+ function generateBaseServerCert(stateDir) {
171
+ const caKeyPath = path.join(stateDir, CA_KEY_FILE);
172
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
173
+ const serverKeyPath = path.join(stateDir, SERVER_KEY_FILE);
174
+ const serverCertPath = path.join(stateDir, SERVER_CERT_FILE);
175
+ const csrPath = path.join(stateDir, "server.csr");
176
+ const extPath = path.join(stateDir, "server-ext.cnf");
177
+ openssl(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", serverKeyPath]);
178
+ openssl(["req", "-new", "-key", serverKeyPath, "-out", csrPath, "-subj", "/CN=localhost"]);
179
+ fs2.writeFileSync(
180
+ extPath,
181
+ [
182
+ "authorityKeyIdentifier=keyid,issuer",
183
+ "basicConstraints=CA:FALSE",
184
+ "keyUsage=digitalSignature,keyEncipherment",
185
+ "extendedKeyUsage=serverAuth",
186
+ "subjectAltName=DNS:localhost,DNS:*.localhost"
187
+ ].join("\n") + "\n"
188
+ );
189
+ openssl([
190
+ "x509",
191
+ "-req",
192
+ "-sha256",
193
+ "-in",
194
+ csrPath,
195
+ "-CA",
196
+ caCertPath,
197
+ "-CAkey",
198
+ caKeyPath,
199
+ "-CAcreateserial",
200
+ "-out",
201
+ serverCertPath,
202
+ "-days",
203
+ String(SERVER_VALIDITY_DAYS),
204
+ "-extfile",
205
+ extPath
206
+ ]);
207
+ for (const tempPath of [csrPath, extPath]) {
208
+ try {
209
+ fs2.unlinkSync(tempPath);
210
+ } catch {
211
+ }
212
+ }
213
+ fs2.chmodSync(serverKeyPath, 384);
214
+ fs2.chmodSync(serverCertPath, 420);
215
+ fixOwnership(serverKeyPath, serverCertPath);
216
+ return { certPath: serverCertPath, keyPath: serverKeyPath };
217
+ }
218
+ function ensureCerts(stateDir) {
219
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
220
+ const caKeyPath = path.join(stateDir, CA_KEY_FILE);
221
+ const serverCertPath = path.join(stateDir, SERVER_CERT_FILE);
222
+ const serverKeyPath = path.join(stateDir, SERVER_KEY_FILE);
223
+ let caGenerated = false;
224
+ if (!fileExists(caCertPath) || !fileExists(caKeyPath) || !isCertValid(caCertPath) || !isCertSignatureStrong(caCertPath)) {
225
+ generateCA(stateDir);
226
+ caGenerated = true;
227
+ }
228
+ if (caGenerated || !fileExists(serverCertPath) || !fileExists(serverKeyPath) || !isCertValid(serverCertPath) || !isCertSignatureStrong(serverCertPath)) {
229
+ generateBaseServerCert(stateDir);
230
+ }
231
+ return {
232
+ certPath: serverCertPath,
233
+ keyPath: serverKeyPath,
234
+ caPath: caCertPath,
235
+ caGenerated
236
+ };
237
+ }
238
+ function sanitizeHostForFilename(hostname) {
239
+ return hostname.replace(/\./g, "_").replace(/[^a-z0-9_-]/gi, "");
240
+ }
241
+ async function generateHostCertAsync(stateDir, hostname) {
242
+ const caKeyPath = path.join(stateDir, CA_KEY_FILE);
243
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
244
+ const hostCertDir = path.join(stateDir, HOST_CERTS_DIR);
245
+ if (!fs2.existsSync(hostCertDir)) {
246
+ await fs2.promises.mkdir(hostCertDir, { recursive: true, mode: 493 });
247
+ fixOwnership(hostCertDir);
248
+ }
249
+ const safeName = sanitizeHostForFilename(hostname);
250
+ const keyPath = path.join(hostCertDir, `${safeName}-key.pem`);
251
+ const certPath = path.join(hostCertDir, `${safeName}.pem`);
252
+ const csrPath = path.join(hostCertDir, `${safeName}.csr`);
253
+ const extPath = path.join(hostCertDir, `${safeName}-ext.cnf`);
254
+ await opensslAsync(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", keyPath]);
255
+ const commonName = hostname.length > MAX_CN_LENGTH ? hostname.slice(0, MAX_CN_LENGTH) : hostname;
256
+ await opensslAsync(["req", "-new", "-key", keyPath, "-out", csrPath, "-subj", `/CN=${commonName}`]);
257
+ fs2.writeFileSync(
258
+ extPath,
259
+ [
260
+ "authorityKeyIdentifier=keyid,issuer",
261
+ "basicConstraints=CA:FALSE",
262
+ "keyUsage=digitalSignature,keyEncipherment",
263
+ "extendedKeyUsage=serverAuth",
264
+ `subjectAltName=DNS:${hostname}`
265
+ ].join("\n") + "\n"
266
+ );
267
+ await opensslAsync([
268
+ "x509",
269
+ "-req",
270
+ "-sha256",
271
+ "-in",
272
+ csrPath,
273
+ "-CA",
274
+ caCertPath,
275
+ "-CAkey",
276
+ caKeyPath,
277
+ "-CAcreateserial",
278
+ "-out",
279
+ certPath,
280
+ "-days",
281
+ String(SERVER_VALIDITY_DAYS),
282
+ "-extfile",
283
+ extPath
284
+ ]);
285
+ for (const tempPath of [csrPath, extPath]) {
286
+ try {
287
+ fs2.unlinkSync(tempPath);
288
+ } catch {
289
+ }
290
+ }
291
+ fs2.chmodSync(keyPath, 384);
292
+ fs2.chmodSync(certPath, 420);
293
+ fixOwnership(keyPath, certPath);
294
+ return { certPath, keyPath };
295
+ }
296
+ function createSNICallback(stateDir, defaultCert, defaultKey) {
297
+ const cache = /* @__PURE__ */ new Map();
298
+ const defaultContext = tls.createSecureContext({ cert: defaultCert, key: defaultKey });
299
+ return async (servername, callback) => {
300
+ try {
301
+ if (!servername || servername === "localhost") {
302
+ callback(null, defaultContext);
303
+ return;
304
+ }
305
+ const cached = cache.get(servername);
306
+ if (cached) {
307
+ callback(null, cached);
308
+ return;
309
+ }
310
+ const safeName = sanitizeHostForFilename(servername);
311
+ const certPath = path.join(stateDir, HOST_CERTS_DIR, `${safeName}.pem`);
312
+ const keyPath = path.join(stateDir, HOST_CERTS_DIR, `${safeName}-key.pem`);
313
+ let resolvedCertPath = certPath;
314
+ let resolvedKeyPath = keyPath;
315
+ if (!fileExists(certPath) || !fileExists(keyPath) || !isCertValid(certPath) || !isCertSignatureStrong(certPath)) {
316
+ const generated = await generateHostCertAsync(stateDir, servername);
317
+ resolvedCertPath = generated.certPath;
318
+ resolvedKeyPath = generated.keyPath;
319
+ }
320
+ const context = tls.createSecureContext({
321
+ cert: fs2.readFileSync(resolvedCertPath),
322
+ key: fs2.readFileSync(resolvedKeyPath)
323
+ });
324
+ cache.set(servername, context);
325
+ callback(null, context);
326
+ } catch (error) {
327
+ callback(error instanceof Error ? error : new Error(String(error)), defaultContext);
328
+ }
329
+ };
330
+ }
331
+ function isCATrustedMacOS(caCertPath) {
332
+ try {
333
+ execFileSync("security", ["verify-cert", "-c", caCertPath, "-L", "-p", "ssl"], {
334
+ stdio: "pipe",
335
+ timeout: 5e3
336
+ });
337
+ return true;
338
+ } catch {
339
+ return false;
340
+ }
341
+ }
342
+ function isCATrustedLinux(stateDir) {
343
+ const candidatePaths = [
344
+ "/usr/local/share/ca-certificates/local-router-ca.crt",
345
+ "/etc/pki/ca-trust/source/anchors/local-router-ca.crt",
346
+ "/etc/ca-certificates/trust-source/anchors/local-router-ca.crt",
347
+ "/etc/pki/trust/anchors/local-router-ca.crt"
348
+ ];
349
+ const localCert = path.join(stateDir, CA_CERT_FILE);
350
+ if (!fileExists(localCert)) return false;
351
+ for (const candidate of candidatePaths) {
352
+ try {
353
+ if (fs2.readFileSync(candidate, "utf-8").trim() === fs2.readFileSync(localCert, "utf-8").trim()) {
354
+ return true;
355
+ }
356
+ } catch {
357
+ }
358
+ }
359
+ return false;
360
+ }
361
+ function isCATrustedWindows(caCertPath) {
362
+ try {
363
+ const fingerprint = openssl(["x509", "-in", caCertPath, "-noout", "-fingerprint", "-sha1"]).trim().replace(/^.*=/, "").replace(/:/g, "").toLowerCase();
364
+ const result = execFileSync("certutil", ["-store", "-user", "Root"], {
365
+ encoding: "utf-8",
366
+ timeout: 1e4,
367
+ stdio: ["pipe", "pipe", "pipe"]
368
+ });
369
+ return result.replace(/\s/g, "").toLowerCase().includes(fingerprint);
370
+ } catch {
371
+ return false;
372
+ }
373
+ }
374
+ function isCATrusted(stateDir) {
375
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
376
+ if (!fileExists(caCertPath)) return false;
377
+ if (process.platform === "darwin") return isCATrustedMacOS(caCertPath);
378
+ if (process.platform === "linux") return isCATrustedLinux(stateDir);
379
+ if (process.platform === "win32") return isCATrustedWindows(caCertPath);
380
+ return false;
381
+ }
382
+ function trustCA(stateDir) {
383
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
384
+ if (!fileExists(caCertPath)) {
385
+ return { trusted: false, error: "CA certificate not found. Start the proxy with HTTPS first." };
386
+ }
387
+ try {
388
+ if (process.platform === "darwin") {
389
+ execFileSync("security", ["add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", caCertPath], {
390
+ stdio: "pipe",
391
+ timeout: 1e4
392
+ });
393
+ return { trusted: true };
394
+ }
395
+ if (process.platform === "linux") {
396
+ const destination = fs2.existsSync("/usr/local/share/ca-certificates") ? "/usr/local/share/ca-certificates/local-router-ca.crt" : "/etc/pki/ca-trust/source/anchors/local-router-ca.crt";
397
+ fs2.copyFileSync(caCertPath, destination);
398
+ const updateCommand = fs2.existsSync("/usr/sbin/update-ca-certificates") || fs2.existsSync("/usr/bin/update-ca-certificates") ? ["update-ca-certificates"] : ["update-ca-trust"];
399
+ execFileSync(updateCommand[0], [], { stdio: "pipe", timeout: 15e3 });
400
+ return { trusted: true };
401
+ }
402
+ if (process.platform === "win32") {
403
+ execFileSync("certutil", ["-addstore", "-user", "Root", caCertPath], {
404
+ stdio: "pipe",
405
+ timeout: 1e4
406
+ });
407
+ return { trusted: true };
408
+ }
409
+ return { trusted: false, error: `Unsupported platform: ${process.platform}` };
410
+ } catch (error) {
411
+ const message = error instanceof Error ? error.message : String(error);
412
+ return { trusted: false, error: message };
413
+ }
414
+ }
415
+
416
+ // src/config.ts
417
+ import * as fs4 from "fs";
418
+ import * as path3 from "path";
419
+ import JSON5 from "json5";
420
+
421
+ // src/auto.ts
422
+ import { createHash } from "crypto";
423
+ import { execFileSync as execFileSync2 } from "child_process";
424
+ import * as fs3 from "fs";
425
+ import * as path2 from "path";
426
+ var MAX_DNS_LABEL_LENGTH = 63;
427
+ function truncateLabel(label) {
428
+ if (label.length <= MAX_DNS_LABEL_LENGTH) return label;
429
+ const hash = createHash("sha256").update(label).digest("hex").slice(0, 6);
430
+ const maxPrefixLength = MAX_DNS_LABEL_LENGTH - 7;
431
+ const prefix = label.slice(0, maxPrefixLength).replace(/-+$/, "");
432
+ return `${prefix}-${hash}`;
433
+ }
434
+ function sanitizeForHostname(name) {
435
+ const sanitized = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
436
+ return truncateLabel(sanitized);
437
+ }
438
+ function findPackageJsonName(startDir) {
439
+ let dir = startDir;
440
+ for (; ; ) {
441
+ const packageJsonPath = path2.join(dir, "package.json");
442
+ try {
443
+ const raw = fs3.readFileSync(packageJsonPath, "utf-8");
444
+ const pkg = JSON.parse(raw);
445
+ if (typeof pkg.name === "string" && pkg.name) {
446
+ return pkg.name.replace(/^@[^/]+\//, "");
447
+ }
448
+ } catch {
449
+ }
450
+ const parent = path2.dirname(dir);
451
+ if (parent === dir) break;
452
+ dir = parent;
453
+ }
454
+ return null;
455
+ }
456
+ function findGitRoot(startDir) {
457
+ try {
458
+ const output = execFileSync2("git", ["rev-parse", "--show-toplevel"], {
459
+ cwd: startDir,
460
+ encoding: "utf-8",
461
+ timeout: 5e3,
462
+ stdio: ["ignore", "pipe", "ignore"]
463
+ }).trim();
464
+ if (output) return output;
465
+ } catch {
466
+ }
467
+ let dir = startDir;
468
+ for (; ; ) {
469
+ const gitPath = path2.join(dir, ".git");
470
+ try {
471
+ const stats = fs3.statSync(gitPath);
472
+ if (stats.isDirectory() || stats.isFile()) {
473
+ return dir;
474
+ }
475
+ } catch {
476
+ }
477
+ const parent = path2.dirname(dir);
478
+ if (parent === dir) break;
479
+ dir = parent;
480
+ }
481
+ return null;
482
+ }
483
+ function inferProjectName(cwd = process.cwd()) {
484
+ const packageName = findPackageJsonName(cwd);
485
+ if (packageName) {
486
+ const sanitized = sanitizeForHostname(packageName);
487
+ if (sanitized) {
488
+ return { name: sanitized, source: "package.json" };
489
+ }
490
+ }
491
+ const gitRoot = findGitRoot(cwd);
492
+ if (gitRoot) {
493
+ const sanitized = sanitizeForHostname(path2.basename(gitRoot));
494
+ if (sanitized) {
495
+ return { name: sanitized, source: "git root" };
496
+ }
497
+ }
498
+ const directoryName = sanitizeForHostname(path2.basename(cwd));
499
+ if (directoryName) {
500
+ return { name: directoryName, source: "directory name" };
501
+ }
502
+ throw new Error("Could not infer a project name from package.json, git root, or directory name.");
503
+ }
504
+
505
+ // src/config.ts
506
+ var CONFIG_FILENAMES = [".local-router", ".local-router.json", "local-router.config.json"];
507
+ function findLocalRouterConfig(cwd = process.cwd()) {
508
+ let dir = cwd;
509
+ for (; ; ) {
510
+ for (const filename of CONFIG_FILENAMES) {
511
+ const candidate = path3.join(dir, filename);
512
+ if (fs4.existsSync(candidate)) {
513
+ return candidate;
514
+ }
515
+ }
516
+ const parent = path3.dirname(dir);
517
+ if (parent === dir) break;
518
+ dir = parent;
519
+ }
520
+ return null;
521
+ }
522
+ function loadLocalRouterConfig(cwd = process.cwd()) {
523
+ const configPath = findLocalRouterConfig(cwd);
524
+ if (!configPath) return null;
525
+ const raw = fs4.readFileSync(configPath, "utf-8");
526
+ const parsed = JSON5.parse(raw);
527
+ if (parsed && typeof parsed === "object") {
528
+ if (parsed.name !== void 0 && typeof parsed.name !== "string") {
529
+ throw new Error(`Invalid "name" in ${configPath}: expected a string.`);
530
+ }
531
+ if (parsed.hosts !== void 0) {
532
+ if (!Array.isArray(parsed.hosts) || parsed.hosts.some((item) => typeof item !== "string")) {
533
+ throw new Error(`Invalid "hosts" in ${configPath}: expected an array of strings.`);
534
+ }
535
+ }
536
+ } else {
537
+ throw new Error(`Invalid ${configPath}: expected an object.`);
538
+ }
539
+ return { config: parsed, path: configPath };
540
+ }
541
+ function buildLocalhostName(name) {
542
+ const sanitized = sanitizeForHostname(name);
543
+ if (!sanitized) {
544
+ throw new Error(`Invalid project name "${name}".`);
545
+ }
546
+ return `${sanitized}.localhost`;
547
+ }
548
+ function dedupe(values) {
549
+ return Array.from(new Set(values));
550
+ }
551
+ function resolveProjectHosts(options) {
552
+ const cwd = options?.cwd ?? process.cwd();
553
+ const loadedConfig = loadLocalRouterConfig(cwd);
554
+ const inferred = inferProjectName(cwd);
555
+ let effectiveName;
556
+ let nameSource;
557
+ if (options?.explicitName) {
558
+ effectiveName = options.explicitName;
559
+ nameSource = "--name";
560
+ } else if (loadedConfig?.config.name) {
561
+ effectiveName = loadedConfig.config.name;
562
+ nameSource = loadedConfig.path;
563
+ } else {
564
+ effectiveName = inferred.name;
565
+ nameSource = inferred.source;
566
+ }
567
+ const baseHostname = buildLocalhostName(effectiveName);
568
+ const configHosts = loadedConfig?.config.hosts ?? [];
569
+ const cliHosts = options?.extraDomains ?? [];
570
+ const hostnames = dedupe([
571
+ baseHostname,
572
+ ...configHosts.map((host) => normalizeExplicitHostname(host)),
573
+ ...cliHosts.map((host) => normalizeExplicitHostname(host))
574
+ ]);
575
+ return {
576
+ name: sanitizeForHostname(effectiveName),
577
+ baseHostname,
578
+ hostnames,
579
+ configPath: loadedConfig?.path,
580
+ nameSource
581
+ };
582
+ }
583
+
584
+ // src/hosts.ts
585
+ import * as dns from "dns";
586
+ import * as fs5 from "fs";
587
+ import * as path4 from "path";
588
+ var isWindows = process.platform === "win32";
589
+ var HOSTS_PATH = isWindows ? path4.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "drivers", "etc", "hosts") : "/etc/hosts";
590
+ var MARKER_START = "# local-router-start";
591
+ var MARKER_END = "# local-router-end";
592
+ function readHostsFile() {
593
+ try {
594
+ return fs5.readFileSync(HOSTS_PATH, "utf-8");
595
+ } catch {
596
+ return "";
597
+ }
598
+ }
599
+ function removeManagedBlock(content) {
600
+ const startIndex = content.indexOf(MARKER_START);
601
+ const endIndex = content.indexOf(MARKER_END);
602
+ if (startIndex === -1 || endIndex === -1) return content;
603
+ const before = content.slice(0, startIndex);
604
+ const after = content.slice(endIndex + MARKER_END.length);
605
+ return (before + after).replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
606
+ }
607
+ function buildManagedBlock(hostnames) {
608
+ if (hostnames.length === 0) return "";
609
+ const ipv4Entries = hostnames.map((hostname) => `127.0.0.1 ${hostname}`).join("\n");
610
+ const ipv6Entries = hostnames.map((hostname) => `::1 ${hostname}`).join("\n");
611
+ return `${MARKER_START}
612
+ ${ipv4Entries}
613
+ ${ipv6Entries}
614
+ ${MARKER_END}`;
615
+ }
616
+ function syncHostsFile(hostnames) {
617
+ try {
618
+ const uniqueHostnames = Array.from(new Set(hostnames)).sort();
619
+ const current = readHostsFile();
620
+ const cleaned = removeManagedBlock(current);
621
+ if (uniqueHostnames.length === 0) {
622
+ fs5.writeFileSync(HOSTS_PATH, cleaned);
623
+ return true;
624
+ }
625
+ const managedBlock = buildManagedBlock(uniqueHostnames);
626
+ fs5.writeFileSync(HOSTS_PATH, `${cleaned.trimEnd()}
627
+
628
+ ${managedBlock}
629
+ `);
630
+ return true;
631
+ } catch {
632
+ return false;
633
+ }
634
+ }
635
+ function cleanHostsFile() {
636
+ try {
637
+ const content = readHostsFile();
638
+ if (!content.includes(MARKER_START)) return true;
639
+ fs5.writeFileSync(HOSTS_PATH, removeManagedBlock(content));
640
+ return true;
641
+ } catch {
642
+ return false;
643
+ }
644
+ }
645
+
646
+ // src/proxy.ts
647
+ import * as http2 from "http";
648
+ import * as http22 from "http2";
649
+
650
+ // src/state.ts
651
+ import * as fs6 from "fs";
652
+ import * as http from "http";
653
+ import * as https from "https";
654
+ import * as net from "net";
655
+ import * as os from "os";
656
+ import * as path5 from "path";
657
+ import * as readline from "readline";
658
+ import { execSync, spawn } from "child_process";
659
+ var isWindows2 = process.platform === "win32";
660
+ var PRIVILEGED_PORT_THRESHOLD = 1024;
661
+ var DEFAULT_HTTP_PORT = 80;
662
+ var DEFAULT_HTTPS_PORT = 443;
663
+ var SYSTEM_STATE_DIR = isWindows2 ? path5.join(os.tmpdir(), "local-router") : "/tmp/local-router";
664
+ var USER_STATE_DIR = path5.join(os.homedir(), ".local-router");
665
+ var MIN_APP_PORT = 4e3;
666
+ var MAX_APP_PORT = 4999;
667
+ var RANDOM_PORT_ATTEMPTS = 50;
668
+ var SOCKET_TIMEOUT_MS = 750;
669
+ var WAIT_FOR_PROXY_MAX_ATTEMPTS = 24;
670
+ var WAIT_FOR_PROXY_INTERVAL_MS = 250;
671
+ var HEALTH_HEADER = "x-local-router";
672
+ var SIGNAL_CODES = {
673
+ SIGHUP: 1,
674
+ SIGINT: 2,
675
+ SIGQUIT: 3,
676
+ SIGABRT: 6,
677
+ SIGKILL: 9,
678
+ SIGTERM: 15
679
+ };
680
+ function getDefaultHttpPort() {
681
+ const value = Number(process.env.LOCAL_ROUTER_HTTP_PORT);
682
+ return Number.isInteger(value) && value > 0 && value <= 65535 ? value : DEFAULT_HTTP_PORT;
683
+ }
684
+ function getDefaultHttpsPort() {
685
+ const value = Number(process.env.LOCAL_ROUTER_HTTPS_PORT);
686
+ return Number.isInteger(value) && value > 0 && value <= 65535 ? value : DEFAULT_HTTPS_PORT;
687
+ }
688
+ function resolveStateDir(runtime) {
689
+ if (process.env.LOCAL_ROUTER_STATE_DIR) return process.env.LOCAL_ROUTER_STATE_DIR;
690
+ const httpPort = runtime?.httpPort ?? getDefaultHttpPort();
691
+ const httpsPort = runtime?.httpsPort ?? getDefaultHttpsPort();
692
+ const needsSystemDir = !isWindows2 && ((runtime?.httpEnabled ?? true) && httpPort < PRIVILEGED_PORT_THRESHOLD || (runtime?.httpsEnabled ?? true) && httpsPort < PRIVILEGED_PORT_THRESHOLD);
693
+ return needsSystemDir ? SYSTEM_STATE_DIR : USER_STATE_DIR;
694
+ }
695
+ function readProxyState(dir) {
696
+ try {
697
+ const raw = fs6.readFileSync(path5.join(dir, "proxy-state.json"), "utf-8");
698
+ return JSON.parse(raw);
699
+ } catch {
700
+ return null;
701
+ }
702
+ }
703
+ function writeProxyState(dir, state) {
704
+ fs6.writeFileSync(path5.join(dir, "proxy-state.json"), JSON.stringify(state, null, 2), { mode: 420 });
705
+ }
706
+ function removeProxyState(dir) {
707
+ try {
708
+ fs6.unlinkSync(path5.join(dir, "proxy-state.json"));
709
+ } catch {
710
+ }
711
+ }
712
+ function requestHealth(port, tls2) {
713
+ return new Promise((resolve) => {
714
+ const request2 = (tls2 ? https : http).request(
715
+ {
716
+ hostname: "127.0.0.1",
717
+ port,
718
+ path: "/__local_router/health",
719
+ method: "HEAD",
720
+ timeout: SOCKET_TIMEOUT_MS,
721
+ ...tls2 ? { rejectUnauthorized: false } : {}
722
+ },
723
+ (response) => {
724
+ response.resume();
725
+ resolve(response.headers[HEALTH_HEADER] === "1");
726
+ }
727
+ );
728
+ request2.on("error", () => resolve(false));
729
+ request2.on("timeout", () => {
730
+ request2.destroy();
731
+ resolve(false);
732
+ });
733
+ request2.end();
734
+ });
735
+ }
736
+ async function isProxyRunning(runtime) {
737
+ const checks = [];
738
+ if (runtime.httpEnabled) {
739
+ checks.push(requestHealth(runtime.httpPort, false));
740
+ }
741
+ if (runtime.httpsEnabled) {
742
+ checks.push(requestHealth(runtime.httpsPort, true));
743
+ }
744
+ if (checks.length === 0) return false;
745
+ const results = await Promise.all(checks);
746
+ return results.every(Boolean);
747
+ }
748
+ async function discoverState() {
749
+ if (process.env.LOCAL_ROUTER_STATE_DIR) {
750
+ const dir = process.env.LOCAL_ROUTER_STATE_DIR;
751
+ return { dir, state: readProxyState(dir) };
752
+ }
753
+ for (const dir of [USER_STATE_DIR, SYSTEM_STATE_DIR]) {
754
+ const state = readProxyState(dir);
755
+ if (!state) continue;
756
+ if (await isProxyRunning(state)) {
757
+ return { dir, state };
758
+ }
759
+ }
760
+ const fallback = {
761
+ pid: 0,
762
+ httpPort: getDefaultHttpPort(),
763
+ httpsPort: getDefaultHttpsPort(),
764
+ httpEnabled: true,
765
+ httpsEnabled: true
766
+ };
767
+ return { dir: resolveStateDir(fallback), state: null };
768
+ }
769
+ async function waitForProxy(runtime) {
770
+ for (let attempt = 0; attempt < WAIT_FOR_PROXY_MAX_ATTEMPTS; attempt += 1) {
771
+ await new Promise((resolve) => setTimeout(resolve, WAIT_FOR_PROXY_INTERVAL_MS));
772
+ if (await isProxyRunning(runtime)) {
773
+ return true;
774
+ }
775
+ }
776
+ return false;
777
+ }
778
+ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
779
+ if (minPort > maxPort) {
780
+ throw new Error(`Invalid app port range: ${minPort}-${maxPort}.`);
781
+ }
782
+ const tryPort = (port) => new Promise((resolve) => {
783
+ const server = net.createServer();
784
+ server.listen(port, () => server.close(() => resolve(true)));
785
+ server.on("error", () => resolve(false));
786
+ });
787
+ for (let attempt = 0; attempt < RANDOM_PORT_ATTEMPTS; attempt += 1) {
788
+ const port = minPort + Math.floor(Math.random() * (maxPort - minPort + 1));
789
+ if (await tryPort(port)) return port;
790
+ }
791
+ for (let port = minPort; port <= maxPort; port += 1) {
792
+ if (await tryPort(port)) return port;
793
+ }
794
+ throw new Error(`Could not find a free port between ${minPort} and ${maxPort}.`);
795
+ }
796
+ function collectBinPaths(cwd) {
797
+ const results = [];
798
+ let dir = cwd;
799
+ for (; ; ) {
800
+ const binDir = path5.join(dir, "node_modules", ".bin");
801
+ if (fs6.existsSync(binDir)) {
802
+ results.push(binDir);
803
+ }
804
+ const parent = path5.dirname(dir);
805
+ if (parent === dir) break;
806
+ dir = parent;
807
+ }
808
+ return results;
809
+ }
810
+ function augmentedPath(env) {
811
+ const source = env ?? process.env;
812
+ const basePath = source.PATH ?? source.Path ?? "";
813
+ const nodeBin = path5.dirname(process.execPath);
814
+ return [...collectBinPaths(process.cwd()), nodeBin, basePath].join(path5.delimiter);
815
+ }
816
+ function shellEscape(value) {
817
+ return `'${value.replace(/'/g, "'\\''")}'`;
818
+ }
819
+ function spawnCommand(commandArgs, options) {
820
+ const env = { ...options?.env ?? process.env, PATH: augmentedPath(options?.env) };
821
+ const child = isWindows2 ? spawn(commandArgs[0], commandArgs.slice(1), {
822
+ stdio: "inherit",
823
+ env,
824
+ shell: true
825
+ }) : spawn("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
826
+ stdio: "inherit",
827
+ env
828
+ });
829
+ let exiting = false;
830
+ const cleanup = () => {
831
+ process.removeListener("SIGINT", onSigInt);
832
+ process.removeListener("SIGTERM", onSigTerm);
833
+ options?.onCleanup?.();
834
+ };
835
+ const handleSignal = (signal) => {
836
+ if (exiting) return;
837
+ exiting = true;
838
+ child.kill(signal);
839
+ cleanup();
840
+ process.exit(128 + (SIGNAL_CODES[signal] || 15));
841
+ };
842
+ const onSigInt = () => handleSignal("SIGINT");
843
+ const onSigTerm = () => handleSignal("SIGTERM");
844
+ process.on("SIGINT", onSigInt);
845
+ process.on("SIGTERM", onSigTerm);
846
+ child.on("error", (error) => {
847
+ if (exiting) return;
848
+ exiting = true;
849
+ console.error(`Failed to run command: ${error.message}`);
850
+ cleanup();
851
+ process.exit(1);
852
+ });
853
+ child.on("exit", (code, signal) => {
854
+ if (exiting) return;
855
+ exiting = true;
856
+ cleanup();
857
+ if (signal) {
858
+ process.exit(128 + (SIGNAL_CODES[signal] || 15));
859
+ }
860
+ process.exit(code ?? 1);
861
+ });
862
+ }
863
+ function prompt(question) {
864
+ return new Promise((resolve) => {
865
+ const rl = readline.createInterface({
866
+ input: process.stdin,
867
+ output: process.stdout
868
+ });
869
+ rl.question(question, (answer) => {
870
+ rl.close();
871
+ resolve(answer.trim().toLowerCase());
872
+ });
873
+ });
874
+ }
875
+ function findPidOnPort(port) {
876
+ try {
877
+ if (isWindows2) {
878
+ const output2 = execSync("netstat -ano -p tcp", { encoding: "utf-8", timeout: 5e3 });
879
+ for (const line of output2.split(/\r?\n/)) {
880
+ if (!line.includes("LISTENING") || !line.includes(`:${port}`)) continue;
881
+ const pid2 = Number(line.trim().split(/\s+/).at(-1));
882
+ if (Number.isInteger(pid2) && pid2 > 0) return pid2;
883
+ }
884
+ return null;
885
+ }
886
+ const output = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
887
+ encoding: "utf-8",
888
+ timeout: 5e3
889
+ }).trim();
890
+ const pid = Number(output.split("\n")[0]);
891
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
892
+ } catch {
893
+ return null;
894
+ }
895
+ }
896
+ var FRAMEWORKS_NEEDING_PORT = {
897
+ vite: { strictPort: true },
898
+ "react-router": { strictPort: true },
899
+ astro: { strictPort: false },
900
+ ng: { strictPort: false },
901
+ "react-native": { strictPort: false },
902
+ expo: { strictPort: false }
903
+ };
904
+ var PACKAGE_RUNNERS = {
905
+ npx: [],
906
+ bunx: [],
907
+ pnpx: [],
908
+ yarn: ["dlx", "exec"],
909
+ pnpm: ["dlx", "exec"]
910
+ };
911
+ function findFrameworkBasename(commandArgs) {
912
+ if (commandArgs.length === 0) return null;
913
+ const first = path5.basename(commandArgs[0]);
914
+ if (FRAMEWORKS_NEEDING_PORT[first]) return first;
915
+ const subcommands = PACKAGE_RUNNERS[first];
916
+ if (!subcommands) return null;
917
+ let index = 1;
918
+ if (subcommands.length > 0) {
919
+ while (index < commandArgs.length && commandArgs[index].startsWith("-")) index += 1;
920
+ if (index >= commandArgs.length) return null;
921
+ if (!subcommands.includes(commandArgs[index])) {
922
+ const name2 = path5.basename(commandArgs[index]);
923
+ return FRAMEWORKS_NEEDING_PORT[name2] ? name2 : null;
924
+ }
925
+ index += 1;
926
+ }
927
+ while (index < commandArgs.length && commandArgs[index].startsWith("-")) index += 1;
928
+ if (index >= commandArgs.length) return null;
929
+ const name = path5.basename(commandArgs[index]);
930
+ return FRAMEWORKS_NEEDING_PORT[name] ? name : null;
931
+ }
932
+ function injectFrameworkFlags(commandArgs, port) {
933
+ const basename3 = findFrameworkBasename(commandArgs);
934
+ if (!basename3) return;
935
+ if (!commandArgs.includes("--port")) {
936
+ commandArgs.push("--port", String(port));
937
+ }
938
+ if (!commandArgs.includes("--host")) {
939
+ const hostValue = basename3 === "expo" ? "localhost" : "127.0.0.1";
940
+ commandArgs.push("--host", hostValue);
941
+ }
942
+ if (FRAMEWORKS_NEEDING_PORT[basename3].strictPort && !commandArgs.includes("--strictPort")) {
943
+ commandArgs.push("--strictPort");
944
+ }
945
+ }
946
+ function getHealthHeader() {
947
+ return HEALTH_HEADER;
948
+ }
949
+
950
+ // src/proxy.ts
951
+ function getRequestHost(req) {
952
+ const authority = req.headers[":authority"];
953
+ if (typeof authority === "string" && authority) return authority;
954
+ return req.headers.host ?? "";
955
+ }
956
+ function buildForwardedHeaders(req, tls2) {
957
+ const remoteAddress = req.socket.remoteAddress || "127.0.0.1";
958
+ const hostHeader = getRequestHost(req);
959
+ return {
960
+ "x-forwarded-for": req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remoteAddress}` : remoteAddress,
961
+ "x-forwarded-proto": req.headers["x-forwarded-proto"] || (tls2 ? "https" : "http"),
962
+ "x-forwarded-host": req.headers["x-forwarded-host"] || hostHeader,
963
+ "x-forwarded-port": req.headers["x-forwarded-port"] || hostHeader.split(":")[1] || (tls2 ? "443" : "80")
964
+ };
965
+ }
966
+ function findRoute(routes, host) {
967
+ return routes.find((route) => route.hostname === host);
968
+ }
969
+ function createNotFoundResponse(host, routes, https2, port) {
970
+ const links = routes.map(
971
+ (route) => `<li><a href="${escapeHtml(formatUrl(route.hostname, https2, port))}">${escapeHtml(route.hostname)}</a></li>`
972
+ ).join("");
973
+ return `<!doctype html>
974
+ <html lang="en">
975
+ <head>
976
+ <meta charset="utf-8" />
977
+ <title>Route not found</title>
978
+ <style>
979
+ body { font-family: system-ui, sans-serif; padding: 32px; line-height: 1.5; }
980
+ h1 { margin: 0 0 12px; }
981
+ code { background: #f3f4f6; padding: 2px 6px; border-radius: 6px; }
982
+ </style>
983
+ </head>
984
+ <body>
985
+ <h1>No route for <code>${escapeHtml(host)}</code></h1>
986
+ <p>Start an app with <code>local-router run ...</code> or check the configured hostnames.</p>
987
+ ${links ? `<ul>${links}</ul>` : "<p>No apps are currently registered.</p>"}
988
+ </body>
989
+ </html>`;
990
+ }
991
+ function createProxyServers(options) {
992
+ const onError = options.onError ?? ((message) => console.error(message));
993
+ const handleRequest = (httpsRequest) => (req, res) => {
994
+ res.setHeader(getHealthHeader(), "1");
995
+ if (req.method === "HEAD" && req.url === "/__local_router/health") {
996
+ res.writeHead(200);
997
+ res.end();
998
+ return;
999
+ }
1000
+ const host = getRequestHost(req).split(":")[0];
1001
+ if (!host) {
1002
+ res.writeHead(400, { "content-type": "text/plain" });
1003
+ res.end("Missing Host header.");
1004
+ return;
1005
+ }
1006
+ const route = findRoute(options.getRoutes(), host);
1007
+ if (!route) {
1008
+ res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
1009
+ res.end(
1010
+ createNotFoundResponse(
1011
+ host,
1012
+ options.getRoutes(),
1013
+ httpsRequest,
1014
+ httpsRequest ? options.httpsPort : options.httpPort
1015
+ )
1016
+ );
1017
+ return;
1018
+ }
1019
+ const headers = { ...req.headers, ...buildForwardedHeaders(req, httpsRequest) };
1020
+ headers.host = getRequestHost(req);
1021
+ for (const key of Object.keys(headers)) {
1022
+ if (key.startsWith(":")) {
1023
+ delete headers[key];
1024
+ }
1025
+ }
1026
+ const proxyRequest = http2.request(
1027
+ {
1028
+ hostname: "127.0.0.1",
1029
+ port: route.port,
1030
+ path: req.url,
1031
+ method: req.method,
1032
+ headers
1033
+ },
1034
+ (proxyResponse) => {
1035
+ const responseHeaders = { ...proxyResponse.headers };
1036
+ if (httpsRequest) {
1037
+ delete responseHeaders.connection;
1038
+ delete responseHeaders["keep-alive"];
1039
+ delete responseHeaders["proxy-connection"];
1040
+ delete responseHeaders["transfer-encoding"];
1041
+ delete responseHeaders.upgrade;
1042
+ }
1043
+ res.writeHead(proxyResponse.statusCode || 502, responseHeaders);
1044
+ proxyResponse.on("error", () => {
1045
+ if (!res.headersSent) {
1046
+ res.writeHead(502, { "content-type": "text/plain" });
1047
+ }
1048
+ res.end();
1049
+ });
1050
+ proxyResponse.pipe(res);
1051
+ }
1052
+ );
1053
+ proxyRequest.on("error", (error) => {
1054
+ onError(`Proxy error for ${host}: ${error.message}`);
1055
+ if (!res.headersSent) {
1056
+ res.writeHead(502, { "content-type": "text/plain" });
1057
+ res.end("Target app is not responding.");
1058
+ }
1059
+ });
1060
+ res.on("close", () => {
1061
+ if (!proxyRequest.destroyed) {
1062
+ proxyRequest.destroy();
1063
+ }
1064
+ });
1065
+ req.pipe(proxyRequest);
1066
+ };
1067
+ const handleUpgrade = (httpsRequest) => (req, socket, head) => {
1068
+ socket.on("error", () => socket.destroy());
1069
+ const host = getRequestHost(req).split(":")[0];
1070
+ const route = findRoute(options.getRoutes(), host);
1071
+ if (!route) {
1072
+ socket.destroy();
1073
+ return;
1074
+ }
1075
+ const headers = { ...req.headers, ...buildForwardedHeaders(req, httpsRequest) };
1076
+ headers.host = getRequestHost(req);
1077
+ for (const key of Object.keys(headers)) {
1078
+ if (key.startsWith(":")) {
1079
+ delete headers[key];
1080
+ }
1081
+ }
1082
+ const proxyRequest = http2.request({
1083
+ hostname: "127.0.0.1",
1084
+ port: route.port,
1085
+ path: req.url,
1086
+ method: req.method,
1087
+ headers
1088
+ });
1089
+ proxyRequest.on("upgrade", (proxyResponse, proxySocket, proxyHead) => {
1090
+ let response = "HTTP/1.1 101 Switching Protocols\r\n";
1091
+ for (let index = 0; index < proxyResponse.rawHeaders.length; index += 2) {
1092
+ response += `${proxyResponse.rawHeaders[index]}: ${proxyResponse.rawHeaders[index + 1]}\r
1093
+ `;
1094
+ }
1095
+ response += "\r\n";
1096
+ socket.write(response);
1097
+ if (proxyHead.length > 0) {
1098
+ socket.write(proxyHead);
1099
+ }
1100
+ proxySocket.pipe(socket);
1101
+ socket.pipe(proxySocket);
1102
+ proxySocket.on("error", () => socket.destroy());
1103
+ socket.on("error", () => proxySocket.destroy());
1104
+ });
1105
+ proxyRequest.on("response", (response) => {
1106
+ if (!socket.destroyed) {
1107
+ let raw = `HTTP/1.1 ${response.statusCode} ${response.statusMessage}\r
1108
+ `;
1109
+ for (let index = 0; index < response.rawHeaders.length; index += 2) {
1110
+ raw += `${response.rawHeaders[index]}: ${response.rawHeaders[index + 1]}\r
1111
+ `;
1112
+ }
1113
+ raw += "\r\n";
1114
+ socket.write(raw);
1115
+ response.pipe(socket);
1116
+ }
1117
+ });
1118
+ proxyRequest.on("error", (error) => {
1119
+ onError(`WebSocket proxy error for ${host}: ${error.message}`);
1120
+ socket.destroy();
1121
+ });
1122
+ if (head.length > 0) {
1123
+ proxyRequest.write(head);
1124
+ }
1125
+ proxyRequest.end();
1126
+ };
1127
+ const bundle = {};
1128
+ if (options.httpEnabled) {
1129
+ const server = http2.createServer(handleRequest(false));
1130
+ server.on("upgrade", handleUpgrade(false));
1131
+ bundle.httpServer = server;
1132
+ }
1133
+ if (options.httpsEnabled && options.tls) {
1134
+ const server = http22.createSecureServer({
1135
+ cert: options.tls.cert,
1136
+ key: options.tls.key,
1137
+ allowHTTP1: true,
1138
+ ...options.tls.SNICallback ? { SNICallback: options.tls.SNICallback } : {}
1139
+ });
1140
+ server.on("request", (req, res) => {
1141
+ handleRequest(true)(req, res);
1142
+ });
1143
+ server.on("upgrade", handleUpgrade(true));
1144
+ bundle.httpsServer = server;
1145
+ }
1146
+ return bundle;
1147
+ }
1148
+
1149
+ // src/routes.ts
1150
+ import * as fs7 from "fs";
1151
+ import * as path6 from "path";
1152
+ var STALE_LOCK_THRESHOLD_MS = 1e4;
1153
+ var LOCK_MAX_RETRIES = 20;
1154
+ var LOCK_RETRY_DELAY_MS = 50;
1155
+ var FILE_MODE = 420;
1156
+ var DIR_MODE = 493;
1157
+ var SYSTEM_DIR_MODE = 1023;
1158
+ var SYSTEM_FILE_MODE = 438;
1159
+ var RouteConflictError = class extends Error {
1160
+ hostname;
1161
+ existingPid;
1162
+ constructor(hostname, existingPid) {
1163
+ super(
1164
+ `"${hostname}" is already registered by a running process (PID ${existingPid}). Use --force to override.`
1165
+ );
1166
+ this.name = "RouteConflictError";
1167
+ this.hostname = hostname;
1168
+ this.existingPid = existingPid;
1169
+ }
1170
+ };
1171
+ function isValidRoute(value) {
1172
+ return typeof value === "object" && value !== null && typeof value.hostname === "string" && typeof value.port === "number" && typeof value.pid === "number";
1173
+ }
1174
+ var RouteStore = class _RouteStore {
1175
+ dir;
1176
+ routesPath;
1177
+ lockPath;
1178
+ pidPath;
1179
+ statePath;
1180
+ isSystemDir;
1181
+ onWarning;
1182
+ static sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
1183
+ constructor(dir, options) {
1184
+ this.dir = dir;
1185
+ this.isSystemDir = options?.isSystemDir ?? false;
1186
+ this.routesPath = path6.join(dir, "routes.json");
1187
+ this.lockPath = path6.join(dir, "routes.lock");
1188
+ this.pidPath = path6.join(dir, "proxy.pid");
1189
+ this.statePath = path6.join(dir, "proxy-state.json");
1190
+ this.onWarning = options?.onWarning;
1191
+ }
1192
+ get dirMode() {
1193
+ return this.isSystemDir ? SYSTEM_DIR_MODE : DIR_MODE;
1194
+ }
1195
+ get fileMode() {
1196
+ return this.isSystemDir ? SYSTEM_FILE_MODE : FILE_MODE;
1197
+ }
1198
+ ensureDir() {
1199
+ if (!fs7.existsSync(this.dir)) {
1200
+ fs7.mkdirSync(this.dir, { recursive: true, mode: this.dirMode });
1201
+ }
1202
+ try {
1203
+ fs7.chmodSync(this.dir, this.dirMode);
1204
+ } catch {
1205
+ }
1206
+ fixOwnership(this.dir);
1207
+ }
1208
+ syncSleep(ms) {
1209
+ Atomics.wait(_RouteStore.sleepBuffer, 0, 0, ms);
1210
+ }
1211
+ acquireLock(maxRetries = LOCK_MAX_RETRIES, retryDelayMs = LOCK_RETRY_DELAY_MS) {
1212
+ for (let index = 0; index < maxRetries; index += 1) {
1213
+ try {
1214
+ fs7.mkdirSync(this.lockPath);
1215
+ return true;
1216
+ } catch (error) {
1217
+ if (isErrnoException(error) && error.code === "EEXIST") {
1218
+ try {
1219
+ const stats = fs7.statSync(this.lockPath);
1220
+ if (Date.now() - stats.mtimeMs > STALE_LOCK_THRESHOLD_MS) {
1221
+ fs7.rmSync(this.lockPath, { recursive: true, force: true });
1222
+ continue;
1223
+ }
1224
+ } catch {
1225
+ continue;
1226
+ }
1227
+ this.syncSleep(retryDelayMs);
1228
+ continue;
1229
+ }
1230
+ return false;
1231
+ }
1232
+ }
1233
+ return false;
1234
+ }
1235
+ releaseLock() {
1236
+ try {
1237
+ fs7.rmSync(this.lockPath, { recursive: true, force: true });
1238
+ } catch {
1239
+ }
1240
+ }
1241
+ isProcessAlive(pid) {
1242
+ try {
1243
+ process.kill(pid, 0);
1244
+ return true;
1245
+ } catch {
1246
+ return false;
1247
+ }
1248
+ }
1249
+ loadRoutes(persistCleanup = false) {
1250
+ if (!fs7.existsSync(this.routesPath)) return [];
1251
+ try {
1252
+ const raw = fs7.readFileSync(this.routesPath, "utf-8");
1253
+ const parsed = JSON.parse(raw);
1254
+ if (!Array.isArray(parsed)) {
1255
+ this.onWarning?.(`Corrupted routes file: expected an array at ${this.routesPath}.`);
1256
+ return [];
1257
+ }
1258
+ const routes = parsed.filter(isValidRoute);
1259
+ const aliveRoutes = routes.filter((route) => this.isProcessAlive(route.pid));
1260
+ if (persistCleanup && aliveRoutes.length !== routes.length) {
1261
+ this.saveRoutes(aliveRoutes);
1262
+ }
1263
+ return aliveRoutes;
1264
+ } catch {
1265
+ this.onWarning?.(`Failed to read routes from ${this.routesPath}.`);
1266
+ return [];
1267
+ }
1268
+ }
1269
+ saveRoutes(routes) {
1270
+ fs7.writeFileSync(this.routesPath, JSON.stringify(routes, null, 2), { mode: this.fileMode });
1271
+ fixOwnership(this.routesPath);
1272
+ }
1273
+ addRoute(hostname, port, pid, force = false) {
1274
+ this.ensureDir();
1275
+ if (!this.acquireLock()) {
1276
+ throw new Error("Failed to acquire route lock.");
1277
+ }
1278
+ try {
1279
+ const routes = this.loadRoutes(true);
1280
+ const existing = routes.find((route) => route.hostname === hostname);
1281
+ if (existing && existing.pid !== pid && this.isProcessAlive(existing.pid) && !force) {
1282
+ throw new RouteConflictError(hostname, existing.pid);
1283
+ }
1284
+ const nextRoutes = routes.filter((route) => route.hostname !== hostname);
1285
+ nextRoutes.push({ hostname, port, pid });
1286
+ this.saveRoutes(nextRoutes);
1287
+ } finally {
1288
+ this.releaseLock();
1289
+ }
1290
+ }
1291
+ removeRoute(hostname) {
1292
+ this.ensureDir();
1293
+ if (!this.acquireLock()) {
1294
+ throw new Error("Failed to acquire route lock.");
1295
+ }
1296
+ try {
1297
+ const nextRoutes = this.loadRoutes(true).filter((route) => route.hostname !== hostname);
1298
+ this.saveRoutes(nextRoutes);
1299
+ } finally {
1300
+ this.releaseLock();
1301
+ }
1302
+ }
1303
+ };
1304
+
1305
+ // src/cli.ts
1306
+ var HOSTS_DISPLAY = isWindows2 ? "hosts file" : "/etc/hosts";
1307
+ var SUDO_PREFIX = isWindows2 ? "" : "sudo ";
1308
+ var DEBOUNCE_MS = 100;
1309
+ var POLL_INTERVAL_MS = 3e3;
1310
+ var EXIT_TIMEOUT_MS = 2e3;
1311
+ var START_TIMEOUT_MS = 3e4;
1312
+ function printHelp() {
1313
+ console.log(`
1314
+ ${chalk.bold("local-router")} - Stable local domains with HTTP and HTTPS on the real hostnames you want.
1315
+
1316
+ ${chalk.bold("Core commands:")}
1317
+ ${chalk.cyan("local-router run next dev")} Infer the project name and expose it as https://<name>.localhost
1318
+ ${chalk.cyan("local-router run next dev --domain rapha.com.br")} Add a real domain override that resolves locally
1319
+ ${chalk.cyan("local-router proxy start")} Start the shared proxy daemon
1320
+ ${chalk.cyan("local-router proxy stop")} Stop the shared proxy daemon
1321
+ ${chalk.cyan("local-router trust")} Trust the generated local CA for HTTPS
1322
+ ${chalk.cyan("local-router hosts sync")} Sync managed entries to ${HOSTS_DISPLAY}
1323
+
1324
+ ${chalk.bold("Config file:")}
1325
+ Create ${chalk.cyan(".local-router")} in the project root:
1326
+
1327
+ {
1328
+ name: "algo",
1329
+ hosts: ["rapha.com.br", "bozo.com.br"]
1330
+ }
1331
+
1332
+ Then:
1333
+ ${chalk.cyan("local-router run next dev")}
1334
+
1335
+ Exposes:
1336
+ ${chalk.cyan("http://algo.localhost")}
1337
+ ${chalk.cyan("https://algo.localhost")}
1338
+ ${chalk.cyan("http://rapha.com.br")}
1339
+ ${chalk.cyan("https://rapha.com.br")}
1340
+ ${chalk.cyan("http://bozo.com.br")}
1341
+ ${chalk.cyan("https://bozo.com.br")}
1342
+
1343
+ ${chalk.bold("Options:")}
1344
+ run --name <name> Override the inferred .localhost name
1345
+ run --domain <host> Add a custom hostname (repeatable, can be placed after the child command)
1346
+ run --app-port <port> Force the app port instead of auto-assigning one
1347
+ run --force Replace an existing route registered by another process
1348
+ proxy start --http-port <port> Override the HTTP listener port (default: ${DEFAULT_HTTP_PORT})
1349
+ proxy start --https-port <port> Override the HTTPS listener port (default: ${DEFAULT_HTTPS_PORT})
1350
+ proxy start --no-http Disable the HTTP listener
1351
+ proxy start --no-https Disable the HTTPS listener
1352
+ proxy start --foreground Run in the foreground for debugging
1353
+
1354
+ ${chalk.bold("Notes:")}
1355
+ - Ports 80 and 443 require sudo on Unix. The CLI will prompt when needed.
1356
+ - Custom domains are managed through ${HOSTS_DISPLAY} and are restored/cleaned automatically.
1357
+ - HTTPS uses a local CA and per-host certificates generated on demand.
1358
+ `);
1359
+ }
1360
+ function parseNumberFlag(flag, value) {
1361
+ if (!value || value.startsWith("-")) {
1362
+ console.error(chalk.red(`Error: ${flag} requires a numeric value.`));
1363
+ process.exit(1);
1364
+ }
1365
+ const parsed = Number(value);
1366
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
1367
+ console.error(chalk.red(`Error: Invalid port "${value}". Expected a number between 1 and 65535.`));
1368
+ process.exit(1);
1369
+ }
1370
+ return parsed;
1371
+ }
1372
+ function parseRunArgs(args) {
1373
+ const options = {
1374
+ force: false,
1375
+ domains: [],
1376
+ commandArgs: []
1377
+ };
1378
+ let passthrough = false;
1379
+ for (let index = 0; index < args.length; index += 1) {
1380
+ const token = args[index];
1381
+ if (!passthrough && token === "--") {
1382
+ passthrough = true;
1383
+ continue;
1384
+ }
1385
+ if (!passthrough && token === "--force") {
1386
+ options.force = true;
1387
+ continue;
1388
+ }
1389
+ if (!passthrough && token === "--name") {
1390
+ options.name = args[index + 1];
1391
+ if (!options.name || options.name.startsWith("-")) {
1392
+ console.error(chalk.red("Error: --name requires a value."));
1393
+ process.exit(1);
1394
+ }
1395
+ index += 1;
1396
+ continue;
1397
+ }
1398
+ if (!passthrough && token === "--domain") {
1399
+ const value = args[index + 1];
1400
+ if (!value || value.startsWith("-")) {
1401
+ console.error(chalk.red("Error: --domain requires a hostname."));
1402
+ process.exit(1);
1403
+ }
1404
+ options.domains.push(value);
1405
+ index += 1;
1406
+ continue;
1407
+ }
1408
+ if (!passthrough && token === "--app-port") {
1409
+ options.appPort = parseNumberFlag("--app-port", args[index + 1]);
1410
+ index += 1;
1411
+ continue;
1412
+ }
1413
+ if (!passthrough && (token === "--help" || token === "-h")) {
1414
+ console.log(`
1415
+ ${chalk.bold("local-router run")} - Infer the project name, register hostnames, and run the app.
1416
+
1417
+ ${chalk.bold("Usage:")}
1418
+ ${chalk.cyan("local-router run <command...>")}
1419
+ ${chalk.cyan("local-router run next dev --domain rapha.com.br")}
1420
+
1421
+ ${chalk.bold("Flags accepted anywhere before '--':")}
1422
+ --name <name> Override the base .localhost name
1423
+ --domain <host> Add a custom hostname override
1424
+ --app-port <port> Use a fixed port for the child app
1425
+ --force Replace routes already claimed by another process
1426
+
1427
+ Use ${chalk.cyan("--")} if the child command itself needs a colliding flag name.
1428
+ `);
1429
+ process.exit(0);
1430
+ }
1431
+ options.commandArgs.push(token);
1432
+ }
1433
+ return options;
1434
+ }
1435
+ function parseProxyArgs(args) {
1436
+ const options = {
1437
+ httpPort: getDefaultHttpPort(),
1438
+ httpsPort: getDefaultHttpsPort(),
1439
+ httpEnabled: true,
1440
+ httpsEnabled: true,
1441
+ foreground: false
1442
+ };
1443
+ for (let index = 0; index < args.length; index += 1) {
1444
+ const token = args[index];
1445
+ if (token === "--foreground") {
1446
+ options.foreground = true;
1447
+ continue;
1448
+ }
1449
+ if (token === "--http-port") {
1450
+ options.httpPort = parseNumberFlag("--http-port", args[index + 1]);
1451
+ index += 1;
1452
+ continue;
1453
+ }
1454
+ if (token === "--https-port") {
1455
+ options.httpsPort = parseNumberFlag("--https-port", args[index + 1]);
1456
+ index += 1;
1457
+ continue;
1458
+ }
1459
+ if (token === "--no-http") {
1460
+ options.httpEnabled = false;
1461
+ continue;
1462
+ }
1463
+ if (token === "--no-https") {
1464
+ options.httpsEnabled = false;
1465
+ continue;
1466
+ }
1467
+ if (token === "--help" || token === "-h") {
1468
+ console.log(`
1469
+ ${chalk.bold("local-router proxy start")} - Start the shared router daemon.
1470
+
1471
+ ${chalk.bold("Usage:")}
1472
+ ${chalk.cyan("local-router proxy start")}
1473
+ ${chalk.cyan("local-router proxy start --foreground")}
1474
+ ${chalk.cyan("local-router proxy start --http-port 8080 --https-port 8443")}
1475
+
1476
+ ${chalk.bold("Options:")}
1477
+ --http-port <port> HTTP listener port (default: ${DEFAULT_HTTP_PORT})
1478
+ --https-port <port> HTTPS listener port (default: ${DEFAULT_HTTPS_PORT})
1479
+ --no-http Disable the HTTP listener
1480
+ --no-https Disable the HTTPS listener
1481
+ --foreground Run without daemonizing
1482
+ `);
1483
+ process.exit(0);
1484
+ }
1485
+ console.error(chalk.red(`Error: Unknown proxy flag "${token}".`));
1486
+ process.exit(1);
1487
+ }
1488
+ if (!options.httpEnabled && !options.httpsEnabled) {
1489
+ console.error(chalk.red("Error: at least one of HTTP or HTTPS must stay enabled."));
1490
+ process.exit(1);
1491
+ }
1492
+ return options;
1493
+ }
1494
+ function printUrls(hostnames, runtime) {
1495
+ console.log(chalk.blue.bold("\nURLs\n"));
1496
+ for (const hostname of hostnames) {
1497
+ if (runtime.httpEnabled) {
1498
+ console.log(chalk.cyan(` ${formatUrl(hostname, false, runtime.httpPort)}`));
1499
+ }
1500
+ if (runtime.httpsEnabled) {
1501
+ console.log(chalk.cyan(` ${formatUrl(hostname, true, runtime.httpsPort)}`));
1502
+ }
1503
+ }
1504
+ console.log();
1505
+ }
1506
+ function registerHostnames(store, hostnames, port, pid, force) {
1507
+ const registered = [];
1508
+ try {
1509
+ for (const hostname of hostnames) {
1510
+ store.addRoute(hostname, port, pid, force);
1511
+ registered.push(hostname);
1512
+ }
1513
+ } catch (error) {
1514
+ for (const hostname of registered) {
1515
+ try {
1516
+ store.removeRoute(hostname);
1517
+ } catch {
1518
+ }
1519
+ }
1520
+ throw error;
1521
+ }
1522
+ }
1523
+ async function stopProxy(store, stateDir, runtime) {
1524
+ if (!fs8.existsSync(store.pidPath)) {
1525
+ if (await isProxyRunning(runtime)) {
1526
+ const pid = findPidOnPort(runtime.httpEnabled ? runtime.httpPort : runtime.httpsPort);
1527
+ if (pid !== null) {
1528
+ try {
1529
+ process.kill(pid, "SIGTERM");
1530
+ removeProxyState(stateDir);
1531
+ console.log(chalk.green(`Stopped proxy process ${pid}.`));
1532
+ return;
1533
+ } catch (error) {
1534
+ if (isErrnoException(error) && error.code === "EPERM") {
1535
+ console.error(chalk.red("Permission denied while stopping the proxy."));
1536
+ console.error(chalk.cyan(` ${SUDO_PREFIX}local-router proxy stop`));
1537
+ process.exit(1);
1538
+ }
1539
+ }
1540
+ }
1541
+ }
1542
+ console.log(chalk.yellow("Proxy is not running."));
1543
+ return;
1544
+ }
1545
+ try {
1546
+ const pid = Number(fs8.readFileSync(store.pidPath, "utf-8"));
1547
+ if (!Number.isInteger(pid) || pid <= 0) {
1548
+ console.error(chalk.red("Corrupted PID file."));
1549
+ process.exit(1);
1550
+ }
1551
+ process.kill(pid, "SIGTERM");
1552
+ console.log(chalk.green("Proxy stopped."));
1553
+ } catch (error) {
1554
+ if (isErrnoException(error) && error.code === "EPERM") {
1555
+ console.error(chalk.red("Permission denied while stopping the proxy."));
1556
+ console.error(chalk.cyan(` ${SUDO_PREFIX}local-router proxy stop`));
1557
+ process.exit(1);
1558
+ }
1559
+ const message = error instanceof Error ? error.message : String(error);
1560
+ console.error(chalk.red(`Failed to stop the proxy: ${message}`));
1561
+ process.exit(1);
1562
+ }
1563
+ }
1564
+ function writeEmptyRoutesIfNeeded(store) {
1565
+ if (!fs8.existsSync(store.routesPath)) {
1566
+ fs8.writeFileSync(store.routesPath, "[]", { mode: FILE_MODE });
1567
+ fixOwnership(store.routesPath);
1568
+ }
1569
+ }
1570
+ function needsPrivileges(runtime) {
1571
+ if (isWindows2) return false;
1572
+ return runtime.httpEnabled && runtime.httpPort < PRIVILEGED_PORT_THRESHOLD || runtime.httpsEnabled && runtime.httpsPort < PRIVILEGED_PORT_THRESHOLD;
1573
+ }
1574
+ function startProxyServer(store, runtime, stateDir) {
1575
+ store.ensureDir();
1576
+ writeEmptyRoutesIfNeeded(store);
1577
+ let cachedRoutes = store.loadRoutes();
1578
+ let debounceTimer = null;
1579
+ let watcher = null;
1580
+ let poller = null;
1581
+ const reloadRoutes = () => {
1582
+ try {
1583
+ cachedRoutes = store.loadRoutes();
1584
+ syncHostsFile(cachedRoutes.map((route) => route.hostname));
1585
+ } catch {
1586
+ }
1587
+ };
1588
+ try {
1589
+ watcher = fs8.watch(store.routesPath, () => {
1590
+ if (debounceTimer) clearTimeout(debounceTimer);
1591
+ debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
1592
+ });
1593
+ } catch {
1594
+ poller = setInterval(reloadRoutes, POLL_INTERVAL_MS);
1595
+ }
1596
+ syncHostsFile(cachedRoutes.map((route) => route.hostname));
1597
+ let tlsOptions;
1598
+ if (runtime.httpsEnabled) {
1599
+ const certs = ensureCerts(stateDir);
1600
+ if (!isCATrusted(stateDir)) {
1601
+ const trustResult = trustCA(stateDir);
1602
+ if (!trustResult.trusted) {
1603
+ console.warn(chalk.yellow("Warning: could not trust the local CA automatically."));
1604
+ if (trustResult.error) {
1605
+ console.warn(chalk.gray(trustResult.error));
1606
+ }
1607
+ }
1608
+ }
1609
+ tlsOptions = {
1610
+ cert: fs8.readFileSync(certs.certPath),
1611
+ key: fs8.readFileSync(certs.keyPath),
1612
+ SNICallback: createSNICallback(stateDir, fs8.readFileSync(certs.certPath), fs8.readFileSync(certs.keyPath))
1613
+ };
1614
+ }
1615
+ const servers = createProxyServers({
1616
+ getRoutes: () => cachedRoutes,
1617
+ httpEnabled: runtime.httpEnabled,
1618
+ httpsEnabled: runtime.httpsEnabled,
1619
+ httpPort: runtime.httpPort,
1620
+ httpsPort: runtime.httpsPort,
1621
+ tls: tlsOptions,
1622
+ onError: (message) => console.error(chalk.red(message))
1623
+ });
1624
+ let pendingListeners = 0;
1625
+ const onListening = () => {
1626
+ pendingListeners -= 1;
1627
+ if (pendingListeners > 0) return;
1628
+ fs8.writeFileSync(store.pidPath, String(process.pid), { mode: FILE_MODE });
1629
+ writeProxyState(stateDir, { pid: process.pid, ...runtime });
1630
+ fixOwnership(store.pidPath, store.statePath);
1631
+ console.log(chalk.green("local-router proxy is listening."));
1632
+ if (runtime.httpEnabled) {
1633
+ console.log(chalk.gray(`HTTP -> ${runtime.httpPort}`));
1634
+ }
1635
+ if (runtime.httpsEnabled) {
1636
+ console.log(chalk.gray(`HTTPS -> ${runtime.httpsPort}`));
1637
+ }
1638
+ };
1639
+ const registerServerError = (label, port) => (error) => {
1640
+ if (error.code === "EADDRINUSE") {
1641
+ console.error(chalk.red(`${label} port ${port} is already in use.`));
1642
+ } else if (error.code === "EACCES") {
1643
+ console.error(chalk.red(`Permission denied while binding ${label} port ${port}.`));
1644
+ } else {
1645
+ console.error(chalk.red(`${label} server error: ${error.message}`));
1646
+ }
1647
+ process.exit(1);
1648
+ };
1649
+ if (servers.httpServer) {
1650
+ pendingListeners += 1;
1651
+ servers.httpServer.on("error", registerServerError("HTTP", runtime.httpPort));
1652
+ servers.httpServer.listen(runtime.httpPort, onListening);
1653
+ }
1654
+ if (servers.httpsServer) {
1655
+ pendingListeners += 1;
1656
+ servers.httpsServer.on("error", registerServerError("HTTPS", runtime.httpsPort));
1657
+ servers.httpsServer.listen(runtime.httpsPort, onListening);
1658
+ }
1659
+ const cleanup = () => {
1660
+ if (debounceTimer) clearTimeout(debounceTimer);
1661
+ if (poller) clearInterval(poller);
1662
+ watcher?.close();
1663
+ try {
1664
+ fs8.unlinkSync(store.pidPath);
1665
+ } catch {
1666
+ }
1667
+ removeProxyState(stateDir);
1668
+ cleanHostsFile();
1669
+ const closePromises = [];
1670
+ if (servers.httpServer) {
1671
+ closePromises.push(new Promise((resolve) => servers.httpServer.close(() => resolve())));
1672
+ }
1673
+ if (servers.httpsServer) {
1674
+ closePromises.push(new Promise((resolve) => servers.httpsServer.close(() => resolve())));
1675
+ }
1676
+ Promise.all(closePromises).finally(() => process.exit(0));
1677
+ setTimeout(() => process.exit(0), EXIT_TIMEOUT_MS).unref();
1678
+ };
1679
+ process.on("SIGINT", cleanup);
1680
+ process.on("SIGTERM", cleanup);
1681
+ }
1682
+ async function ensureProxy(runtime) {
1683
+ const { dir, state } = await discoverState();
1684
+ const effectiveState = state ?? {
1685
+ pid: 0,
1686
+ ...runtime
1687
+ };
1688
+ const stateDir = resolveStateDir(runtime);
1689
+ const store = new RouteStore(stateDir, {
1690
+ isSystemDir: stateDir === SYSTEM_STATE_DIR,
1691
+ onWarning: (message) => console.warn(chalk.yellow(message))
1692
+ });
1693
+ if (state && await isProxyRunning(state)) {
1694
+ return { dir, store: new RouteStore(dir, { isSystemDir: dir === SYSTEM_STATE_DIR }) };
1695
+ }
1696
+ const requiresSudo = needsPrivileges(effectiveState);
1697
+ if (requiresSudo && (process.getuid?.() ?? -1) !== 0) {
1698
+ if (!process.stdin.isTTY) {
1699
+ console.error(chalk.red("The proxy is not running and the default ports require sudo."));
1700
+ console.error(chalk.cyan(` ${SUDO_PREFIX}local-router proxy start`));
1701
+ process.exit(1);
1702
+ }
1703
+ const answer = await prompt(chalk.yellow("Proxy not running. Start it with sudo now? [Y/n/skip] "));
1704
+ if (answer === "n" || answer === "no") {
1705
+ process.exit(0);
1706
+ }
1707
+ if (answer === "s" || answer === "skip") {
1708
+ throw new Error("Skipping the proxy is not supported for this command.");
1709
+ }
1710
+ const childArgs = [process.execPath, process.argv[1], "proxy", "start"];
1711
+ if (!runtime.httpEnabled) childArgs.push("--no-http");
1712
+ if (!runtime.httpsEnabled) childArgs.push("--no-https");
1713
+ if (runtime.httpPort !== getDefaultHttpPort()) childArgs.push("--http-port", String(runtime.httpPort));
1714
+ if (runtime.httpsPort !== getDefaultHttpsPort()) childArgs.push("--https-port", String(runtime.httpsPort));
1715
+ const result = spawnSync("sudo", childArgs, {
1716
+ stdio: "inherit",
1717
+ timeout: START_TIMEOUT_MS
1718
+ });
1719
+ if (result.status !== 0) {
1720
+ console.error(chalk.red("Failed to start the proxy with sudo."));
1721
+ process.exit(1);
1722
+ }
1723
+ } else {
1724
+ const childArgs = [process.argv[1], "proxy", "start"];
1725
+ if (!runtime.httpEnabled) childArgs.push("--no-http");
1726
+ if (!runtime.httpsEnabled) childArgs.push("--no-https");
1727
+ if (runtime.httpPort !== getDefaultHttpPort()) childArgs.push("--http-port", String(runtime.httpPort));
1728
+ if (runtime.httpsPort !== getDefaultHttpsPort()) childArgs.push("--https-port", String(runtime.httpsPort));
1729
+ const result = spawnSync(process.execPath, childArgs, {
1730
+ stdio: "inherit",
1731
+ timeout: START_TIMEOUT_MS
1732
+ });
1733
+ if (result.status !== 0) {
1734
+ console.error(chalk.red("Failed to start the proxy."));
1735
+ process.exit(1);
1736
+ }
1737
+ }
1738
+ if (!await waitForProxy(runtime)) {
1739
+ console.error(chalk.red("Proxy failed to become ready."));
1740
+ process.exit(1);
1741
+ }
1742
+ return {
1743
+ dir: stateDir,
1744
+ store
1745
+ };
1746
+ }
1747
+ async function handleRun(args) {
1748
+ const parsed = parseRunArgs(args);
1749
+ if (parsed.commandArgs.length === 0) {
1750
+ console.error(chalk.red("Error: no child command provided."));
1751
+ console.error(chalk.cyan(" local-router run next dev"));
1752
+ process.exit(1);
1753
+ }
1754
+ const resolved = resolveProjectHosts({
1755
+ explicitName: parsed.name,
1756
+ extraDomains: parsed.domains
1757
+ });
1758
+ console.log(chalk.blue.bold("\nlocal-router\n"));
1759
+ console.log(chalk.gray(`Base hostname: ${resolved.baseHostname}`));
1760
+ console.log(chalk.gray(`Name source: ${resolved.nameSource}`));
1761
+ if (resolved.configPath) {
1762
+ console.log(chalk.gray(`Config: ${resolved.configPath}`));
1763
+ }
1764
+ const runtime = {
1765
+ httpPort: getDefaultHttpPort(),
1766
+ httpsPort: getDefaultHttpsPort(),
1767
+ httpEnabled: true,
1768
+ httpsEnabled: true
1769
+ };
1770
+ const { dir } = await ensureProxy(runtime);
1771
+ const store = new RouteStore(dir, {
1772
+ isSystemDir: dir === SYSTEM_STATE_DIR,
1773
+ onWarning: (message) => console.warn(chalk.yellow(message))
1774
+ });
1775
+ const port = parsed.appPort ?? await findFreePort();
1776
+ console.log(chalk.green(`App port: ${port}`));
1777
+ try {
1778
+ registerHostnames(store, resolved.hostnames, port, process.pid, parsed.force);
1779
+ } catch (error) {
1780
+ if (error instanceof RouteConflictError) {
1781
+ console.error(chalk.red(error.message));
1782
+ process.exit(1);
1783
+ }
1784
+ throw error;
1785
+ }
1786
+ printUrls(resolved.hostnames, runtime);
1787
+ injectFrameworkFlags(parsed.commandArgs, port);
1788
+ const primaryHttpsUrl = formatUrl(resolved.baseHostname, true, runtime.httpsPort);
1789
+ const allHttpsUrls = resolved.hostnames.map((hostname) => formatUrl(hostname, true, runtime.httpsPort)).join(",");
1790
+ const allHttpUrls = resolved.hostnames.map((hostname) => formatUrl(hostname, false, runtime.httpPort)).join(",");
1791
+ spawnCommand(parsed.commandArgs, {
1792
+ env: {
1793
+ ...process.env,
1794
+ PORT: String(port),
1795
+ HOST: "127.0.0.1",
1796
+ LOCAL_ROUTER_URL: primaryHttpsUrl,
1797
+ LOCAL_ROUTER_URLS_HTTPS: allHttpsUrls,
1798
+ LOCAL_ROUTER_URLS_HTTP: allHttpUrls,
1799
+ __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: ".localhost"
1800
+ },
1801
+ onCleanup: () => {
1802
+ for (const hostname of resolved.hostnames) {
1803
+ try {
1804
+ store.removeRoute(hostname);
1805
+ } catch {
1806
+ }
1807
+ }
1808
+ }
1809
+ });
1810
+ }
1811
+ async function handleHosts(args) {
1812
+ const subcommand = args[0];
1813
+ if (subcommand === "clean") {
1814
+ if (!cleanHostsFile()) {
1815
+ console.error(chalk.red(`Failed to update ${HOSTS_DISPLAY}.`));
1816
+ process.exit(1);
1817
+ }
1818
+ console.log(chalk.green(`Removed managed entries from ${HOSTS_DISPLAY}.`));
1819
+ return;
1820
+ }
1821
+ if (subcommand !== "sync") {
1822
+ console.log(`
1823
+ ${chalk.bold("Usage:")}
1824
+ ${chalk.cyan(`${SUDO_PREFIX}local-router hosts sync`)}
1825
+ ${chalk.cyan(`${SUDO_PREFIX}local-router hosts clean`)}
1826
+ `);
1827
+ return;
1828
+ }
1829
+ const { dir } = await discoverState();
1830
+ const store = new RouteStore(dir, { isSystemDir: dir === SYSTEM_STATE_DIR });
1831
+ const hostnames = store.loadRoutes().map((route) => route.hostname);
1832
+ if (!syncHostsFile(hostnames)) {
1833
+ console.error(chalk.red(`Failed to update ${HOSTS_DISPLAY}.`));
1834
+ process.exit(1);
1835
+ }
1836
+ console.log(chalk.green(`Synced ${hostnames.length} hostnames to ${HOSTS_DISPLAY}.`));
1837
+ }
1838
+ async function handleProxy(args) {
1839
+ const subcommand = args[0];
1840
+ if (subcommand === "stop") {
1841
+ const { dir, state } = await discoverState();
1842
+ const runtime = state ?? {
1843
+ httpPort: getDefaultHttpPort(),
1844
+ httpsPort: getDefaultHttpsPort(),
1845
+ httpEnabled: true,
1846
+ httpsEnabled: true
1847
+ };
1848
+ const store2 = new RouteStore(dir, { isSystemDir: dir === SYSTEM_STATE_DIR });
1849
+ await stopProxy(store2, dir, runtime);
1850
+ return;
1851
+ }
1852
+ if (subcommand !== "start") {
1853
+ console.log(`
1854
+ ${chalk.bold("Usage:")}
1855
+ ${chalk.cyan("local-router proxy start")}
1856
+ ${chalk.cyan("local-router proxy stop")}
1857
+ `);
1858
+ return;
1859
+ }
1860
+ const options = parseProxyArgs(args.slice(1));
1861
+ const stateDir = resolveStateDir(options);
1862
+ const store = new RouteStore(stateDir, {
1863
+ isSystemDir: stateDir === SYSTEM_STATE_DIR,
1864
+ onWarning: (message) => console.warn(chalk.yellow(message))
1865
+ });
1866
+ if (!isWindows2 && needsPrivileges(options) && (process.getuid?.() ?? -1) !== 0) {
1867
+ console.error(chalk.red("The selected ports require sudo."));
1868
+ console.error(chalk.cyan(` ${SUDO_PREFIX}local-router proxy start`));
1869
+ process.exit(1);
1870
+ }
1871
+ if (await isProxyRunning(options)) {
1872
+ console.log(chalk.yellow("Proxy is already running."));
1873
+ return;
1874
+ }
1875
+ if (options.foreground) {
1876
+ console.log(chalk.blue.bold("\nlocal-router proxy\n"));
1877
+ startProxyServer(store, options, stateDir);
1878
+ return;
1879
+ }
1880
+ store.ensureDir();
1881
+ const logPath = path7.join(stateDir, "proxy.log");
1882
+ const logFd = fs8.openSync(logPath, "a");
1883
+ try {
1884
+ try {
1885
+ fs8.chmodSync(logPath, FILE_MODE);
1886
+ fs8.chmodSync(stateDir, DIR_MODE);
1887
+ } catch {
1888
+ }
1889
+ fixOwnership(logPath, stateDir);
1890
+ const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
1891
+ if (!options.httpEnabled) daemonArgs.push("--no-http");
1892
+ if (!options.httpsEnabled) daemonArgs.push("--no-https");
1893
+ if (options.httpPort !== getDefaultHttpPort()) daemonArgs.push("--http-port", String(options.httpPort));
1894
+ if (options.httpsPort !== getDefaultHttpsPort()) daemonArgs.push("--https-port", String(options.httpsPort));
1895
+ const child = spawn2(process.execPath, daemonArgs, {
1896
+ detached: true,
1897
+ stdio: ["ignore", logFd, logFd],
1898
+ env: process.env,
1899
+ windowsHide: true
1900
+ });
1901
+ child.unref();
1902
+ } finally {
1903
+ fs8.closeSync(logFd);
1904
+ }
1905
+ if (!await waitForProxy(options)) {
1906
+ console.error(chalk.red("Proxy failed to start."));
1907
+ console.error(chalk.gray(`Logs: ${logPath}`));
1908
+ process.exit(1);
1909
+ }
1910
+ console.log(chalk.green("local-router proxy started."));
1911
+ }
1912
+ async function handleTrust() {
1913
+ const { dir } = await discoverState();
1914
+ const result = trustCA(dir);
1915
+ if (!result.trusted) {
1916
+ console.error(chalk.red(`Failed to trust the CA: ${result.error ?? "unknown error"}`));
1917
+ process.exit(1);
1918
+ }
1919
+ console.log(chalk.green("Local CA added to the trust store."));
1920
+ }
1921
+ async function main() {
1922
+ const args = process.argv.slice(2);
1923
+ const command = args[0];
1924
+ if (!command || command === "--help" || command === "-h") {
1925
+ printHelp();
1926
+ return;
1927
+ }
1928
+ if (command === "--version" || command === "-v") {
1929
+ console.log("0.1.0");
1930
+ return;
1931
+ }
1932
+ if (command === "run") {
1933
+ await handleRun(args.slice(1));
1934
+ return;
1935
+ }
1936
+ if (command === "proxy") {
1937
+ await handleProxy(args.slice(1));
1938
+ return;
1939
+ }
1940
+ if (command === "hosts") {
1941
+ await handleHosts(args.slice(1));
1942
+ return;
1943
+ }
1944
+ if (command === "trust") {
1945
+ await handleTrust();
1946
+ return;
1947
+ }
1948
+ console.error(chalk.red(`Unknown command "${command}".`));
1949
+ printHelp();
1950
+ process.exit(1);
1951
+ }
1952
+ main().catch((error) => {
1953
+ const message = error instanceof Error ? error.message : String(error);
1954
+ console.error(chalk.red(message));
1955
+ process.exit(1);
1956
+ });