@elisoncampos/local-router 0.1.0 → 0.2.1

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 @@
1
+ /opt/hostedtoolcache/node/24.14.0/x64/bin/node
@@ -0,0 +1,104 @@
1
+ #!/bin/sh
2
+
3
+ set -eu
4
+
5
+ SOURCE="$0"
6
+
7
+ while [ -h "$SOURCE" ]; do
8
+ DIR=$(CDPATH= cd -- "$(dirname -- "$SOURCE")" && pwd)
9
+ TARGET=$(readlink "$SOURCE")
10
+ case "$TARGET" in
11
+ /*) SOURCE="$TARGET" ;;
12
+ *) SOURCE="$DIR/$TARGET" ;;
13
+ esac
14
+ done
15
+
16
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SOURCE")" && pwd)
17
+ PACKAGE_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
18
+ INSTALLED_NODE_PATH_FILE="$PACKAGE_ROOT/bin/.installed-node-path"
19
+
20
+ PRIMARY_NODE="$(command -v node 2>/dev/null || true)"
21
+
22
+ can_run_node() {
23
+ CANDIDATE="$1"
24
+ [ -n "$CANDIDATE" ] || return 1
25
+ [ -x "$CANDIDATE" ] || return 1
26
+ "$CANDIDATE" -e "process.exit(0)" >/dev/null 2>&1
27
+ }
28
+
29
+ find_fallback_node() {
30
+ OLD_IFS=$IFS
31
+ IFS=:
32
+ for DIR in $PATH; do
33
+ [ -n "$DIR" ] || continue
34
+ CANDIDATE="$DIR/node"
35
+ [ -x "$CANDIDATE" ] || continue
36
+
37
+ if [ -n "$PRIMARY_NODE" ] && [ "$CANDIDATE" = "$PRIMARY_NODE" ]; then
38
+ continue
39
+ fi
40
+
41
+ case "$CANDIDATE" in
42
+ */.asdf/shims/node|*/asdf/shims/node|*/.mise/shims/node|*/mise/shims/node)
43
+ continue
44
+ ;;
45
+ esac
46
+
47
+ if can_run_node "$CANDIDATE"; then
48
+ printf '%s\n' "$CANDIDATE"
49
+ IFS=$OLD_IFS
50
+ return 0
51
+ fi
52
+ done
53
+ IFS=$OLD_IFS
54
+ return 1
55
+ }
56
+
57
+ SELECTED_NODE=""
58
+ USED_FALLBACK="0"
59
+
60
+ if [ -n "$PRIMARY_NODE" ] && can_run_node "$PRIMARY_NODE"; then
61
+ SELECTED_NODE="$PRIMARY_NODE"
62
+ else
63
+ if [ -f "$INSTALLED_NODE_PATH_FILE" ]; then
64
+ INSTALLED_NODE="$(tr -d '\r\n' < "$INSTALLED_NODE_PATH_FILE")"
65
+ if can_run_node "$INSTALLED_NODE"; then
66
+ SELECTED_NODE="$INSTALLED_NODE"
67
+ fi
68
+ fi
69
+
70
+ if [ -z "$SELECTED_NODE" ]; then
71
+ SELECTED_NODE="$(find_fallback_node || true)"
72
+ fi
73
+
74
+ if [ -n "$SELECTED_NODE" ]; then
75
+ USED_FALLBACK="1"
76
+ fi
77
+ fi
78
+
79
+ if [ -z "$SELECTED_NODE" ]; then
80
+ echo "local-router: could not find a working Node.js runtime." >&2
81
+ echo "local-router: install the project's Node version or make a global node available in PATH." >&2
82
+ exit 1
83
+ fi
84
+
85
+ CLI_JS="$PACKAGE_ROOT/dist/cli.js"
86
+ TSX_CLI="$PACKAGE_ROOT/node_modules/tsx/dist/cli.mjs"
87
+ CLI_TS="$PACKAGE_ROOT/src/cli.ts"
88
+
89
+ if [ -f "$CLI_JS" ]; then
90
+ exec env \
91
+ LOCAL_ROUTER_NODE_BIN="$SELECTED_NODE" \
92
+ LOCAL_ROUTER_NODE_FALLBACK="$USED_FALLBACK" \
93
+ "$SELECTED_NODE" "$CLI_JS" "$@"
94
+ fi
95
+
96
+ if [ -f "$TSX_CLI" ] && [ -f "$CLI_TS" ]; then
97
+ exec env \
98
+ LOCAL_ROUTER_NODE_BIN="$SELECTED_NODE" \
99
+ LOCAL_ROUTER_NODE_FALLBACK="$USED_FALLBACK" \
100
+ "$SELECTED_NODE" "$TSX_CLI" "$CLI_TS" "$@"
101
+ fi
102
+
103
+ echo "local-router: CLI entrypoint not found. Build the project first." >&2
104
+ exit 1
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import chalk from "chalk";
5
5
  import * as fs8 from "fs";
6
6
  import * as path7 from "path";
7
7
  import { spawn as spawn2, spawnSync } from "child_process";
8
+ import { fileURLToPath } from "url";
8
9
 
9
10
  // src/certs.ts
10
11
  import * as crypto from "crypto";
@@ -44,8 +45,11 @@ function formatUrl(hostname, https2, port) {
44
45
  function removeProtocol(hostname) {
45
46
  return hostname.replace(/^https?:\/\//i, "").split("/")[0].trim();
46
47
  }
48
+ function trimTrailingDots(hostname) {
49
+ return hostname.replace(/\.+$/, "");
50
+ }
47
51
  function normalizeExplicitHostname(hostname) {
48
- const normalized = removeProtocol(hostname).toLowerCase();
52
+ const normalized = trimTrailingDots(removeProtocol(hostname).toLowerCase());
49
53
  if (!normalized) {
50
54
  throw new Error("Hostname cannot be empty.");
51
55
  }
@@ -67,6 +71,18 @@ function normalizeExplicitHostname(hostname) {
67
71
  }
68
72
  return normalized;
69
73
  }
74
+ function normalizeRequestHostname(hostname) {
75
+ const authority = removeProtocol(hostname);
76
+ if (!authority) return "";
77
+ if (authority.startsWith("[")) {
78
+ const closingBracketIndex = authority.indexOf("]");
79
+ const bracketedHost = closingBracketIndex === -1 ? authority.slice(1) : authority.slice(1, closingBracketIndex);
80
+ return trimTrailingDots(bracketedHost.toLowerCase());
81
+ }
82
+ const lastColonIndex = authority.lastIndexOf(":");
83
+ const withoutPort = lastColonIndex === -1 || authority.includes(":", lastColonIndex + 1) ? authority : authority.slice(0, lastColonIndex);
84
+ return trimTrailingDots(withoutPort.toLowerCase());
85
+ }
70
86
 
71
87
  // src/certs.ts
72
88
  var CA_VALIDITY_DAYS = 3650;
@@ -298,22 +314,23 @@ function createSNICallback(stateDir, defaultCert, defaultKey) {
298
314
  const defaultContext = tls.createSecureContext({ cert: defaultCert, key: defaultKey });
299
315
  return async (servername, callback) => {
300
316
  try {
301
- if (!servername || servername === "localhost") {
317
+ const normalizedServername = normalizeRequestHostname(servername);
318
+ if (!normalizedServername || normalizedServername === "localhost") {
302
319
  callback(null, defaultContext);
303
320
  return;
304
321
  }
305
- const cached = cache.get(servername);
322
+ const cached = cache.get(normalizedServername);
306
323
  if (cached) {
307
324
  callback(null, cached);
308
325
  return;
309
326
  }
310
- const safeName = sanitizeHostForFilename(servername);
327
+ const safeName = sanitizeHostForFilename(normalizedServername);
311
328
  const certPath = path.join(stateDir, HOST_CERTS_DIR, `${safeName}.pem`);
312
329
  const keyPath = path.join(stateDir, HOST_CERTS_DIR, `${safeName}-key.pem`);
313
330
  let resolvedCertPath = certPath;
314
331
  let resolvedKeyPath = keyPath;
315
332
  if (!fileExists(certPath) || !fileExists(keyPath) || !isCertValid(certPath) || !isCertSignatureStrong(certPath)) {
316
- const generated = await generateHostCertAsync(stateDir, servername);
333
+ const generated = await generateHostCertAsync(stateDir, normalizedServername);
317
334
  resolvedCertPath = generated.certPath;
318
335
  resolvedKeyPath = generated.keyPath;
319
336
  }
@@ -321,7 +338,7 @@ function createSNICallback(stateDir, defaultCert, defaultKey) {
321
338
  cert: fs2.readFileSync(resolvedCertPath),
322
339
  key: fs2.readFileSync(resolvedKeyPath)
323
340
  });
324
- cache.set(servername, context);
341
+ cache.set(normalizedServername, context);
325
342
  callback(null, context);
326
343
  } catch (error) {
327
344
  callback(error instanceof Error ? error : new Error(String(error)), defaultContext);
@@ -504,12 +521,19 @@ function inferProjectName(cwd = process.cwd()) {
504
521
 
505
522
  // src/config.ts
506
523
  var CONFIG_FILENAMES = [".local-router", ".local-router.json", "local-router.config.json"];
524
+ function isReadableFile(candidate) {
525
+ try {
526
+ return fs4.statSync(candidate).isFile();
527
+ } catch {
528
+ return false;
529
+ }
530
+ }
507
531
  function findLocalRouterConfig(cwd = process.cwd()) {
508
532
  let dir = cwd;
509
533
  for (; ; ) {
510
534
  for (const filename of CONFIG_FILENAMES) {
511
535
  const candidate = path3.join(dir, filename);
512
- if (fs4.existsSync(candidate)) {
536
+ if (isReadableFile(candidate)) {
513
537
  return candidate;
514
538
  }
515
539
  }
@@ -666,7 +690,7 @@ var MIN_APP_PORT = 4e3;
666
690
  var MAX_APP_PORT = 4999;
667
691
  var RANDOM_PORT_ATTEMPTS = 50;
668
692
  var SOCKET_TIMEOUT_MS = 750;
669
- var WAIT_FOR_PROXY_MAX_ATTEMPTS = 24;
693
+ var WAIT_FOR_PROXY_MAX_ATTEMPTS = 60;
670
694
  var WAIT_FOR_PROXY_INTERVAL_MS = 250;
671
695
  var HEALTH_HEADER = "x-local-router";
672
696
  var SIGNAL_CODES = {
@@ -830,6 +854,9 @@ function spawnCommand(commandArgs, options) {
830
854
  const cleanup = () => {
831
855
  process.removeListener("SIGINT", onSigInt);
832
856
  process.removeListener("SIGTERM", onSigTerm);
857
+ process.removeListener("SIGHUP", onSigHup);
858
+ process.removeListener("uncaughtException", onUncaughtException);
859
+ process.removeListener("unhandledRejection", onUnhandledRejection);
833
860
  options?.onCleanup?.();
834
861
  };
835
862
  const handleSignal = (signal) => {
@@ -841,8 +868,28 @@ function spawnCommand(commandArgs, options) {
841
868
  };
842
869
  const onSigInt = () => handleSignal("SIGINT");
843
870
  const onSigTerm = () => handleSignal("SIGTERM");
871
+ const onSigHup = () => handleSignal("SIGHUP");
872
+ const onUncaughtException = (error) => {
873
+ if (exiting) return;
874
+ exiting = true;
875
+ console.error(error.stack || error.message);
876
+ child.kill("SIGTERM");
877
+ cleanup();
878
+ process.exit(1);
879
+ };
880
+ const onUnhandledRejection = (reason) => {
881
+ if (exiting) return;
882
+ exiting = true;
883
+ console.error(reason instanceof Error ? reason.stack || reason.message : String(reason));
884
+ child.kill("SIGTERM");
885
+ cleanup();
886
+ process.exit(1);
887
+ };
844
888
  process.on("SIGINT", onSigInt);
845
889
  process.on("SIGTERM", onSigTerm);
890
+ process.on("SIGHUP", onSigHup);
891
+ process.on("uncaughtException", onUncaughtException);
892
+ process.on("unhandledRejection", onUnhandledRejection);
846
893
  child.on("error", (error) => {
847
894
  if (exiting) return;
848
895
  exiting = true;
@@ -948,19 +995,34 @@ function getHealthHeader() {
948
995
  }
949
996
 
950
997
  // src/proxy.ts
951
- function getRequestHost(req) {
998
+ function getRequestAuthority(req) {
952
999
  const authority = req.headers[":authority"];
953
1000
  if (typeof authority === "string" && authority) return authority;
1001
+ if (Array.isArray(req.headers.host)) {
1002
+ return req.headers.host[0] ?? "";
1003
+ }
954
1004
  return req.headers.host ?? "";
955
1005
  }
956
- function buildForwardedHeaders(req, tls2) {
1006
+ function getAuthorityPort(authority) {
1007
+ if (!authority) return void 0;
1008
+ if (authority.startsWith("[")) {
1009
+ const closingBracketIndex = authority.indexOf("]");
1010
+ if (closingBracketIndex !== -1 && authority[closingBracketIndex + 1] === ":") {
1011
+ return authority.slice(closingBracketIndex + 2);
1012
+ }
1013
+ return void 0;
1014
+ }
1015
+ const lastColonIndex = authority.lastIndexOf(":");
1016
+ if (lastColonIndex === -1) return void 0;
1017
+ return authority.slice(lastColonIndex + 1);
1018
+ }
1019
+ function buildForwardedHeaders(req, authority, tls2) {
957
1020
  const remoteAddress = req.socket.remoteAddress || "127.0.0.1";
958
- const hostHeader = getRequestHost(req);
959
1021
  return {
960
1022
  "x-forwarded-for": req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remoteAddress}` : remoteAddress,
961
1023
  "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")
1024
+ "x-forwarded-host": req.headers["x-forwarded-host"] || authority,
1025
+ "x-forwarded-port": req.headers["x-forwarded-port"] || getAuthorityPort(authority) || (tls2 ? "443" : "80")
964
1026
  };
965
1027
  }
966
1028
  function findRoute(routes, host) {
@@ -997,7 +1059,8 @@ function createProxyServers(options) {
997
1059
  res.end();
998
1060
  return;
999
1061
  }
1000
- const host = getRequestHost(req).split(":")[0];
1062
+ const authority = getRequestAuthority(req);
1063
+ const host = normalizeRequestHostname(authority);
1001
1064
  if (!host) {
1002
1065
  res.writeHead(400, { "content-type": "text/plain" });
1003
1066
  res.end("Missing Host header.");
@@ -1016,8 +1079,8 @@ function createProxyServers(options) {
1016
1079
  );
1017
1080
  return;
1018
1081
  }
1019
- const headers = { ...req.headers, ...buildForwardedHeaders(req, httpsRequest) };
1020
- headers.host = getRequestHost(req);
1082
+ const headers = { ...req.headers, ...buildForwardedHeaders(req, authority, httpsRequest) };
1083
+ headers.host = authority;
1021
1084
  for (const key of Object.keys(headers)) {
1022
1085
  if (key.startsWith(":")) {
1023
1086
  delete headers[key];
@@ -1066,14 +1129,15 @@ function createProxyServers(options) {
1066
1129
  };
1067
1130
  const handleUpgrade = (httpsRequest) => (req, socket, head) => {
1068
1131
  socket.on("error", () => socket.destroy());
1069
- const host = getRequestHost(req).split(":")[0];
1132
+ const authority = getRequestAuthority(req);
1133
+ const host = normalizeRequestHostname(authority);
1070
1134
  const route = findRoute(options.getRoutes(), host);
1071
1135
  if (!route) {
1072
1136
  socket.destroy();
1073
1137
  return;
1074
1138
  }
1075
- const headers = { ...req.headers, ...buildForwardedHeaders(req, httpsRequest) };
1076
- headers.host = getRequestHost(req);
1139
+ const headers = { ...req.headers, ...buildForwardedHeaders(req, authority, httpsRequest) };
1140
+ headers.host = authority;
1077
1141
  for (const key of Object.keys(headers)) {
1078
1142
  if (key.startsWith(":")) {
1079
1143
  delete headers[key];
@@ -1171,6 +1235,16 @@ var RouteConflictError = class extends Error {
1171
1235
  function isValidRoute(value) {
1172
1236
  return typeof value === "object" && value !== null && typeof value.hostname === "string" && typeof value.port === "number" && typeof value.pid === "number";
1173
1237
  }
1238
+ function normalizeRoute(route) {
1239
+ try {
1240
+ return {
1241
+ ...route,
1242
+ hostname: normalizeExplicitHostname(route.hostname)
1243
+ };
1244
+ } catch {
1245
+ return null;
1246
+ }
1247
+ }
1174
1248
  var RouteStore = class _RouteStore {
1175
1249
  dir;
1176
1250
  routesPath;
@@ -1255,7 +1329,10 @@ var RouteStore = class _RouteStore {
1255
1329
  this.onWarning?.(`Corrupted routes file: expected an array at ${this.routesPath}.`);
1256
1330
  return [];
1257
1331
  }
1258
- const routes = parsed.filter(isValidRoute);
1332
+ const routes = parsed.filter(isValidRoute).flatMap((route) => {
1333
+ const normalized = normalizeRoute(route);
1334
+ return normalized ? [normalized] : [];
1335
+ });
1259
1336
  const aliveRoutes = routes.filter((route) => this.isProcessAlive(route.pid));
1260
1337
  if (persistCleanup && aliveRoutes.length !== routes.length) {
1261
1338
  this.saveRoutes(aliveRoutes);
@@ -1271,30 +1348,32 @@ var RouteStore = class _RouteStore {
1271
1348
  fixOwnership(this.routesPath);
1272
1349
  }
1273
1350
  addRoute(hostname, port, pid, force = false) {
1351
+ const normalizedHostname = normalizeExplicitHostname(hostname);
1274
1352
  this.ensureDir();
1275
1353
  if (!this.acquireLock()) {
1276
1354
  throw new Error("Failed to acquire route lock.");
1277
1355
  }
1278
1356
  try {
1279
1357
  const routes = this.loadRoutes(true);
1280
- const existing = routes.find((route) => route.hostname === hostname);
1358
+ const existing = routes.find((route) => route.hostname === normalizedHostname);
1281
1359
  if (existing && existing.pid !== pid && this.isProcessAlive(existing.pid) && !force) {
1282
- throw new RouteConflictError(hostname, existing.pid);
1360
+ throw new RouteConflictError(normalizedHostname, existing.pid);
1283
1361
  }
1284
- const nextRoutes = routes.filter((route) => route.hostname !== hostname);
1285
- nextRoutes.push({ hostname, port, pid });
1362
+ const nextRoutes = routes.filter((route) => route.hostname !== normalizedHostname);
1363
+ nextRoutes.push({ hostname: normalizedHostname, port, pid });
1286
1364
  this.saveRoutes(nextRoutes);
1287
1365
  } finally {
1288
1366
  this.releaseLock();
1289
1367
  }
1290
1368
  }
1291
1369
  removeRoute(hostname) {
1370
+ const normalizedHostname = normalizeExplicitHostname(hostname);
1292
1371
  this.ensureDir();
1293
1372
  if (!this.acquireLock()) {
1294
1373
  throw new Error("Failed to acquire route lock.");
1295
1374
  }
1296
1375
  try {
1297
- const nextRoutes = this.loadRoutes(true).filter((route) => route.hostname !== hostname);
1376
+ const nextRoutes = this.loadRoutes(true).filter((route) => route.hostname !== normalizedHostname);
1298
1377
  this.saveRoutes(nextRoutes);
1299
1378
  } finally {
1300
1379
  this.releaseLock();
@@ -1309,6 +1388,51 @@ var DEBOUNCE_MS = 100;
1309
1388
  var POLL_INTERVAL_MS = 3e3;
1310
1389
  var EXIT_TIMEOUT_MS = 2e3;
1311
1390
  var START_TIMEOUT_MS = 3e4;
1391
+ var AUTO_STOP_IDLE_MS = 3e3;
1392
+ var AUTO_STOP_STARTUP_GRACE_MS = 15e3;
1393
+ var CLI_ENTRY_PATH = fileURLToPath(import.meta.url);
1394
+ var PACKAGE_VERSION = getPackageVersion();
1395
+ function getPackageVersion() {
1396
+ try {
1397
+ const packageJsonPath = path7.join(path7.dirname(path7.dirname(CLI_ENTRY_PATH)), "package.json");
1398
+ const packageJson = JSON.parse(fs8.readFileSync(packageJsonPath, "utf-8"));
1399
+ return packageJson.version ?? "0.0.0";
1400
+ } catch {
1401
+ return "0.0.0";
1402
+ }
1403
+ }
1404
+ function maybeWarnAboutNodeFallback() {
1405
+ if (process.env.LOCAL_ROUTER_NODE_FALLBACK !== "1") return;
1406
+ const fallbackNode = process.env.LOCAL_ROUTER_NODE_BIN;
1407
+ if (!fallbackNode) return;
1408
+ console.warn(
1409
+ chalk.yellow(
1410
+ `Warning: the project-local Node.js runtime was not available. Falling back to ${fallbackNode}.`
1411
+ )
1412
+ );
1413
+ console.warn(
1414
+ chalk.gray("Install the project's Node version in asdf, mise, nvm, or your preferred manager.\n")
1415
+ );
1416
+ }
1417
+ function getSelfInvocation(args) {
1418
+ if (CLI_ENTRY_PATH.endsWith(".ts")) {
1419
+ const projectRoot = path7.dirname(path7.dirname(CLI_ENTRY_PATH));
1420
+ const tsxBin = path7.join(
1421
+ projectRoot,
1422
+ "node_modules",
1423
+ ".bin",
1424
+ isWindows2 ? "tsx.cmd" : "tsx"
1425
+ );
1426
+ return {
1427
+ command: tsxBin,
1428
+ args: [CLI_ENTRY_PATH, ...args]
1429
+ };
1430
+ }
1431
+ return {
1432
+ command: process.execPath,
1433
+ args: [CLI_ENTRY_PATH, ...args]
1434
+ };
1435
+ }
1312
1436
  function printHelp() {
1313
1437
  console.log(`
1314
1438
  ${chalk.bold("local-router")} - Stable local domains with HTTP and HTTPS on the real hostnames you want.
@@ -1438,7 +1562,8 @@ function parseProxyArgs(args) {
1438
1562
  httpsPort: getDefaultHttpsPort(),
1439
1563
  httpEnabled: true,
1440
1564
  httpsEnabled: true,
1441
- foreground: false
1565
+ foreground: false,
1566
+ autoStopWhenIdle: false
1442
1567
  };
1443
1568
  for (let index = 0; index < args.length; index += 1) {
1444
1569
  const token = args[index];
@@ -1446,6 +1571,10 @@ function parseProxyArgs(args) {
1446
1571
  options.foreground = true;
1447
1572
  continue;
1448
1573
  }
1574
+ if (token === "--auto-stop-when-idle") {
1575
+ options.autoStopWhenIdle = true;
1576
+ continue;
1577
+ }
1449
1578
  if (token === "--http-port") {
1450
1579
  options.httpPort = parseNumberFlag("--http-port", args[index + 1]);
1451
1580
  index += 1;
@@ -1567,6 +1696,15 @@ function writeEmptyRoutesIfNeeded(store) {
1567
1696
  fixOwnership(store.routesPath);
1568
1697
  }
1569
1698
  }
1699
+ function syncHostsFromStore(store) {
1700
+ const activeRoutes = store.loadRoutes(true);
1701
+ const hostnames = activeRoutes.map((route) => route.hostname);
1702
+ if (hostnames.length === 0) {
1703
+ cleanHostsFile();
1704
+ return;
1705
+ }
1706
+ syncHostsFile(hostnames);
1707
+ }
1570
1708
  function needsPrivileges(runtime) {
1571
1709
  if (isWindows2) return false;
1572
1710
  return runtime.httpEnabled && runtime.httpPort < PRIVILEGED_PORT_THRESHOLD || runtime.httpsEnabled && runtime.httpsPort < PRIVILEGED_PORT_THRESHOLD;
@@ -1574,14 +1712,40 @@ function needsPrivileges(runtime) {
1574
1712
  function startProxyServer(store, runtime, stateDir) {
1575
1713
  store.ensureDir();
1576
1714
  writeEmptyRoutesIfNeeded(store);
1577
- let cachedRoutes = store.loadRoutes();
1715
+ let cachedRoutes = store.loadRoutes(true);
1578
1716
  let debounceTimer = null;
1579
1717
  let watcher = null;
1580
1718
  let poller = null;
1719
+ let idleTimer = null;
1720
+ let cleaningUp = false;
1721
+ let hasSeenRoutes = cachedRoutes.length > 0;
1722
+ const clearIdleTimer = () => {
1723
+ if (!idleTimer) return;
1724
+ clearTimeout(idleTimer);
1725
+ idleTimer = null;
1726
+ };
1727
+ const scheduleIdleShutdown = () => {
1728
+ if (!runtime.autoStopWhenIdle || idleTimer || cachedRoutes.length > 0) return;
1729
+ const delay = hasSeenRoutes ? AUTO_STOP_IDLE_MS : AUTO_STOP_STARTUP_GRACE_MS;
1730
+ idleTimer = setTimeout(() => {
1731
+ idleTimer = null;
1732
+ cachedRoutes = store.loadRoutes(true);
1733
+ if (cachedRoutes.length === 0) {
1734
+ cleanup();
1735
+ }
1736
+ }, delay);
1737
+ idleTimer.unref();
1738
+ };
1581
1739
  const reloadRoutes = () => {
1582
1740
  try {
1583
- cachedRoutes = store.loadRoutes();
1584
- syncHostsFile(cachedRoutes.map((route) => route.hostname));
1741
+ cachedRoutes = store.loadRoutes(true);
1742
+ syncHostsFromStore(store);
1743
+ if (cachedRoutes.length > 0) {
1744
+ hasSeenRoutes = true;
1745
+ clearIdleTimer();
1746
+ } else {
1747
+ scheduleIdleShutdown();
1748
+ }
1585
1749
  } catch {
1586
1750
  }
1587
1751
  };
@@ -1591,9 +1755,10 @@ function startProxyServer(store, runtime, stateDir) {
1591
1755
  debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
1592
1756
  });
1593
1757
  } catch {
1594
- poller = setInterval(reloadRoutes, POLL_INTERVAL_MS);
1595
1758
  }
1596
- syncHostsFile(cachedRoutes.map((route) => route.hostname));
1759
+ poller = setInterval(reloadRoutes, POLL_INTERVAL_MS);
1760
+ poller.unref();
1761
+ syncHostsFromStore(store);
1597
1762
  let tlsOptions;
1598
1763
  if (runtime.httpsEnabled) {
1599
1764
  const certs = ensureCerts(stateDir);
@@ -1626,7 +1791,14 @@ function startProxyServer(store, runtime, stateDir) {
1626
1791
  pendingListeners -= 1;
1627
1792
  if (pendingListeners > 0) return;
1628
1793
  fs8.writeFileSync(store.pidPath, String(process.pid), { mode: FILE_MODE });
1629
- writeProxyState(stateDir, { pid: process.pid, ...runtime });
1794
+ writeProxyState(stateDir, {
1795
+ pid: process.pid,
1796
+ httpPort: runtime.httpPort,
1797
+ httpsPort: runtime.httpsPort,
1798
+ httpEnabled: runtime.httpEnabled,
1799
+ httpsEnabled: runtime.httpsEnabled,
1800
+ autoStopWhenIdle: runtime.autoStopWhenIdle
1801
+ });
1630
1802
  fixOwnership(store.pidPath, store.statePath);
1631
1803
  console.log(chalk.green("local-router proxy is listening."));
1632
1804
  if (runtime.httpEnabled) {
@@ -1635,6 +1807,7 @@ function startProxyServer(store, runtime, stateDir) {
1635
1807
  if (runtime.httpsEnabled) {
1636
1808
  console.log(chalk.gray(`HTTPS -> ${runtime.httpsPort}`));
1637
1809
  }
1810
+ scheduleIdleShutdown();
1638
1811
  };
1639
1812
  const registerServerError = (label, port) => (error) => {
1640
1813
  if (error.code === "EADDRINUSE") {
@@ -1657,8 +1830,11 @@ function startProxyServer(store, runtime, stateDir) {
1657
1830
  servers.httpsServer.listen(runtime.httpsPort, onListening);
1658
1831
  }
1659
1832
  const cleanup = () => {
1833
+ if (cleaningUp) return;
1834
+ cleaningUp = true;
1660
1835
  if (debounceTimer) clearTimeout(debounceTimer);
1661
1836
  if (poller) clearInterval(poller);
1837
+ clearIdleTimer();
1662
1838
  watcher?.close();
1663
1839
  try {
1664
1840
  fs8.unlinkSync(store.pidPath);
@@ -1683,7 +1859,8 @@ async function ensureProxy(runtime) {
1683
1859
  const { dir, state } = await discoverState();
1684
1860
  const effectiveState = state ?? {
1685
1861
  pid: 0,
1686
- ...runtime
1862
+ ...runtime,
1863
+ autoStopWhenIdle: true
1687
1864
  };
1688
1865
  const stateDir = resolveStateDir(runtime);
1689
1866
  const store = new RouteStore(stateDir, {
@@ -1707,12 +1884,13 @@ async function ensureProxy(runtime) {
1707
1884
  if (answer === "s" || answer === "skip") {
1708
1885
  throw new Error("Skipping the proxy is not supported for this command.");
1709
1886
  }
1710
- const childArgs = [process.execPath, process.argv[1], "proxy", "start"];
1887
+ const invocation = getSelfInvocation(["proxy", "start"]);
1888
+ const childArgs = [...invocation.args, "--auto-stop-when-idle"];
1711
1889
  if (!runtime.httpEnabled) childArgs.push("--no-http");
1712
1890
  if (!runtime.httpsEnabled) childArgs.push("--no-https");
1713
1891
  if (runtime.httpPort !== getDefaultHttpPort()) childArgs.push("--http-port", String(runtime.httpPort));
1714
1892
  if (runtime.httpsPort !== getDefaultHttpsPort()) childArgs.push("--https-port", String(runtime.httpsPort));
1715
- const result = spawnSync("sudo", childArgs, {
1893
+ const result = spawnSync("sudo", [invocation.command, ...childArgs], {
1716
1894
  stdio: "inherit",
1717
1895
  timeout: START_TIMEOUT_MS
1718
1896
  });
@@ -1721,12 +1899,13 @@ async function ensureProxy(runtime) {
1721
1899
  process.exit(1);
1722
1900
  }
1723
1901
  } else {
1724
- const childArgs = [process.argv[1], "proxy", "start"];
1902
+ const invocation = getSelfInvocation(["proxy", "start"]);
1903
+ const childArgs = [...invocation.args, "--auto-stop-when-idle"];
1725
1904
  if (!runtime.httpEnabled) childArgs.push("--no-http");
1726
1905
  if (!runtime.httpsEnabled) childArgs.push("--no-https");
1727
1906
  if (runtime.httpPort !== getDefaultHttpPort()) childArgs.push("--http-port", String(runtime.httpPort));
1728
1907
  if (runtime.httpsPort !== getDefaultHttpsPort()) childArgs.push("--https-port", String(runtime.httpsPort));
1729
- const result = spawnSync(process.execPath, childArgs, {
1908
+ const result = spawnSync(invocation.command, childArgs, {
1730
1909
  stdio: "inherit",
1731
1910
  timeout: START_TIMEOUT_MS
1732
1911
  });
@@ -1772,6 +1951,18 @@ async function handleRun(args) {
1772
1951
  isSystemDir: dir === SYSTEM_STATE_DIR,
1773
1952
  onWarning: (message) => console.warn(chalk.yellow(message))
1774
1953
  });
1954
+ const cleanupRoutes = () => {
1955
+ for (const hostname of resolved.hostnames) {
1956
+ try {
1957
+ store.removeRoute(hostname);
1958
+ } catch {
1959
+ }
1960
+ }
1961
+ try {
1962
+ syncHostsFromStore(store);
1963
+ } catch {
1964
+ }
1965
+ };
1775
1966
  const port = parsed.appPort ?? await findFreePort();
1776
1967
  console.log(chalk.green(`App port: ${port}`));
1777
1968
  try {
@@ -1798,14 +1989,7 @@ async function handleRun(args) {
1798
1989
  LOCAL_ROUTER_URLS_HTTP: allHttpUrls,
1799
1990
  __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: ".localhost"
1800
1991
  },
1801
- onCleanup: () => {
1802
- for (const hostname of resolved.hostnames) {
1803
- try {
1804
- store.removeRoute(hostname);
1805
- } catch {
1806
- }
1807
- }
1808
- }
1992
+ onCleanup: cleanupRoutes
1809
1993
  });
1810
1994
  }
1811
1995
  async function handleHosts(args) {
@@ -1887,12 +2071,14 @@ ${chalk.bold("Usage:")}
1887
2071
  } catch {
1888
2072
  }
1889
2073
  fixOwnership(logPath, stateDir);
1890
- const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
2074
+ const invocation = getSelfInvocation(["proxy", "start", "--foreground"]);
2075
+ const daemonArgs = [...invocation.args];
2076
+ if (options.autoStopWhenIdle) daemonArgs.push("--auto-stop-when-idle");
1891
2077
  if (!options.httpEnabled) daemonArgs.push("--no-http");
1892
2078
  if (!options.httpsEnabled) daemonArgs.push("--no-https");
1893
2079
  if (options.httpPort !== getDefaultHttpPort()) daemonArgs.push("--http-port", String(options.httpPort));
1894
2080
  if (options.httpsPort !== getDefaultHttpsPort()) daemonArgs.push("--https-port", String(options.httpsPort));
1895
- const child = spawn2(process.execPath, daemonArgs, {
2081
+ const child = spawn2(invocation.command, daemonArgs, {
1896
2082
  detached: true,
1897
2083
  stdio: ["ignore", logFd, logFd],
1898
2084
  env: process.env,
@@ -1919,6 +2105,7 @@ async function handleTrust() {
1919
2105
  console.log(chalk.green("Local CA added to the trust store."));
1920
2106
  }
1921
2107
  async function main() {
2108
+ maybeWarnAboutNodeFallback();
1922
2109
  const args = process.argv.slice(2);
1923
2110
  const command = args[0];
1924
2111
  if (!command || command === "--help" || command === "-h") {
@@ -1926,7 +2113,7 @@ async function main() {
1926
2113
  return;
1927
2114
  }
1928
2115
  if (command === "--version" || command === "-v") {
1929
- console.log("0.1.0");
2116
+ console.log(PACKAGE_VERSION);
1930
2117
  return;
1931
2118
  }
1932
2119
  if (command === "run") {
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@elisoncampos/local-router",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Named local domains with automatic HTTP/HTTPS routing for development.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
- "local-router": "dist/cli.js"
8
+ "local-router": "bin/local-router"
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
@@ -19,7 +19,9 @@
19
19
  "access": "public"
20
20
  },
21
21
  "files": [
22
- "dist"
22
+ "bin",
23
+ "dist",
24
+ "scripts"
23
25
  ],
24
26
  "engines": {
25
27
  "node": ">=20.0.0"
@@ -28,6 +30,7 @@
28
30
  "build": "tsup src/cli.ts --format esm --target node20 --out-dir dist --clean",
29
31
  "changeset": "changeset",
30
32
  "dev": "tsx src/cli.ts",
33
+ "postinstall": "node scripts/write-node-path.js",
31
34
  "prepack": "npm run build",
32
35
  "prepublishOnly": "npm run typecheck && npm test",
33
36
  "release": "npm run typecheck && npm test && npm run build && changeset publish",
@@ -40,6 +43,7 @@
40
43
  "json5": "^2.2.3"
41
44
  },
42
45
  "devDependencies": {
46
+ "@changesets/changelog-github": "^0.5.1",
43
47
  "@changesets/cli": "^2.29.5",
44
48
  "@types/node": "^24.0.0",
45
49
  "tsup": "^8.3.5",
@@ -0,0 +1,10 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
6
+ const projectRoot = path.dirname(scriptDir);
7
+ const outputPath = path.join(projectRoot, "bin", ".installed-node-path");
8
+
9
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
10
+ fs.writeFileSync(outputPath, `${process.execPath}\n`, "utf8");