@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.
- package/bin/.installed-node-path +1 -0
- package/bin/local-router +104 -0
- package/dist/cli.js +235 -48
- package/package.json +7 -3
- package/scripts/write-node-path.js +10 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/opt/hostedtoolcache/node/24.14.0/x64/bin/node
|
package/bin/local-router
ADDED
|
@@ -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
|
-
|
|
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(
|
|
322
|
+
const cached = cache.get(normalizedServername);
|
|
306
323
|
if (cached) {
|
|
307
324
|
callback(null, cached);
|
|
308
325
|
return;
|
|
309
326
|
}
|
|
310
|
-
const safeName = sanitizeHostForFilename(
|
|
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,
|
|
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(
|
|
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 (
|
|
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 =
|
|
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
|
|
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
|
|
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"] ||
|
|
963
|
-
"x-forwarded-port": req.headers["x-forwarded-port"] ||
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 ===
|
|
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(
|
|
1360
|
+
throw new RouteConflictError(normalizedHostname, existing.pid);
|
|
1283
1361
|
}
|
|
1284
|
-
const nextRoutes = routes.filter((route) => route.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 !==
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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": "
|
|
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
|
-
"
|
|
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");
|