portless 0.5.2 → 0.7.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.
@@ -1,6 +1,7 @@
1
1
  // src/utils.ts
2
2
  import * as fs from "fs";
3
3
  function fixOwnership(...paths) {
4
+ if (process.platform === "win32") return;
4
5
  const uid = process.env.SUDO_UID;
5
6
  const gid = process.env.SUDO_GID;
6
7
  if (!uid || process.getuid?.() !== 0) return;
@@ -22,15 +23,19 @@ function formatUrl(hostname, proxyPort, tls = false) {
22
23
  const defaultPort = tls ? 443 : 80;
23
24
  return proxyPort === defaultPort ? `${proto}://${hostname}` : `${proto}://${hostname}:${proxyPort}`;
24
25
  }
25
- function parseHostname(input) {
26
+ function parseHostname(input, tld = "localhost") {
27
+ const suffix = `.${tld}`;
26
28
  let hostname = input.trim().replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
27
- if (!hostname || hostname === ".localhost") {
29
+ if (tld !== "localhost" && hostname.endsWith(".localhost")) {
30
+ hostname = hostname.slice(0, -".localhost".length);
31
+ }
32
+ if (!hostname || hostname === suffix) {
28
33
  throw new Error("Hostname cannot be empty");
29
34
  }
30
- if (!hostname.endsWith(".localhost")) {
31
- hostname = `${hostname}.localhost`;
35
+ if (!hostname.endsWith(suffix)) {
36
+ hostname = `${hostname}${suffix}`;
32
37
  }
33
- const name = hostname.replace(/\.localhost$/, "");
38
+ const name = hostname.slice(0, -suffix.length);
34
39
  if (name.includes("..")) {
35
40
  throw new Error(`Invalid hostname "${name}": consecutive dots are not allowed`);
36
41
  }
@@ -39,6 +44,14 @@ function parseHostname(input) {
39
44
  `Invalid hostname "${name}": must contain only lowercase letters, digits, hyphens, and dots`
40
45
  );
41
46
  }
47
+ const labels = name.split(".");
48
+ for (const label of labels) {
49
+ if (label.length > 63) {
50
+ throw new Error(
51
+ `Invalid hostname "${name}": label "${label}" exceeds 63-character DNS limit`
52
+ );
53
+ }
54
+ }
42
55
  return hostname;
43
56
  }
44
57
 
@@ -291,7 +304,14 @@ function findRoute(routes, host) {
291
304
  return routes.find((r) => r.hostname === host) || routes.find((r) => host.endsWith("." + r.hostname));
292
305
  }
293
306
  function createProxyServer(options) {
294
- const { getRoutes, proxyPort, onError = (msg) => console.error(msg), tls } = options;
307
+ const {
308
+ getRoutes,
309
+ proxyPort,
310
+ tld = "localhost",
311
+ onError = (msg) => console.error(msg),
312
+ tls
313
+ } = options;
314
+ const tldSuffix = `.${tld}`;
295
315
  const isTls = !!tls;
296
316
  const handleRequest = (req, res) => {
297
317
  res.setHeader(PORTLESS_HEADER, "1");
@@ -314,7 +334,7 @@ function createProxyServer(options) {
314
334
  "Loop Detected",
315
335
  `<div class="content"><p class="desc">This request has passed through portless ${hops} times. This usually means a dev server (Vite, webpack, etc.) is proxying requests back through portless without rewriting the Host header.</p><div class="section"><p class="label">Fix: add changeOrigin to your proxy config</p><pre class="terminal">proxy: {
316
336
  "/api": {
317
- target: "http://&lt;backend&gt;.localhost:&lt;port&gt;",
337
+ target: "http://&lt;backend&gt;${escapeHtml(tldSuffix)}:&lt;port&gt;",
318
338
  changeOrigin: true,
319
339
  },
320
340
  }</pre></div></div>`
@@ -325,13 +345,15 @@ function createProxyServer(options) {
325
345
  const route = findRoute(routes, host);
326
346
  if (!route) {
327
347
  const safeHost = escapeHtml(host);
328
- const routesList = routes.length > 0 ? `<div class="section"><p class="label">Active apps</p><ul class="card">${routes.map((r) => `<li><a href="${escapeHtml(formatUrl(r.hostname, proxyPort, isTls))}" class="card-link"><span class="name">${escapeHtml(r.hostname)}</span><span class="meta"><code class="port">localhost:${escapeHtml(String(r.port))}</code><span class="arrow">${ARROW_SVG}</span></span></a></li>`).join("")}</ul></div>` : '<p class="empty">No apps running.</p>';
348
+ const strippedHost = host.endsWith(tldSuffix) ? host.slice(0, -tldSuffix.length) : host;
349
+ const safeSuggestion = escapeHtml(strippedHost);
350
+ const routesList = routes.length > 0 ? `<div class="section"><p class="label">Active apps</p><ul class="card">${routes.map((r) => `<li><a href="${escapeHtml(formatUrl(r.hostname, proxyPort, isTls))}" class="card-link"><span class="name">${escapeHtml(r.hostname)}</span><span class="meta"><code class="port">127.0.0.1:${escapeHtml(String(r.port))}</code><span class="arrow">${ARROW_SVG}</span></span></a></li>`).join("")}</ul></div>` : '<p class="empty">No apps running.</p>';
329
351
  res.writeHead(404, { "Content-Type": "text/html" });
330
352
  res.end(
331
353
  renderPage(
332
354
  404,
333
355
  "Not Found",
334
- `<div class="content"><p class="desc">No app registered for <strong>${safeHost}</strong></p>${routesList}<div class="section"><div class="terminal"><span class="prompt">$ </span>portless ${safeHost.replace(".localhost", "")} your-command</div></div></div>`
356
+ `<div class="content"><p class="desc">No app registered for <strong>${safeHost}</strong></p>${routesList}<div class="section"><div class="terminal"><span class="prompt">$ </span>portless ${safeSuggestion} your-command</div></div></div>`
335
357
  )
336
358
  );
337
359
  return;
@@ -523,7 +545,9 @@ function createProxyServer(options) {
523
545
  // src/hosts.ts
524
546
  import * as fs2 from "fs";
525
547
  import * as dns from "dns";
526
- var HOSTS_PATH = "/etc/hosts";
548
+ import * as path from "path";
549
+ var isWindows = process.platform === "win32";
550
+ var HOSTS_PATH = isWindows ? path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "drivers", "etc", "hosts") : "/etc/hosts";
527
551
  var MARKER_START = "# portless-start";
528
552
  var MARKER_END = "# portless-end";
529
553
  function readHostsFile() {
@@ -587,7 +611,7 @@ function getManagedHostnames() {
587
611
  return parts.length >= 2 ? parts[1] : "";
588
612
  }).filter(Boolean);
589
613
  }
590
- function checkLocalhostResolution(hostname) {
614
+ function checkHostResolution(hostname) {
591
615
  return new Promise((resolve) => {
592
616
  dns.lookup(hostname, { family: 4 }, (err, address) => {
593
617
  if (err) {
@@ -605,18 +629,19 @@ import * as http3 from "http";
605
629
  import * as https from "https";
606
630
  import * as net2 from "net";
607
631
  import * as os from "os";
608
- import * as path from "path";
632
+ import * as path2 from "path";
609
633
  import * as readline from "readline";
610
634
  import { execSync, spawn } from "child_process";
635
+ var isWindows2 = process.platform === "win32";
611
636
  var DEFAULT_PROXY_PORT = 1355;
612
637
  var PRIVILEGED_PORT_THRESHOLD = 1024;
613
- var SYSTEM_STATE_DIR = "/tmp/portless";
614
- var USER_STATE_DIR = path.join(os.homedir(), ".portless");
638
+ var SYSTEM_STATE_DIR = path2.join(os.tmpdir(), "portless");
639
+ var USER_STATE_DIR = path2.join(os.homedir(), ".portless");
615
640
  var MIN_APP_PORT = 4e3;
616
641
  var MAX_APP_PORT = 4999;
617
642
  var RANDOM_PORT_ATTEMPTS = 50;
618
643
  var SOCKET_TIMEOUT_MS = 500;
619
- var LSOF_TIMEOUT_MS = 5e3;
644
+ var PID_LOOKUP_TIMEOUT_MS = 5e3;
620
645
  var WAIT_FOR_PROXY_MAX_ATTEMPTS = 20;
621
646
  var WAIT_FOR_PROXY_INTERVAL_MS = 250;
622
647
  var SIGNAL_CODES = {
@@ -637,11 +662,12 @@ function getDefaultPort() {
637
662
  }
638
663
  function resolveStateDir(port) {
639
664
  if (process.env.PORTLESS_STATE_DIR) return process.env.PORTLESS_STATE_DIR;
665
+ if (isWindows2) return USER_STATE_DIR;
640
666
  return port < PRIVILEGED_PORT_THRESHOLD ? SYSTEM_STATE_DIR : USER_STATE_DIR;
641
667
  }
642
668
  function readPortFromDir(dir) {
643
669
  try {
644
- const raw = fs3.readFileSync(path.join(dir, "proxy.port"), "utf-8").trim();
670
+ const raw = fs3.readFileSync(path2.join(dir, "proxy.port"), "utf-8").trim();
645
671
  const port = parseInt(raw, 10);
646
672
  return isNaN(port) ? null : port;
647
673
  } catch {
@@ -651,13 +677,13 @@ function readPortFromDir(dir) {
651
677
  var TLS_MARKER_FILE = "proxy.tls";
652
678
  function readTlsMarker(dir) {
653
679
  try {
654
- return fs3.existsSync(path.join(dir, TLS_MARKER_FILE));
680
+ return fs3.existsSync(path2.join(dir, TLS_MARKER_FILE));
655
681
  } catch {
656
682
  return false;
657
683
  }
658
684
  }
659
685
  function writeTlsMarker(dir, enabled) {
660
- const markerPath = path.join(dir, TLS_MARKER_FILE);
686
+ const markerPath = path2.join(dir, TLS_MARKER_FILE);
661
687
  if (enabled) {
662
688
  fs3.writeFileSync(markerPath, "1", { mode: 420 });
663
689
  } else {
@@ -667,6 +693,54 @@ function writeTlsMarker(dir, enabled) {
667
693
  }
668
694
  }
669
695
  }
696
+ var DEFAULT_TLD = "localhost";
697
+ var RISKY_TLDS = /* @__PURE__ */ new Map([
698
+ ["local", "conflicts with mDNS/Bonjour on macOS"],
699
+ ["dev", "Google-owned; browsers force HTTPS via preloaded HSTS"],
700
+ ["com", "public TLD -- DNS requests will leak to the internet"],
701
+ ["org", "public TLD -- DNS requests will leak to the internet"],
702
+ ["net", "public TLD -- DNS requests will leak to the internet"],
703
+ ["io", "public TLD -- DNS requests will leak to the internet"],
704
+ ["app", "public TLD -- DNS requests will leak to the internet"],
705
+ ["edu", "public TLD -- DNS requests will leak to the internet"],
706
+ ["gov", "public TLD -- DNS requests will leak to the internet"],
707
+ ["mil", "public TLD -- DNS requests will leak to the internet"],
708
+ ["int", "public TLD -- DNS requests will leak to the internet"]
709
+ ]);
710
+ function validateTld(tld) {
711
+ if (!tld) return "TLD cannot be empty";
712
+ if (!/^[a-z0-9]+$/.test(tld)) {
713
+ return `Invalid TLD "${tld}": must contain only lowercase letters and digits`;
714
+ }
715
+ return null;
716
+ }
717
+ var TLD_FILE = "proxy.tld";
718
+ function readTldFromDir(dir) {
719
+ try {
720
+ const raw = fs3.readFileSync(path2.join(dir, TLD_FILE), "utf-8").trim();
721
+ return raw || DEFAULT_TLD;
722
+ } catch {
723
+ return DEFAULT_TLD;
724
+ }
725
+ }
726
+ function writeTldFile(dir, tld) {
727
+ const filePath = path2.join(dir, TLD_FILE);
728
+ if (tld === DEFAULT_TLD) {
729
+ try {
730
+ fs3.unlinkSync(filePath);
731
+ } catch {
732
+ }
733
+ } else {
734
+ fs3.writeFileSync(filePath, tld, { mode: 420 });
735
+ }
736
+ }
737
+ function getDefaultTld() {
738
+ const val = process.env.PORTLESS_TLD?.trim().toLowerCase();
739
+ if (!val) return DEFAULT_TLD;
740
+ const err = validateTld(val);
741
+ if (err) throw new Error(`PORTLESS_TLD: ${err}`);
742
+ return val;
743
+ }
670
744
  function isHttpsEnvEnabled() {
671
745
  const val = process.env.PORTLESS_HTTPS;
672
746
  return val === "1" || val === "true";
@@ -676,24 +750,36 @@ async function discoverState() {
676
750
  const dir = process.env.PORTLESS_STATE_DIR;
677
751
  const port = readPortFromDir(dir) ?? getDefaultPort();
678
752
  const tls = readTlsMarker(dir);
679
- return { dir, port, tls };
753
+ const tld = readTldFromDir(dir);
754
+ return { dir, port, tls, tld };
680
755
  }
681
756
  const userPort = readPortFromDir(USER_STATE_DIR);
682
757
  if (userPort !== null) {
683
- const tls = readTlsMarker(USER_STATE_DIR);
684
- if (await isProxyRunning(userPort, tls)) {
685
- return { dir: USER_STATE_DIR, port: userPort, tls };
758
+ if (await isProxyRunning(userPort)) {
759
+ const tls = readTlsMarker(USER_STATE_DIR);
760
+ const tld = readTldFromDir(USER_STATE_DIR);
761
+ return { dir: USER_STATE_DIR, port: userPort, tls, tld };
686
762
  }
687
763
  }
688
764
  const systemPort = readPortFromDir(SYSTEM_STATE_DIR);
689
765
  if (systemPort !== null) {
690
- const tls = readTlsMarker(SYSTEM_STATE_DIR);
691
- if (await isProxyRunning(systemPort, tls)) {
692
- return { dir: SYSTEM_STATE_DIR, port: systemPort, tls };
766
+ if (await isProxyRunning(systemPort)) {
767
+ const tls = readTlsMarker(SYSTEM_STATE_DIR);
768
+ const tld = readTldFromDir(SYSTEM_STATE_DIR);
769
+ return { dir: SYSTEM_STATE_DIR, port: systemPort, tls, tld };
693
770
  }
694
771
  }
695
772
  const defaultPort = getDefaultPort();
696
- return { dir: resolveStateDir(defaultPort), port: defaultPort, tls: false };
773
+ const probePorts = /* @__PURE__ */ new Set([defaultPort, 443, 80]);
774
+ for (const port of probePorts) {
775
+ if (await isProxyRunning(port)) {
776
+ const dir = resolveStateDir(port);
777
+ const tls = readTlsMarker(dir);
778
+ const tld = readTldFromDir(dir);
779
+ return { dir, port, tls, tld };
780
+ }
781
+ }
782
+ return { dir: resolveStateDir(defaultPort), port: defaultPort, tls: false, tld: getDefaultTld() };
697
783
  }
698
784
  async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
699
785
  if (minPort > maxPort) {
@@ -746,11 +832,34 @@ function isProxyRunning(port, tls = false) {
746
832
  req.end();
747
833
  });
748
834
  }
835
+ function parsePidFromNetstat(output, port) {
836
+ for (const line of output.split(/\r?\n/)) {
837
+ if (!line.includes("LISTENING")) continue;
838
+ const parts = line.trim().split(/\s+/);
839
+ if (parts.length < 5) continue;
840
+ const localAddr = parts[1];
841
+ const lastColon = localAddr.lastIndexOf(":");
842
+ if (lastColon === -1) continue;
843
+ const addrPort = parseInt(localAddr.substring(lastColon + 1), 10);
844
+ if (addrPort === port) {
845
+ const pid = parseInt(parts[parts.length - 1], 10);
846
+ if (!isNaN(pid) && pid > 0) return pid;
847
+ }
848
+ }
849
+ return null;
850
+ }
749
851
  function findPidOnPort(port) {
750
852
  try {
853
+ if (isWindows2) {
854
+ const output2 = execSync("netstat -ano -p tcp", {
855
+ encoding: "utf-8",
856
+ timeout: PID_LOOKUP_TIMEOUT_MS
857
+ });
858
+ return parsePidFromNetstat(output2, port);
859
+ }
751
860
  const output = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
752
861
  encoding: "utf-8",
753
- timeout: LSOF_TIMEOUT_MS
862
+ timeout: PID_LOOKUP_TIMEOUT_MS
754
863
  });
755
864
  const pid = parseInt(output.trim().split("\n")[0], 10);
756
865
  return isNaN(pid) ? null : pid;
@@ -774,11 +883,11 @@ function collectBinPaths(cwd) {
774
883
  const dirs = [];
775
884
  let dir = cwd;
776
885
  for (; ; ) {
777
- const bin = path.join(dir, "node_modules", ".bin");
886
+ const bin = path2.join(dir, "node_modules", ".bin");
778
887
  if (fs3.existsSync(bin)) {
779
888
  dirs.push(bin);
780
889
  }
781
- const parent = path.dirname(dir);
890
+ const parent = path2.dirname(dir);
782
891
  if (parent === dir) break;
783
892
  dir = parent;
784
893
  }
@@ -787,12 +896,15 @@ function collectBinPaths(cwd) {
787
896
  function augmentedPath(env) {
788
897
  const base = (env ?? process.env).PATH ?? "";
789
898
  const bins = collectBinPaths(process.cwd());
790
- return bins.length > 0 ? bins.join(path.delimiter) + path.delimiter + base : base;
899
+ return bins.length > 0 ? bins.join(path2.delimiter) + path2.delimiter + base : base;
791
900
  }
792
901
  function spawnCommand(commandArgs, options) {
793
902
  const env = { ...options?.env ?? process.env, PATH: augmentedPath(options?.env) };
794
- const shellCmd = commandArgs.map(shellEscape).join(" ");
795
- const child = spawn("/bin/sh", ["-c", shellCmd], {
903
+ const child = isWindows2 ? spawn(commandArgs[0], commandArgs.slice(1), {
904
+ stdio: "inherit",
905
+ env,
906
+ shell: true
907
+ }) : spawn("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
796
908
  stdio: "inherit",
797
909
  env
798
910
  });
@@ -844,7 +956,7 @@ var FRAMEWORKS_NEEDING_PORT = {
844
956
  function injectFrameworkFlags(commandArgs, port) {
845
957
  const cmd = commandArgs[0];
846
958
  if (!cmd) return;
847
- const basename2 = path.basename(cmd);
959
+ const basename2 = path2.basename(cmd);
848
960
  const framework = FRAMEWORKS_NEEDING_PORT[basename2];
849
961
  if (!framework) return;
850
962
  if (!commandArgs.includes("--port")) {
@@ -874,7 +986,7 @@ function prompt(question) {
874
986
 
875
987
  // src/routes.ts
876
988
  import * as fs4 from "fs";
877
- import * as path2 from "path";
989
+ import * as path3 from "path";
878
990
  var STALE_LOCK_THRESHOLD_MS = 1e4;
879
991
  var LOCK_MAX_RETRIES = 20;
880
992
  var LOCK_RETRY_DELAY_MS = 50;
@@ -907,10 +1019,10 @@ var RouteStore = class _RouteStore {
907
1019
  onWarning;
908
1020
  constructor(dir, options) {
909
1021
  this.dir = dir;
910
- this.routesPath = path2.join(dir, "routes.json");
911
- this.lockPath = path2.join(dir, "routes.lock");
912
- this.pidPath = path2.join(dir, "proxy.pid");
913
- this.portFilePath = path2.join(dir, "proxy.port");
1022
+ this.routesPath = path3.join(dir, "routes.json");
1023
+ this.lockPath = path3.join(dir, "routes.lock");
1024
+ this.pidPath = path3.join(dir, "proxy.pid");
1025
+ this.portFilePath = path3.join(dir, "proxy.port");
914
1026
  this.onWarning = options?.onWarning;
915
1027
  }
916
1028
  isSystemDir() {
@@ -1067,12 +1179,19 @@ export {
1067
1179
  syncHostsFile,
1068
1180
  cleanHostsFile,
1069
1181
  getManagedHostnames,
1070
- checkLocalhostResolution,
1182
+ checkHostResolution,
1183
+ isWindows2 as isWindows,
1071
1184
  PRIVILEGED_PORT_THRESHOLD,
1072
1185
  getDefaultPort,
1073
1186
  resolveStateDir,
1074
1187
  readTlsMarker,
1075
1188
  writeTlsMarker,
1189
+ DEFAULT_TLD,
1190
+ RISKY_TLDS,
1191
+ validateTld,
1192
+ readTldFromDir,
1193
+ writeTldFile,
1194
+ getDefaultTld,
1076
1195
  isHttpsEnvEnabled,
1077
1196
  discoverState,
1078
1197
  findFreePort,