portless 0.14.0 → 0.15.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/dist/cli.js CHANGED
@@ -4,16 +4,20 @@ import {
4
4
  PORTLESS_HEADER,
5
5
  RouteConflictError,
6
6
  RouteStore,
7
+ checkHostResolution,
7
8
  cleanHostsFile,
8
9
  createHttpRedirectServer,
9
10
  createProxyServer,
10
11
  fixOwnership,
11
12
  formatUrl,
13
+ getManagedHostnames,
12
14
  isErrnoException,
15
+ isProcessAlive,
13
16
  parseHostname,
17
+ parseHostnames,
14
18
  shouldAutoSyncHosts,
15
19
  syncHostsFile
16
- } from "./chunk-OZM4AEYL.js";
20
+ } from "./chunk-SD2PIWJU.js";
17
21
 
18
22
  // src/colors.ts
19
23
  function supportsColor() {
@@ -498,15 +502,16 @@ async function generateHostCertAsync(stateDir, hostname) {
498
502
  fixOwnership(keyPath, certPath);
499
503
  return { certPath, keyPath };
500
504
  }
501
- function createSNICallback(stateDir, defaultCert, defaultKey, tld = "localhost", caCert) {
505
+ function createSNICallback(stateDir, defaultCert, defaultKey, tlds = "localhost", caCert) {
502
506
  const cache = /* @__PURE__ */ new Map();
503
507
  const pending = /* @__PURE__ */ new Map();
508
+ const configuredTlds = Array.isArray(tlds) ? tlds : [tlds];
504
509
  const defaultCtx = tls.createSecureContext({
505
510
  cert: caCert ? Buffer.concat([defaultCert, caCert]) : defaultCert,
506
511
  key: defaultKey
507
512
  });
508
513
  return (servername, cb) => {
509
- if (servername === tld) {
514
+ if (servername === "localhost" && configuredTlds.includes("localhost")) {
510
515
  cb(null, defaultCtx);
511
516
  return;
512
517
  }
@@ -1503,6 +1508,7 @@ function readPortFromDir(dir) {
1503
1508
  }
1504
1509
  }
1505
1510
  var TLS_MARKER_FILE = "proxy.tls";
1511
+ var CUSTOM_CERT_MARKER_FILE = "proxy.custom-cert";
1506
1512
  function readTlsMarker(dir) {
1507
1513
  try {
1508
1514
  return fs3.existsSync(path3.join(dir, TLS_MARKER_FILE));
@@ -1521,6 +1527,24 @@ function writeTlsMarker(dir, enabled2) {
1521
1527
  }
1522
1528
  }
1523
1529
  }
1530
+ function readCustomCertMarker(dir) {
1531
+ try {
1532
+ return fs3.existsSync(path3.join(dir, CUSTOM_CERT_MARKER_FILE));
1533
+ } catch {
1534
+ return false;
1535
+ }
1536
+ }
1537
+ function writeCustomCertMarker(dir, enabled2) {
1538
+ const markerPath = path3.join(dir, CUSTOM_CERT_MARKER_FILE);
1539
+ if (enabled2) {
1540
+ fs3.writeFileSync(markerPath, "1", { mode: 420 });
1541
+ } else {
1542
+ try {
1543
+ fs3.unlinkSync(markerPath);
1544
+ } catch {
1545
+ }
1546
+ }
1547
+ }
1524
1548
  var LAN_MARKER_FILE = "proxy.lan";
1525
1549
  function readLanMarker(dir) {
1526
1550
  try {
@@ -1562,8 +1586,25 @@ function validateTld(tld) {
1562
1586
  }
1563
1587
  return null;
1564
1588
  }
1589
+ function parseTldList(value, source = "TLD") {
1590
+ const trimmed = value.trim();
1591
+ if (!trimmed) return [];
1592
+ const tlds = [];
1593
+ const seen = /* @__PURE__ */ new Set();
1594
+ for (const rawPart of trimmed.split(",")) {
1595
+ const tld = rawPart.trim().toLowerCase();
1596
+ const err = validateTld(tld);
1597
+ if (err) throw new Error(source === "TLD" ? err : `${source}: ${err}`);
1598
+ if (!seen.has(tld)) {
1599
+ seen.add(tld);
1600
+ tlds.push(tld);
1601
+ }
1602
+ }
1603
+ return tlds;
1604
+ }
1565
1605
  var TLD_FILE = "proxy.tld";
1566
- function readTldFromDir(dir) {
1606
+ var TLDS_FILE = "proxy.tlds";
1607
+ function readLegacyTldFromDir(dir) {
1567
1608
  try {
1568
1609
  const raw = fs3.readFileSync(path3.join(dir, TLD_FILE), "utf-8").trim();
1569
1610
  return raw || DEFAULT_TLD;
@@ -1571,23 +1612,43 @@ function readTldFromDir(dir) {
1571
1612
  return DEFAULT_TLD;
1572
1613
  }
1573
1614
  }
1574
- function writeTldFile(dir, tld) {
1575
- const filePath = path3.join(dir, TLD_FILE);
1576
- if (tld === DEFAULT_TLD) {
1615
+ function readTldsFromDir(dir) {
1616
+ try {
1617
+ const raw = fs3.readFileSync(path3.join(dir, TLDS_FILE), "utf-8").trim();
1618
+ const parsed = raw.startsWith("[") ? JSON.parse(raw) : raw.split(/\r?\n/).flatMap((line) => line.split(",")).map((line) => line.trim()).filter(Boolean);
1619
+ if (!Array.isArray(parsed)) return [readLegacyTldFromDir(dir)];
1620
+ const tlds = parsed.flatMap((value) => typeof value === "string" ? parseTldList(value) : []);
1621
+ return tlds.length > 0 ? [...new Set(tlds)] : [DEFAULT_TLD];
1622
+ } catch {
1623
+ return [readLegacyTldFromDir(dir)];
1624
+ }
1625
+ }
1626
+ function writeTldsFile(dir, tlds) {
1627
+ const uniqueTlds = [...new Set(tlds)];
1628
+ const tldsPath = path3.join(dir, TLDS_FILE);
1629
+ const tldPath = path3.join(dir, TLD_FILE);
1630
+ if (uniqueTlds.length === 1 && uniqueTlds[0] === DEFAULT_TLD) {
1631
+ try {
1632
+ fs3.unlinkSync(tldsPath);
1633
+ } catch {
1634
+ }
1577
1635
  try {
1578
- fs3.unlinkSync(filePath);
1636
+ fs3.unlinkSync(tldPath);
1579
1637
  } catch {
1580
1638
  }
1581
1639
  } else {
1582
- fs3.writeFileSync(filePath, tld, { mode: 420 });
1640
+ fs3.writeFileSync(tldsPath, uniqueTlds.join("\n") + "\n", { mode: 420 });
1641
+ fs3.writeFileSync(tldPath, uniqueTlds[0] ?? DEFAULT_TLD, { mode: 420 });
1583
1642
  }
1584
1643
  }
1585
- function getDefaultTld() {
1644
+ function writeTldFile(dir, tld) {
1645
+ writeTldsFile(dir, [tld]);
1646
+ }
1647
+ function getDefaultTlds() {
1586
1648
  const val = process.env.PORTLESS_TLD?.trim().toLowerCase();
1587
- if (!val) return DEFAULT_TLD;
1588
- const err = validateTld(val);
1589
- if (err) throw new Error(`PORTLESS_TLD: ${err}`);
1590
- return val;
1649
+ if (!val) return [DEFAULT_TLD];
1650
+ const tlds = parseTldList(val, "PORTLESS_TLD");
1651
+ return tlds.length > 0 ? tlds : [DEFAULT_TLD];
1591
1652
  }
1592
1653
  function isHttpsEnvDisabled() {
1593
1654
  const val = process.env.PORTLESS_HTTPS;
@@ -1606,14 +1667,17 @@ function readPersistedProxyState() {
1606
1667
  const port = readPortFromDir(dir);
1607
1668
  if (port !== null) {
1608
1669
  const tls2 = readTlsMarker(dir);
1609
- const tld = readTldFromDir(dir);
1670
+ const tlds = readTldsFromDir(dir);
1671
+ const tld = tlds[0] ?? DEFAULT_TLD;
1610
1672
  const lanIp = readLanMarker(dir);
1611
- return { port, tls: tls2, tld, lanMode: lanIp !== null || tld === "local" };
1673
+ return { port, tls: tls2, tld, tlds, lanMode: lanIp !== null || tlds.includes("local") };
1612
1674
  }
1613
1675
  return null;
1614
1676
  }
1615
1677
  function buildProxyStartConfig(options) {
1616
- const effectiveTld = options.lanMode ? "local" : options.tld;
1678
+ const requestedTlds = options.tlds && options.tlds.length > 0 ? [...options.tlds] : [options.tld];
1679
+ const effectiveTlds = options.lanMode ? ["local"] : [...new Set(requestedTlds)];
1680
+ const effectiveTld = effectiveTlds[0] ?? DEFAULT_TLD;
1617
1681
  const args = [];
1618
1682
  if (options.foreground) {
1619
1683
  args.push("--foreground");
@@ -1639,8 +1703,10 @@ function buildProxyStartConfig(options) {
1639
1703
  args.push(INTERNAL_LAN_IP_FLAG, options.lanIp);
1640
1704
  }
1641
1705
  }
1642
- } else if (effectiveTld !== DEFAULT_TLD) {
1643
- args.push("--tld", effectiveTld);
1706
+ } else if (effectiveTlds.length > 1 || effectiveTld !== DEFAULT_TLD) {
1707
+ for (const tld of effectiveTlds) {
1708
+ args.push("--tld", tld);
1709
+ }
1644
1710
  }
1645
1711
  if (options.useWildcard) {
1646
1712
  args.push("--wildcard");
@@ -1648,7 +1714,7 @@ function buildProxyStartConfig(options) {
1648
1714
  if (options.skipTrust) {
1649
1715
  args.push("--skip-trust");
1650
1716
  }
1651
- return { effectiveTld, args };
1717
+ return { effectiveTld, effectiveTlds, args };
1652
1718
  }
1653
1719
  async function discoverState() {
1654
1720
  if (process.env.PORTLESS_STATE_DIR) {
@@ -1657,14 +1723,25 @@ async function discoverState() {
1657
1723
  const lanIp = readLanMarker(dir2);
1658
1724
  if (await isProxyRunning(port) || await isPortListening(port)) {
1659
1725
  const tls2 = readTlsMarker(dir2);
1660
- const tld = readTldFromDir(dir2);
1661
- return { dir: dir2, port, tls: tls2, tld, lanMode: lanIp !== null || tld === "local", lanIp };
1726
+ const tlds3 = readTldsFromDir(dir2);
1727
+ const tld = tlds3[0] ?? DEFAULT_TLD;
1728
+ return {
1729
+ dir: dir2,
1730
+ port,
1731
+ tls: tls2,
1732
+ tld,
1733
+ tlds: tlds3,
1734
+ lanMode: lanIp !== null || tlds3.includes("local"),
1735
+ lanIp
1736
+ };
1662
1737
  }
1738
+ const tlds2 = readTldsFromDir(dir2);
1663
1739
  return {
1664
1740
  dir: dir2,
1665
1741
  port,
1666
1742
  tls: readTlsMarker(dir2),
1667
- tld: readTldFromDir(dir2),
1743
+ tld: tlds2[0] ?? DEFAULT_TLD,
1744
+ tlds: tlds2,
1668
1745
  lanMode: lanIp !== null,
1669
1746
  lanIp: null
1670
1747
  };
@@ -1673,14 +1750,16 @@ async function discoverState() {
1673
1750
  if (userPort !== null) {
1674
1751
  if (await isProxyRunning(userPort)) {
1675
1752
  const tls2 = readTlsMarker(USER_STATE_DIR);
1676
- const tld = readTldFromDir(USER_STATE_DIR);
1753
+ const tlds2 = readTldsFromDir(USER_STATE_DIR);
1754
+ const tld = tlds2[0] ?? DEFAULT_TLD;
1677
1755
  const lanIp = readLanMarker(USER_STATE_DIR);
1678
1756
  return {
1679
1757
  dir: USER_STATE_DIR,
1680
1758
  port: userPort,
1681
1759
  tls: tls2,
1682
1760
  tld,
1683
- lanMode: lanIp !== null || tld === "local",
1761
+ tlds: tlds2,
1762
+ lanMode: lanIp !== null || tlds2.includes("local"),
1684
1763
  lanIp
1685
1764
  };
1686
1765
  }
@@ -1689,14 +1768,16 @@ async function discoverState() {
1689
1768
  if (legacyPort !== null) {
1690
1769
  if (await isProxyRunning(legacyPort)) {
1691
1770
  const tls2 = readTlsMarker(LEGACY_SYSTEM_STATE_DIR);
1692
- const tld = readTldFromDir(LEGACY_SYSTEM_STATE_DIR);
1771
+ const tlds2 = readTldsFromDir(LEGACY_SYSTEM_STATE_DIR);
1772
+ const tld = tlds2[0] ?? DEFAULT_TLD;
1693
1773
  const lanIp = readLanMarker(LEGACY_SYSTEM_STATE_DIR);
1694
1774
  return {
1695
1775
  dir: LEGACY_SYSTEM_STATE_DIR,
1696
1776
  port: legacyPort,
1697
1777
  tls: tls2,
1698
1778
  tld,
1699
- lanMode: lanIp !== null || tld === "local",
1779
+ tlds: tlds2,
1780
+ lanMode: lanIp !== null || tlds2.includes("local"),
1700
1781
  lanIp
1701
1782
  };
1702
1783
  }
@@ -1708,17 +1789,28 @@ async function discoverState() {
1708
1789
  const dir2 = resolveStateDir(port);
1709
1790
  const markerTls = readTlsMarker(dir2);
1710
1791
  const tls2 = markerTls || port === getProtocolPort(true);
1711
- const tld = readTldFromDir(dir2);
1792
+ const tlds2 = readTldsFromDir(dir2);
1793
+ const tld = tlds2[0] ?? DEFAULT_TLD;
1712
1794
  const lanIp = readLanMarker(dir2);
1713
- return { dir: dir2, port, tls: tls2, tld, lanMode: lanIp !== null || tld === "local", lanIp };
1795
+ return {
1796
+ dir: dir2,
1797
+ port,
1798
+ tls: tls2,
1799
+ tld,
1800
+ tlds: tlds2,
1801
+ lanMode: lanIp !== null || tlds2.includes("local"),
1802
+ lanIp
1803
+ };
1714
1804
  }
1715
1805
  }
1716
1806
  const dir = resolveStateDir(configuredPort);
1807
+ const tlds = readTldsFromDir(dir);
1717
1808
  return {
1718
1809
  dir,
1719
1810
  port: configuredPort,
1720
1811
  tls: readTlsMarker(dir),
1721
- tld: readTldFromDir(dir),
1812
+ tld: tlds[0] ?? DEFAULT_TLD,
1813
+ tlds,
1722
1814
  lanMode: readLanMarker(dir) !== null,
1723
1815
  lanIp: null
1724
1816
  };
@@ -2001,7 +2093,9 @@ var PORTLESS_STATE_FILES = [
2001
2093
  "proxy.port",
2002
2094
  "proxy.log",
2003
2095
  "proxy.tls",
2096
+ "proxy.custom-cert",
2004
2097
  "proxy.tld",
2098
+ "proxy.tlds",
2005
2099
  "proxy.lan",
2006
2100
  "ca-key.pem",
2007
2101
  "ca.pem",
@@ -2774,6 +2868,15 @@ var SYSTEMD_SERVICE = "portless.service";
2774
2868
  var WINDOWS_TASK_NAME = "Portless Proxy";
2775
2869
  var INTERNAL_ELEVATED_ENV = "PORTLESS_INTERNAL_SERVICE_ELEVATED";
2776
2870
  var SERVICE_ENV_KEYS = /* @__PURE__ */ new Set(["PORTLESS_SYNC_HOSTS"]);
2871
+ function normalizeTlds(tlds) {
2872
+ return [...new Set(tlds.length > 0 ? tlds : [DEFAULT_TLD])];
2873
+ }
2874
+ function primaryTld(tlds) {
2875
+ return tlds[0] ?? DEFAULT_TLD;
2876
+ }
2877
+ function formatTldList(tlds) {
2878
+ return tlds.map((tld) => `.${tld}`).join(", ");
2879
+ }
2777
2880
  var DEFAULT_SERVICE_CONFIG = {
2778
2881
  proxyPort: DEFAULT_SERVICE_PORT,
2779
2882
  useHttps: true,
@@ -2783,6 +2886,7 @@ var DEFAULT_SERVICE_CONFIG = {
2783
2886
  lanIp: null,
2784
2887
  lanIpExplicit: false,
2785
2888
  tld: DEFAULT_TLD,
2889
+ tlds: [DEFAULT_TLD],
2786
2890
  useWildcard: false,
2787
2891
  extraEnv: {}
2788
2892
  };
@@ -2869,10 +2973,8 @@ function parseServiceInstallConfig(args, env = process.env, options = {}) {
2869
2973
  config.lanIpExplicit = true;
2870
2974
  }
2871
2975
  if (env.PORTLESS_TLD) {
2872
- const tld = env.PORTLESS_TLD.trim().toLowerCase();
2873
- const err = validateTld(tld);
2874
- if (err) throw new Error(`PORTLESS_TLD: ${err}`);
2875
- config.tld = tld;
2976
+ config.tlds = normalizeTlds(parseTldList(env.PORTLESS_TLD, "PORTLESS_TLD"));
2977
+ config.tld = primaryTld(config.tlds);
2876
2978
  }
2877
2979
  const envWildcard = parseBooleanEnv(env.PORTLESS_WILDCARD);
2878
2980
  if (envWildcard !== null) {
@@ -2884,6 +2986,7 @@ function parseServiceInstallConfig(args, env = process.env, options = {}) {
2884
2986
  config.proxyPort = getProtocolPort(config.useHttps);
2885
2987
  }
2886
2988
  const tokens = args[0] === "service" ? args.slice(2) : args;
2989
+ let tldFlagSeen = false;
2887
2990
  for (let i = 0; i < tokens.length; i += 1) {
2888
2991
  const token = tokens[i];
2889
2992
  switch (token) {
@@ -2908,10 +3011,10 @@ function parseServiceInstallConfig(args, env = process.env, options = {}) {
2908
3011
  i += 1;
2909
3012
  break;
2910
3013
  case "--tld": {
2911
- const tld = getFlagValue(tokens, i, token).trim().toLowerCase();
2912
- const err = validateTld(tld);
2913
- if (err) throw new Error(err);
2914
- config.tld = tld;
3014
+ const tlds = parseTldList(getFlagValue(tokens, i, token));
3015
+ config.tlds = normalizeTlds([...tldFlagSeen ? config.tlds : [], ...tlds]);
3016
+ config.tld = primaryTld(config.tlds);
3017
+ tldFlagSeen = true;
2915
3018
  i += 1;
2916
3019
  break;
2917
3020
  }
@@ -2951,6 +3054,9 @@ function parseServiceInstallConfig(args, env = process.env, options = {}) {
2951
3054
  if (!config.lanMode) {
2952
3055
  config.lanIp = null;
2953
3056
  config.lanIpExplicit = false;
3057
+ } else {
3058
+ config.tlds = ["local"];
3059
+ config.tld = "local";
2954
3060
  }
2955
3061
  return config;
2956
3062
  }
@@ -2996,6 +3102,7 @@ function buildProxyCommand(entryScript, serviceConfig) {
2996
3102
  lanIp: serviceConfig.lanIp,
2997
3103
  lanIpExplicit: serviceConfig.lanIpExplicit,
2998
3104
  tld: serviceConfig.tld,
3105
+ tlds: serviceConfig.tlds,
2999
3106
  useWildcard: serviceConfig.useWildcard,
3000
3107
  foreground: true,
3001
3108
  includePort: true,
@@ -3018,8 +3125,8 @@ function buildServiceEnv(ctx) {
3018
3125
  }
3019
3126
  if (ctx.config.lanMode) {
3020
3127
  env.PORTLESS_TLD = "local";
3021
- } else if (ctx.config.tld !== DEFAULT_TLD) {
3022
- env.PORTLESS_TLD = ctx.config.tld;
3128
+ } else if (ctx.config.tlds.length > 1 || ctx.config.tld !== DEFAULT_TLD) {
3129
+ env.PORTLESS_TLD = ctx.config.tlds.join(",");
3023
3130
  }
3024
3131
  if (ctx.platform === "win32") {
3025
3132
  env.USERPROFILE = ctx.user.home;
@@ -3104,6 +3211,10 @@ function buildServiceSpec(options) {
3104
3211
  ...options.installConfig,
3105
3212
  extraEnv: options.installConfig?.extraEnv ?? {}
3106
3213
  };
3214
+ installConfig.tlds = installConfig.lanMode ? ["local"] : normalizeTlds(
3215
+ options.installConfig?.tlds ?? (options.installConfig?.tld ? [options.installConfig.tld] : installConfig.tlds)
3216
+ );
3217
+ installConfig.tld = primaryTld(installConfig.tlds);
3107
3218
  const stateDir = options.stateDir || installConfig.stateDir || defaultStateDir(options.platform, options.userHome);
3108
3219
  const normalizedConfig = {
3109
3220
  ...installConfig,
@@ -3594,7 +3705,7 @@ async function printServiceStatus(entryScript, runner) {
3594
3705
  ` Proxy on ${config.proxyPort}: ${status.proxyRunning ? "responding" : "not responding"}`
3595
3706
  );
3596
3707
  console.log(` HTTPS: ${config.useHttps ? "yes" : "no"}`);
3597
- console.log(` TLD: ${config.lanMode ? "local" : config.tld}`);
3708
+ console.log(` TLDs: ${config.lanMode ? ".local" : formatTldList(config.tlds)}`);
3598
3709
  console.log(` LAN mode: ${config.lanMode ? "yes" : "no"}`);
3599
3710
  if (config.lanIpExplicit && config.lanIp) {
3600
3711
  console.log(` LAN IP: ${config.lanIp}`);
@@ -3622,7 +3733,7 @@ ${colors_default.bold("Install options:")}
3622
3733
  --https Enable HTTPS
3623
3734
  --lan Enable LAN mode
3624
3735
  --ip <address> Pin a specific LAN IP
3625
- --tld <tld> Use a custom TLD outside LAN mode
3736
+ --tld <tld> Use a custom TLD outside LAN mode, repeatable
3626
3737
  --wildcard Allow subdomain fallback
3627
3738
  --cert <path> Use a custom TLS certificate
3628
3739
  --key <path> Use a custom TLS private key
@@ -3672,7 +3783,17 @@ var DEBOUNCE_MS = 100;
3672
3783
  var POLL_INTERVAL_MS = 3e3;
3673
3784
  var EXIT_TIMEOUT_MS = 2e3;
3674
3785
  var SUDO_SPAWN_TIMEOUT_MS = 3e4;
3675
- function defaultProxyConfig(tld, useHttps, lanMode) {
3786
+ function normalizeTlds2(tlds) {
3787
+ return [...new Set(tlds.length > 0 ? tlds : [DEFAULT_TLD])];
3788
+ }
3789
+ function primaryTld2(tlds) {
3790
+ return tlds[0] ?? DEFAULT_TLD;
3791
+ }
3792
+ function formatTldList2(tlds) {
3793
+ return tlds.map((tld) => `.${tld}`).join(", ");
3794
+ }
3795
+ function defaultProxyConfig(tlds, useHttps, lanMode) {
3796
+ const effectiveTlds = lanMode ? ["local"] : normalizeTlds2(tlds);
3676
3797
  return {
3677
3798
  useHttps,
3678
3799
  customCertPath: null,
@@ -3680,13 +3801,14 @@ function defaultProxyConfig(tld, useHttps, lanMode) {
3680
3801
  lanMode,
3681
3802
  lanIp: null,
3682
3803
  lanIpExplicit: false,
3683
- tld: lanMode ? "local" : tld,
3804
+ tld: primaryTld2(effectiveTlds),
3805
+ tlds: effectiveTlds,
3684
3806
  useWildcard: false
3685
3807
  };
3686
3808
  }
3687
3809
  function resolveProxyConfig(options) {
3688
3810
  const config = defaultProxyConfig(
3689
- options.defaultTld,
3811
+ options.defaultTlds,
3690
3812
  options.useHttps,
3691
3813
  options.explicit.lanMode ? options.lanMode : options.persistedLanMode
3692
3814
  );
@@ -3707,8 +3829,9 @@ function resolveProxyConfig(options) {
3707
3829
  if (!options.lanMode) {
3708
3830
  config.lanIp = null;
3709
3831
  config.lanIpExplicit = false;
3710
- if (!options.explicit.tld) {
3711
- config.tld = options.defaultTld;
3832
+ if (!options.explicit.tlds) {
3833
+ config.tlds = normalizeTlds2(options.defaultTlds);
3834
+ config.tld = primaryTld2(config.tlds);
3712
3835
  }
3713
3836
  }
3714
3837
  }
@@ -3717,8 +3840,9 @@ function resolveProxyConfig(options) {
3717
3840
  config.lanIp = options.lanIp;
3718
3841
  config.lanIpExplicit = true;
3719
3842
  }
3720
- if (options.explicit.tld) {
3721
- config.tld = options.tld;
3843
+ if (options.explicit.tlds) {
3844
+ config.tlds = normalizeTlds2(options.tlds);
3845
+ config.tld = primaryTld2(config.tlds);
3722
3846
  }
3723
3847
  if (options.explicit.useWildcard) {
3724
3848
  config.useWildcard = options.useWildcard;
@@ -3728,6 +3852,7 @@ function resolveProxyConfig(options) {
3728
3852
  config.lanIpExplicit = false;
3729
3853
  }
3730
3854
  if (config.lanMode) {
3855
+ config.tlds = ["local"];
3731
3856
  config.tld = "local";
3732
3857
  if (!config.lanIpExplicit) {
3733
3858
  config.lanIp = null;
@@ -3741,15 +3866,17 @@ function resolveProxyConfig(options) {
3741
3866
  }
3742
3867
  function readCurrentProxyConfig(dir) {
3743
3868
  const lanIp = readLanMarker(dir);
3744
- const tld = readTldFromDir(dir);
3869
+ const tlds = readTldsFromDir(dir);
3870
+ const tld = primaryTld2(tlds);
3745
3871
  return {
3746
3872
  useHttps: readTlsMarker(dir),
3747
3873
  customCertPath: null,
3748
3874
  customKeyPath: null,
3749
- lanMode: lanIp !== null || tld === "local",
3875
+ lanMode: lanIp !== null || tlds.includes("local"),
3750
3876
  lanIp,
3751
3877
  lanIpExplicit: false,
3752
3878
  tld,
3879
+ tlds,
3753
3880
  useWildcard: false
3754
3881
  };
3755
3882
  }
@@ -3770,9 +3897,9 @@ function getProxyConfigMismatchMessages(desiredConfig, actualConfig, explicit) {
3770
3897
  desiredConfig.useHttps ? "requested HTTPS, but the running proxy is using HTTP" : "requested HTTP, but the running proxy is using HTTPS"
3771
3898
  );
3772
3899
  }
3773
- if (explicit.tld && desiredConfig.tld !== actualConfig.tld) {
3900
+ if (explicit.tlds && (desiredConfig.tlds.length !== actualConfig.tlds.length || desiredConfig.tlds.some((tld, index) => tld !== actualConfig.tlds[index]))) {
3774
3901
  messages.push(
3775
- `requested .${desiredConfig.tld}, but the running proxy is using .${actualConfig.tld}`
3902
+ `requested ${formatTldList2(desiredConfig.tlds)}, but the running proxy is using ${formatTldList2(actualConfig.tlds)}`
3776
3903
  );
3777
3904
  }
3778
3905
  return messages;
@@ -3787,6 +3914,7 @@ function formatProxyStartCommand(proxyPort, config) {
3787
3914
  lanIp: config.lanIpExplicit ? config.lanIp : null,
3788
3915
  lanIpExplicit: config.lanIpExplicit,
3789
3916
  tld: config.tld,
3917
+ tlds: config.tlds,
3790
3918
  useWildcard: config.useWildcard,
3791
3919
  includePort: proxyPort !== getDefaultPort(config.useHttps),
3792
3920
  proxyPort
@@ -3880,7 +4008,46 @@ function formatProcessExitSuffix(code, signal) {
3880
4008
  if (code !== null) return ` (exit ${code})`;
3881
4009
  return "";
3882
4010
  }
3883
- function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
4011
+ function buildHostnames(name, tlds) {
4012
+ return parseHostnames(name, normalizeTlds2(tlds));
4013
+ }
4014
+ function formatUrls(hostnames, proxyPort, tls2) {
4015
+ return hostnames.map((hostname) => formatUrl(hostname, proxyPort, tls2));
4016
+ }
4017
+ function formatViteAllowedHosts(tlds) {
4018
+ return tlds.map((configuredTld) => `.${configuredTld}`).join(",");
4019
+ }
4020
+ function addRoutes(store, hostnames, port, pid, force = false) {
4021
+ const registered = [];
4022
+ const killedPids = [];
4023
+ try {
4024
+ for (const hostname of hostnames) {
4025
+ const killedPid = store.addRoute(hostname, port, pid, force);
4026
+ registered.push(hostname);
4027
+ if (killedPid !== void 0) {
4028
+ killedPids.push(killedPid);
4029
+ }
4030
+ }
4031
+ } catch (err) {
4032
+ for (const hostname of registered) {
4033
+ try {
4034
+ store.removeRoute(hostname, pid);
4035
+ } catch {
4036
+ }
4037
+ }
4038
+ throw err;
4039
+ }
4040
+ return [...new Set(killedPids)];
4041
+ }
4042
+ function removeRoutes(store, hostnames, ownerPid) {
4043
+ for (const hostname of hostnames) {
4044
+ try {
4045
+ store.removeRoute(hostname, ownerPid);
4046
+ } catch {
4047
+ }
4048
+ }
4049
+ }
4050
+ function startProxyServer(store, proxyPort, tld, tlds, tlsOptions, lanIp, strict, customCert = false) {
3884
4051
  store.ensureDir();
3885
4052
  const isTls = !!tlsOptions;
3886
4053
  const mdnsSupport = isMdnsSupported();
@@ -3972,6 +4139,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
3972
4139
  getRoutes: () => cachedRoutes,
3973
4140
  proxyPort,
3974
4141
  tld,
4142
+ tlds,
3975
4143
  strict,
3976
4144
  onError: (msg) => console.error(colors_default.red(msg)),
3977
4145
  tls: tlsOptions
@@ -4009,11 +4177,12 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
4009
4177
  fs9.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
4010
4178
  fs9.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
4011
4179
  writeTlsMarker(store.dir, isTls);
4012
- writeTldFile(store.dir, tld);
4180
+ writeCustomCertMarker(store.dir, isTls && customCert);
4181
+ writeTldsFile(store.dir, tlds);
4013
4182
  writeLanMarker(store.dir, activeLanIp);
4014
4183
  fixOwnership(store.dir, store.pidPath, store.portFilePath);
4015
4184
  const proto = isTls ? "HTTPS/2" : "HTTP";
4016
- const tldLabel = tld !== DEFAULT_TLD ? ` (TLD: .${tld})` : "";
4185
+ const tldLabel = tlds.length > 1 || tld !== DEFAULT_TLD ? ` (TLDs: ${formatTldList2(tlds)})` : "";
4017
4186
  const modeLabel = strict === false ? " (wildcard)" : "";
4018
4187
  console.log(
4019
4188
  colors_default.green(`${proto} proxy listening on port ${proxyPort}${tldLabel}${modeLabel}`)
@@ -4021,7 +4190,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
4021
4190
  if (activeLanIp) {
4022
4191
  console.log(chalk.green(`LAN mode: ${activeLanIp}`));
4023
4192
  console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
4024
- if (isTls) {
4193
+ if (isTls && !customCert) {
4025
4194
  console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
4026
4195
  console.log(chalk.gray(` ${path9.join(store.dir, "ca.pem")}`));
4027
4196
  }
@@ -4063,6 +4232,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
4063
4232
  } catch {
4064
4233
  }
4065
4234
  writeTlsMarker(store.dir, false);
4235
+ writeCustomCertMarker(store.dir, false);
4066
4236
  writeTldFile(store.dir, DEFAULT_TLD);
4067
4237
  writeLanMarker(store.dir, null);
4068
4238
  if (autoSyncHosts) cleanHostsFile();
@@ -4101,6 +4271,7 @@ async function stopProxy(store, proxyPort, _tls) {
4101
4271
  } catch {
4102
4272
  }
4103
4273
  writeTlsMarker(store.dir, false);
4274
+ writeCustomCertMarker(store.dir, false);
4104
4275
  writeTldFile(store.dir, DEFAULT_TLD);
4105
4276
  writeLanMarker(store.dir, null);
4106
4277
  console.log(colors_default.green(`Killed process ${pid}. Proxy stopped.`));
@@ -4140,6 +4311,7 @@ async function stopProxy(store, proxyPort, _tls) {
4140
4311
  console.error(colors_default.red("Corrupted PID file. Removing it."));
4141
4312
  fs9.unlinkSync(pidPath);
4142
4313
  writeTlsMarker(store.dir, false);
4314
+ writeCustomCertMarker(store.dir, false);
4143
4315
  writeTldFile(store.dir, DEFAULT_TLD);
4144
4316
  writeLanMarker(store.dir, null);
4145
4317
  return;
@@ -4158,6 +4330,7 @@ async function stopProxy(store, proxyPort, _tls) {
4158
4330
  } catch {
4159
4331
  }
4160
4332
  writeTlsMarker(store.dir, false);
4333
+ writeCustomCertMarker(store.dir, false);
4161
4334
  writeTldFile(store.dir, DEFAULT_TLD);
4162
4335
  writeLanMarker(store.dir, null);
4163
4336
  return;
@@ -4171,6 +4344,7 @@ async function stopProxy(store, proxyPort, _tls) {
4171
4344
  console.log(colors_default.yellow("Removing stale PID file."));
4172
4345
  fs9.unlinkSync(pidPath);
4173
4346
  writeTlsMarker(store.dir, false);
4347
+ writeCustomCertMarker(store.dir, false);
4174
4348
  writeTldFile(store.dir, DEFAULT_TLD);
4175
4349
  writeLanMarker(store.dir, null);
4176
4350
  return;
@@ -4182,6 +4356,7 @@ async function stopProxy(store, proxyPort, _tls) {
4182
4356
  } catch {
4183
4357
  }
4184
4358
  writeTlsMarker(store.dir, false);
4359
+ writeCustomCertMarker(store.dir, false);
4185
4360
  writeTldFile(store.dir, DEFAULT_TLD);
4186
4361
  writeLanMarker(store.dir, null);
4187
4362
  console.log(colors_default.green("Proxy stopped."));
@@ -4225,28 +4400,29 @@ function listRoutes(store, proxyPort, tls2) {
4225
4400
  console.log();
4226
4401
  }
4227
4402
  function resolveProxyDesiredState(lanMode) {
4228
- const envTld = getDefaultTld();
4403
+ const envTlds = getDefaultTlds();
4404
+ const envTld = primaryTld2(envTlds);
4229
4405
  const explicit = {
4230
4406
  useHttps: process.env.PORTLESS_HTTPS !== void 0,
4231
4407
  customCert: false,
4232
4408
  lanMode: process.env.PORTLESS_LAN !== void 0,
4233
4409
  lanIp: process.env.PORTLESS_LAN_IP !== void 0,
4234
- tld: process.env.PORTLESS_TLD !== void 0,
4410
+ tlds: process.env.PORTLESS_TLD !== void 0,
4235
4411
  useWildcard: process.env.PORTLESS_WILDCARD !== void 0
4236
4412
  };
4237
4413
  const desiredConfig = resolveProxyConfig({
4238
4414
  persistedLanMode: lanMode,
4239
4415
  explicit,
4240
- defaultTld: envTld,
4416
+ defaultTlds: envTlds,
4241
4417
  useHttps: !isHttpsEnvDisabled(),
4242
4418
  customCertPath: null,
4243
4419
  customKeyPath: null,
4244
4420
  lanMode: isLanEnvEnabled(),
4245
4421
  lanIp: process.env.PORTLESS_LAN_IP || null,
4246
- tld: envTld,
4422
+ tlds: envTlds,
4247
4423
  useWildcard: isWildcardEnvEnabled()
4248
4424
  });
4249
- return { explicit, desiredConfig, envTld };
4425
+ return { explicit, desiredConfig, envTld, envTlds };
4250
4426
  }
4251
4427
  async function ensureProxyRunning(proxyPort, tls2, desired) {
4252
4428
  const { explicit, desiredConfig } = desired;
@@ -4262,8 +4438,9 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
4262
4438
  if (!explicit.useHttps && persisted.tls !== desiredConfig.useHttps) {
4263
4439
  startConfig.useHttps = persisted.tls;
4264
4440
  }
4265
- if (!explicit.tld && persisted.tld !== desiredConfig.tld) {
4266
- startConfig.tld = persisted.tld;
4441
+ if (!explicit.tlds && (persisted.tlds.length !== desiredConfig.tlds.length || persisted.tlds.some((tld, index) => tld !== desiredConfig.tlds[index]))) {
4442
+ startConfig.tlds = persisted.tlds;
4443
+ startConfig.tld = primaryTld2(persisted.tlds);
4267
4444
  }
4268
4445
  if (!explicit.lanMode && persisted.lanMode !== desiredConfig.lanMode) {
4269
4446
  startConfig.lanMode = persisted.lanMode;
@@ -4299,6 +4476,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
4299
4476
  lanIp: startConfig.lanIpExplicit ? startConfig.lanIp : null,
4300
4477
  lanIpExplicit: startConfig.lanIpExplicit,
4301
4478
  tld: startConfig.tld,
4479
+ tlds: startConfig.tlds,
4302
4480
  useWildcard: startConfig.useWildcard,
4303
4481
  includePort: startPort !== void 0,
4304
4482
  proxyPort: startPort
@@ -4333,7 +4511,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
4333
4511
  }
4334
4512
  return { started: true, state: discovered };
4335
4513
  }
4336
- async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2, tld, force, autoInfo, desiredPort, lanMode = false, lanIp) {
4514
+ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2, tlds, force, autoInfo, desiredPort, lanMode = false, lanIp) {
4337
4515
  let store = initialStore;
4338
4516
  console.log(chalk.blue.bold(`
4339
4517
  portless
@@ -4380,12 +4558,12 @@ portless
4380
4558
  console.error(colors_default.red(`Error: ${err.message}`));
4381
4559
  process.exit(1);
4382
4560
  }
4383
- parseHostname(name, tld);
4561
+ buildHostnames(name, tlds);
4384
4562
  const ensureResult = await ensureProxyRunning(proxyPort, tls2, desired);
4385
4563
  if (ensureResult.started) {
4386
4564
  proxyPort = ensureResult.state.port;
4387
4565
  stateDir = ensureResult.state.dir;
4388
- tld = ensureResult.state.tld;
4566
+ tlds = ensureResult.state.tlds;
4389
4567
  tls2 = ensureResult.state.tls;
4390
4568
  lanMode = ensureResult.state.lanMode;
4391
4569
  lanIp = ensureResult.state.lanIp;
@@ -4407,20 +4585,22 @@ portless
4407
4585
  }
4408
4586
  lanMode = runningConfig.lanMode;
4409
4587
  lanIp = runningConfig.lanIp;
4588
+ tlds = runningConfig.tlds;
4410
4589
  console.log(chalk.gray("-- Proxy is running"));
4411
4590
  }
4412
- const hostname = parseHostname(name, tld);
4413
- if (desired.envTld !== DEFAULT_TLD && desired.envTld !== tld) {
4591
+ const hostnames = buildHostnames(name, tlds);
4592
+ const hostname = hostnames[0];
4593
+ if (desired.explicit.tlds && (desired.envTlds.some((envConfiguredTld, index) => envConfiguredTld !== tlds[index]) || desired.envTlds.length !== tlds.length)) {
4414
4594
  console.warn(
4415
4595
  chalk.yellow(
4416
- `Warning: PORTLESS_TLD=${desired.envTld} but the running proxy uses .${tld}. Using .${tld}.`
4596
+ `Warning: PORTLESS_TLD=${desired.envTlds.join(",")} but the running proxy uses ${formatTldList2(tlds)}.`
4417
4597
  )
4418
4598
  );
4419
4599
  }
4420
4600
  if (lanIp) {
4421
- console.log(chalk.gray(`-- ${hostname} (LAN: ${lanIp})`));
4601
+ console.log(chalk.gray(`-- ${hostnames.join(", ")} (LAN: ${lanIp})`));
4422
4602
  } else {
4423
- console.log(chalk.gray(`-- ${hostname} (auto-resolves to 127.0.0.1)`));
4603
+ console.log(chalk.gray(`-- ${hostnames.join(", ")} (auto-resolves to 127.0.0.1)`));
4424
4604
  }
4425
4605
  if (autoInfo) {
4426
4606
  const baseName = autoInfo.prefix ? name.slice(autoInfo.prefix.length + 1) : name;
@@ -4435,9 +4615,9 @@ portless
4435
4615
  } else {
4436
4616
  console.log(colors_default.green(`-- Using port ${port}`));
4437
4617
  }
4438
- let killedPid;
4618
+ let killedPids = [];
4439
4619
  try {
4440
- killedPid = store.addRoute(hostname, port, process.pid, force);
4620
+ killedPids = addRoutes(store, hostnames, port, process.pid, force);
4441
4621
  } catch (err) {
4442
4622
  if (err instanceof RouteConflictError) {
4443
4623
  console.error(colors_default.red(`Error: ${err.message}`));
@@ -4445,13 +4625,20 @@ portless
4445
4625
  }
4446
4626
  throw err;
4447
4627
  }
4448
- if (killedPid !== void 0) {
4449
- console.log(colors_default.yellow(`Killed existing process (PID ${killedPid})`));
4628
+ if (killedPids.length > 0) {
4629
+ console.log(colors_default.yellow(`Killed existing process(es): ${killedPids.join(", ")}`));
4450
4630
  }
4451
4631
  const finalUrl = formatUrl(hostname, proxyPort, tls2);
4632
+ const allUrls = formatUrls(hostnames, proxyPort, tls2);
4452
4633
  console.log(chalk.cyan.bold(`
4453
4634
  -> ${finalUrl}
4454
4635
  `));
4636
+ for (const extraUrl of allUrls.slice(1)) {
4637
+ console.log(chalk.cyan(` also -> ${extraUrl}`));
4638
+ }
4639
+ if (allUrls.length > 1) {
4640
+ console.log("");
4641
+ }
4455
4642
  if (lanIp) {
4456
4643
  console.log(chalk.green(` LAN -> ${finalUrl}`));
4457
4644
  console.log(chalk.gray(" (accessible from other devices on the same WiFi network)\n"));
@@ -4564,7 +4751,7 @@ portless
4564
4751
  } catch {
4565
4752
  }
4566
4753
  try {
4567
- store.removeRoute(hostname);
4754
+ removeRoutes(store, hostnames, process.pid);
4568
4755
  } catch {
4569
4756
  }
4570
4757
  process.exit(1);
@@ -4598,7 +4785,7 @@ portless
4598
4785
  PORT: port.toString(),
4599
4786
  ...hostBind ? { HOST: hostBind } : {},
4600
4787
  PORTLESS_URL: finalUrl,
4601
- __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: `.${tld}`,
4788
+ __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: formatViteAllowedHosts(tlds),
4602
4789
  // Note: EXPO_PACKAGER_PROXY_URL is not used — expo-dev-client removed
4603
4790
  // baked-in pinging, making this env var ineffective. Expo handles its
4604
4791
  // own LAN discovery natively.
@@ -4618,7 +4805,7 @@ portless
4618
4805
  } catch {
4619
4806
  }
4620
4807
  try {
4621
- store.removeRoute(hostname);
4808
+ removeRoutes(store, hostnames, process.pid);
4622
4809
  } catch {
4623
4810
  }
4624
4811
  }
@@ -4803,13 +4990,14 @@ ${colors_default.bold("Usage:")}
4803
4990
  ${colors_default.cyan("portless run")} Same as above
4804
4991
  ${colors_default.cyan("portless run <cmd>")} Run a command through the proxy
4805
4992
  ${colors_default.cyan("portless <name> <cmd>")} Run with an explicit app name
4806
- ${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon)
4993
+ ${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon); rarely needed since it auto-starts on first run
4807
4994
  ${colors_default.cyan("portless proxy stop")} Stop the proxy
4808
4995
  ${colors_default.cyan("portless service install")} Start proxy automatically when the OS starts
4809
4996
  ${colors_default.cyan("portless get <name>")} Print URL for a service (for cross-service refs)
4810
4997
  ${colors_default.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
4811
4998
  ${colors_default.cyan("portless alias --remove <name>")} Remove a static route
4812
4999
  ${colors_default.cyan("portless list")} Show active routes
5000
+ ${colors_default.cyan("portless doctor")} Check local portless health
4813
5001
  ${colors_default.cyan("portless trust")} Add local CA to system trust store
4814
5002
  ${colors_default.cyan("portless clean")} Remove portless state, trust entry, and hosts block
4815
5003
  ${colors_default.cyan("portless prune")} Kill orphaned dev servers from crashed sessions
@@ -4827,6 +5015,7 @@ ${colors_default.bold("Examples:")}
4827
5015
  portless service install --lan # Persist LAN mode in the startup service
4828
5016
  portless service install --wildcard # Persist wildcard routing in the startup service
4829
5017
  portless get backend # -> https://backend.localhost
5018
+ portless doctor # Check proxy, routes, DNS, and CA trust
4830
5019
  portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
4831
5020
  portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
4832
5021
  portless myapp --ngrok next dev # -> also https://<random>.ngrok.app (public)
@@ -4909,7 +5098,7 @@ ${colors_default.bold("Options:")}
4909
5098
  --cert <path> Use a custom TLS certificate
4910
5099
  --key <path> Use a custom TLS private key
4911
5100
  --foreground Run proxy in foreground (for debugging)
4912
- --tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
5101
+ --tld <tld> Use a custom TLD instead of .localhost; repeat for more
4913
5102
  --wildcard Allow unregistered subdomains to fall back to parent route
4914
5103
  --state-dir <path> Use a custom state directory with service install
4915
5104
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
@@ -4926,7 +5115,7 @@ ${colors_default.bold("Environment variables:")}
4926
5115
  PORTLESS_HTTPS=0 Disable HTTPS (same as --no-tls)
4927
5116
  PORTLESS_LAN=1 Enable LAN mode when set to 1 (set in .bashrc / .zshrc)
4928
5117
  PORTLESS_LAN_IP=<address> Pin a specific LAN IP for LAN mode
4929
- PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
5118
+ PORTLESS_TLD=<tld>[,<tld>] Use one or more TLDs (e.g. localhost,test)
4930
5119
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
4931
5120
  PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
4932
5121
  PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
@@ -4938,7 +5127,7 @@ ${colors_default.bold("Environment variables:")}
4938
5127
  ${colors_default.bold("Child process environment:")}
4939
5128
  PORT Ephemeral port the child should listen on
4940
5129
  HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
4941
- PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
5130
+ PORTLESS_URL Primary public URL of the app
4942
5131
  PORTLESS_LAN Set to 1 when proxy is in LAN mode
4943
5132
  PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
4944
5133
  PORTLESS_NGROK_URL ngrok URL of the app (when --ngrok is active)
@@ -4957,14 +5146,14 @@ ${colors_default.bold("Skip portless:")}
4957
5146
  PORTLESS=0 pnpm dev # Runs command directly without proxy
4958
5147
 
4959
5148
  ${colors_default.bold("Reserved names:")}
4960
- run, get, alias, hosts, list, trust, clean, prune, proxy, service are subcommands and
5149
+ run, get, alias, hosts, list, doctor, trust, clean, prune, proxy, service are subcommands and
4961
5150
  cannot be used as app names directly. Use "portless run" to infer the name,
4962
5151
  or "portless --name <name>" to force any name including reserved ones.
4963
5152
  `);
4964
5153
  process.exit(0);
4965
5154
  }
4966
5155
  function printVersion() {
4967
- console.log("0.14.0");
5156
+ console.log("0.15.1");
4968
5157
  process.exit(0);
4969
5158
  }
4970
5159
  async function handleTrust() {
@@ -5239,8 +5428,8 @@ ${colors_default.bold("Examples:")}
5239
5428
  const name = positional[0];
5240
5429
  const worktree = skipWorktree ? null : detectWorktreePrefix();
5241
5430
  const effectiveName = worktree ? `${worktree.prefix}.${name}` : name;
5242
- const { port, tls: tls2, tld } = await discoverState();
5243
- const hostname = parseHostname(effectiveName, tld);
5431
+ const { port, tls: tls2, tlds } = await discoverState();
5432
+ const hostname = buildHostnames(effectiveName, tlds)[0];
5244
5433
  const url = formatUrl(hostname, port, tls2);
5245
5434
  process.stdout.write(url + "\n");
5246
5435
  }
@@ -5261,7 +5450,7 @@ ${colors_default.bold("Examples:")}
5261
5450
  `);
5262
5451
  process.exit(0);
5263
5452
  }
5264
- const { dir, tld } = await discoverState();
5453
+ const { dir, tlds } = await discoverState();
5265
5454
  const store = new RouteStore(dir, {
5266
5455
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
5267
5456
  });
@@ -5272,15 +5461,15 @@ ${colors_default.bold("Examples:")}
5272
5461
  console.error(colors_default.cyan(" portless alias --remove <name>"));
5273
5462
  process.exit(1);
5274
5463
  }
5275
- const hostname2 = parseHostname(aliasName2, tld);
5464
+ const hostnames2 = buildHostnames(aliasName2, tlds);
5276
5465
  const routes = store.loadRoutes();
5277
- const existing = routes.find((r) => r.hostname === hostname2 && r.pid === 0);
5466
+ const existing = routes.find((r) => hostnames2.includes(r.hostname) && r.pid === 0);
5278
5467
  if (!existing) {
5279
- console.error(colors_default.red(`Error: No alias found for "${hostname2}".`));
5468
+ console.error(colors_default.red(`Error: No alias found for "${hostnames2.join(", ")}".`));
5280
5469
  process.exit(1);
5281
5470
  }
5282
- store.removeRoute(hostname2);
5283
- console.log(colors_default.green(`Removed alias: ${hostname2}`));
5471
+ removeRoutes(store, hostnames2);
5472
+ console.log(colors_default.green(`Removed alias: ${hostnames2.join(", ")}`));
5284
5473
  return;
5285
5474
  }
5286
5475
  const aliasName = args[1];
@@ -5294,15 +5483,15 @@ ${colors_default.bold("Examples:")}
5294
5483
  console.error(colors_default.cyan(" portless alias my-postgres 5432"));
5295
5484
  process.exit(1);
5296
5485
  }
5297
- const hostname = parseHostname(aliasName, tld);
5486
+ const hostnames = buildHostnames(aliasName, tlds);
5298
5487
  const port = parseInt(aliasPort, 10);
5299
5488
  if (isNaN(port) || port < 1 || port > 65535) {
5300
5489
  console.error(colors_default.red(`Error: Invalid port "${aliasPort}". Must be 1-65535.`));
5301
5490
  process.exit(1);
5302
5491
  }
5303
5492
  const force = args.includes("--force");
5304
- store.addRoute(hostname, port, 0, force);
5305
- console.log(colors_default.green(`Alias registered: ${hostname} -> 127.0.0.1:${port}`));
5493
+ addRoutes(store, hostnames, port, 0, force);
5494
+ console.log(colors_default.green(`Alias registered: ${hostnames.join(", ")} -> 127.0.0.1:${port}`));
5306
5495
  }
5307
5496
  async function handleHosts(args) {
5308
5497
  if (args[1] === "--help" || args[1] === "-h") {
@@ -5401,6 +5590,358 @@ ${colors_default.bold("Usage: portless hosts <command>")}
5401
5590
  );
5402
5591
  process.exit(1);
5403
5592
  }
5593
+ function colorDoctorStatus(status) {
5594
+ if (status === "fail") return colors_default.red;
5595
+ if (status === "warn") return colors_default.yellow;
5596
+ if (status === "ok") return colors_default.green;
5597
+ return colors_default.gray;
5598
+ }
5599
+ function printDoctorFinding(finding) {
5600
+ const status = finding.status.padEnd(5);
5601
+ console.log(`${colorDoctorStatus(finding.status)(status)} ${finding.message}`);
5602
+ if (finding.hint) {
5603
+ console.log(colors_default.gray(` ${finding.hint}`));
5604
+ }
5605
+ }
5606
+ function isProcessAliveForDoctor(pid) {
5607
+ if (pid <= 0) return true;
5608
+ return isProcessAlive(pid);
5609
+ }
5610
+ function checkPathWritable(targetPath) {
5611
+ try {
5612
+ fs9.accessSync(targetPath, fs9.constants.W_OK);
5613
+ return true;
5614
+ } catch {
5615
+ return false;
5616
+ }
5617
+ }
5618
+ function findExistingAncestor(targetPath) {
5619
+ let current = targetPath;
5620
+ for (; ; ) {
5621
+ if (fs9.existsSync(current)) return current;
5622
+ const parent = path9.dirname(current);
5623
+ if (parent === current) return null;
5624
+ current = parent;
5625
+ }
5626
+ }
5627
+ function checkCommandAvailable(command, args) {
5628
+ const result = spawnSync5(command, args, {
5629
+ stdio: "ignore",
5630
+ timeout: 3e3,
5631
+ windowsHide: true
5632
+ });
5633
+ return !result.error && (result.status === 0 || result.status === null);
5634
+ }
5635
+ function pluralize(count, singular, plural = `${singular}s`) {
5636
+ return `${count} ${count === 1 ? singular : plural}`;
5637
+ }
5638
+ function isValidTcpPort(port) {
5639
+ return Number.isInteger(port) && port >= 1 && port <= 65535;
5640
+ }
5641
+ function doctorProxyStartHint(proxyPort, tls2) {
5642
+ const defaultPort = getDefaultPort(tls2);
5643
+ const portArgs = proxyPort === defaultPort ? "" : ` -p ${proxyPort}`;
5644
+ const tlsArgs = tls2 ? "" : " --no-tls";
5645
+ return `Run: portless proxy start${portArgs}${tlsArgs}`;
5646
+ }
5647
+ async function handleDoctor(args) {
5648
+ if (args[1] === "--help" || args[1] === "-h") {
5649
+ console.log(`
5650
+ ${colors_default.bold("portless doctor")} - Check local portless health and print suggested fixes.
5651
+
5652
+ ${colors_default.bold("Usage:")}
5653
+ ${colors_default.cyan("portless doctor")}
5654
+
5655
+ Checks Node.js, the state directory, proxy liveness, route entries, HTTPS CA
5656
+ trust, hostname resolution, and LAN mode prerequisites. It does not start,
5657
+ stop, clean, prune, trust, or modify portless state.
5658
+
5659
+ ${colors_default.bold("Options:")}
5660
+ --help, -h Show this help
5661
+ `);
5662
+ process.exit(0);
5663
+ }
5664
+ if (args.length > 1) {
5665
+ console.error(colors_default.red(`Error: Unknown argument "${args[1]}".`));
5666
+ console.error(colors_default.cyan(" portless doctor --help"));
5667
+ process.exit(1);
5668
+ }
5669
+ const findings = [];
5670
+ const add = (status, message, hint) => {
5671
+ findings.push({ status, message, hint });
5672
+ };
5673
+ let state;
5674
+ try {
5675
+ state = await discoverState();
5676
+ } catch (err) {
5677
+ const message = err instanceof Error ? err.message : String(err);
5678
+ add("fail", `Could not discover portless state: ${message}`);
5679
+ state = {
5680
+ dir: resolveStateDir(),
5681
+ port: getDefaultPort(!isHttpsEnvDisabled()),
5682
+ tls: !isHttpsEnvDisabled(),
5683
+ tld: DEFAULT_TLD,
5684
+ tlds: [DEFAULT_TLD],
5685
+ lanMode: isLanEnvEnabled(),
5686
+ lanIp: null
5687
+ };
5688
+ }
5689
+ const store = new RouteStore(state.dir, {
5690
+ onWarning: (msg) => add("warn", msg)
5691
+ });
5692
+ const hasPortFile = fs9.existsSync(store.portFilePath);
5693
+ const configuredTls = hasPortFile ? state.tls : !isHttpsEnvDisabled();
5694
+ const configuredPort = hasPortFile ? state.port : getDefaultPort(configuredTls);
5695
+ const initialProxyRunning = await isProxyRunning(state.port, state.tls);
5696
+ const probePort = initialProxyRunning || hasPortFile ? state.port : configuredPort;
5697
+ const probeTls = initialProxyRunning || hasPortFile ? state.tls : configuredTls;
5698
+ const proxyRunning = initialProxyRunning && probePort === state.port ? true : await isProxyRunning(probePort, probeTls);
5699
+ const portListening = proxyRunning ? true : await isPortListening(probePort);
5700
+ const proxyPort = proxyRunning || portListening || hasPortFile ? probePort : configuredPort;
5701
+ const proxyTls = proxyRunning || portListening || hasPortFile ? probeTls : configuredTls;
5702
+ const currentProxyStateIsHttp = (proxyRunning || portListening || hasPortFile) && !proxyTls;
5703
+ const proxyUsesCustomCert = proxyTls && readCustomCertMarker(state.dir);
5704
+ const stateExists = fs9.existsSync(state.dir);
5705
+ console.log(colors_default.blue.bold("\nportless doctor\n"));
5706
+ console.log(`Version: ${"0.15.1"}`);
5707
+ console.log(`Node.js: ${process.versions.node}`);
5708
+ console.log(`Platform: ${process.platform} ${process.arch}`);
5709
+ console.log(`State dir: ${state.dir}`);
5710
+ console.log(`Proxy target: ${formatUrl("127.0.0.1", proxyPort, proxyTls)}`);
5711
+ console.log(
5712
+ `Mode: ${proxyTls ? "HTTPS" : "HTTP"}, ${formatTldList2(state.tlds)}${state.lanMode ? ", LAN" : ""}`
5713
+ );
5714
+ console.log("");
5715
+ const nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
5716
+ if (nodeMajor >= 24) {
5717
+ add("ok", `Node.js ${process.versions.node} satisfies portless requirements.`);
5718
+ } else {
5719
+ add("fail", `Node.js ${process.versions.node} is unsupported.`, "Install Node.js 24 or newer.");
5720
+ }
5721
+ if (stateExists) {
5722
+ try {
5723
+ const stat = fs9.statSync(state.dir);
5724
+ if (!stat.isDirectory()) {
5725
+ add("fail", `State path exists but is not a directory: ${state.dir}`);
5726
+ } else if (checkPathWritable(state.dir)) {
5727
+ add("ok", `State directory is writable: ${state.dir}`);
5728
+ } else {
5729
+ add("fail", `State directory is not writable: ${state.dir}`);
5730
+ }
5731
+ } catch (err) {
5732
+ const message = err instanceof Error ? err.message : String(err);
5733
+ add("fail", `Could not inspect state directory: ${message}`);
5734
+ }
5735
+ } else {
5736
+ const ancestor = findExistingAncestor(path9.dirname(state.dir));
5737
+ if (!ancestor) {
5738
+ add(
5739
+ "fail",
5740
+ `State directory does not exist and no writable ancestor was found: ${state.dir}`
5741
+ );
5742
+ } else {
5743
+ const ancestorStat = fs9.statSync(ancestor);
5744
+ if (!ancestorStat.isDirectory()) {
5745
+ add("fail", `State directory does not exist and ancestor is not a directory: ${ancestor}`);
5746
+ } else if (checkPathWritable(ancestor)) {
5747
+ add("info", `State directory has not been created yet: ${state.dir}`);
5748
+ } else {
5749
+ add("fail", `State directory does not exist and ancestor is not writable: ${ancestor}`);
5750
+ }
5751
+ }
5752
+ }
5753
+ if (proxyRunning) {
5754
+ add("ok", `Proxy is responding on port ${proxyPort}.`);
5755
+ } else if (portListening) {
5756
+ const pid = findPidOnPort(proxyPort);
5757
+ add(
5758
+ "fail",
5759
+ `Port ${proxyPort} is in use, but it is not a portless proxy.`,
5760
+ pid ? `Process on port: PID ${pid}` : "Could not identify the process holding the port."
5761
+ );
5762
+ } else {
5763
+ add(
5764
+ "warn",
5765
+ `Proxy is not running on port ${proxyPort}.`,
5766
+ doctorProxyStartHint(proxyPort, proxyTls)
5767
+ );
5768
+ }
5769
+ if (fs9.existsSync(store.pidPath)) {
5770
+ try {
5771
+ const rawPid = fs9.readFileSync(store.pidPath, "utf-8").trim();
5772
+ const pid = parseInt(rawPid, 10);
5773
+ if (isNaN(pid) || pid <= 0) {
5774
+ add("fail", `Proxy PID file is invalid: ${store.pidPath}`);
5775
+ } else if (!isProcessAliveForDoctor(pid)) {
5776
+ add("warn", `Proxy PID file is stale: ${pid}`, "Run: portless proxy stop");
5777
+ } else if (!proxyRunning) {
5778
+ add(
5779
+ "warn",
5780
+ `Proxy PID file points to PID ${pid}, but no portless proxy is responding on port ${proxyPort}.`,
5781
+ "Run: portless proxy stop"
5782
+ );
5783
+ } else {
5784
+ const portPid = findPidOnPort(proxyPort);
5785
+ if (portPid !== null && portPid !== pid) {
5786
+ add(
5787
+ "warn",
5788
+ `Proxy PID file points to PID ${pid}, but port ${proxyPort} is owned by PID ${portPid}.`,
5789
+ "Run: portless proxy stop"
5790
+ );
5791
+ } else {
5792
+ add("ok", `Proxy PID file points to the responding proxy process: ${pid}`);
5793
+ }
5794
+ }
5795
+ } catch (err) {
5796
+ const message = err instanceof Error ? err.message : String(err);
5797
+ add("fail", `Could not read proxy PID file: ${message}`);
5798
+ }
5799
+ } else if (proxyRunning) {
5800
+ add("warn", `Proxy is running but the PID file is missing: ${store.pidPath}`);
5801
+ }
5802
+ if (proxyUsesCustomCert) {
5803
+ add("ok", "Proxy is configured with a custom TLS certificate.");
5804
+ } else if (proxyTls || !currentProxyStateIsHttp && !isHttpsEnvDisabled()) {
5805
+ if (checkCommandAvailable("openssl", ["version"])) {
5806
+ add("ok", "OpenSSL is available for certificate generation.");
5807
+ } else {
5808
+ add(
5809
+ "fail",
5810
+ "OpenSSL is not available on PATH.",
5811
+ isWindows ? "Install OpenSSL or add Git for Windows OpenSSL to PATH." : "Install OpenSSL with your system package manager."
5812
+ );
5813
+ }
5814
+ } else {
5815
+ add("info", "HTTPS is disabled, so OpenSSL is not required for this run.");
5816
+ }
5817
+ if (proxyTls && proxyUsesCustomCert) {
5818
+ add("info", "Generated local CA is not required for custom TLS certificates.");
5819
+ } else if (proxyTls) {
5820
+ const caPath = path9.join(state.dir, "ca.pem");
5821
+ if (fs9.existsSync(caPath)) {
5822
+ if (isCATrusted(state.dir)) {
5823
+ add("ok", "Local CA is trusted by the OS trust store.");
5824
+ } else {
5825
+ add(
5826
+ "warn",
5827
+ "Local CA exists but is not trusted by the OS trust store.",
5828
+ "Run: portless trust"
5829
+ );
5830
+ }
5831
+ } else if (proxyRunning) {
5832
+ add("warn", `Generated CA file is missing: ${caPath}`);
5833
+ } else {
5834
+ add("info", "Local CA has not been generated yet.");
5835
+ }
5836
+ } else {
5837
+ add("info", "HTTPS is disabled for the current proxy state.");
5838
+ }
5839
+ let rawRoutes = [];
5840
+ try {
5841
+ rawRoutes = store.loadRoutesRaw();
5842
+ } catch (err) {
5843
+ const message = err instanceof Error ? err.message : String(err);
5844
+ add("fail", `Could not read routes: ${message}`);
5845
+ }
5846
+ const liveRoutes = rawRoutes.filter(
5847
+ (route) => route.pid === 0 || isProcessAliveForDoctor(route.pid)
5848
+ );
5849
+ const staleRoutes = rawRoutes.filter(
5850
+ (route) => route.pid !== 0 && !isProcessAliveForDoctor(route.pid)
5851
+ );
5852
+ if (rawRoutes.length === 0) {
5853
+ add("info", "No routes are registered.");
5854
+ } else if (staleRoutes.length === 0) {
5855
+ add("ok", `Routes: ${pluralize(liveRoutes.length, "active route")}.`);
5856
+ } else {
5857
+ add(
5858
+ "warn",
5859
+ `Routes: ${pluralize(liveRoutes.length, "active route")}, ${pluralize(staleRoutes.length, "stale route")}.`,
5860
+ "Run: portless prune"
5861
+ );
5862
+ }
5863
+ for (const route of staleRoutes.slice(0, 5)) {
5864
+ add("warn", `Stale route ${route.hostname} is owned by exited PID ${route.pid}.`);
5865
+ }
5866
+ if (staleRoutes.length > 5) {
5867
+ add("warn", `${staleRoutes.length - 5} additional stale routes hidden.`);
5868
+ }
5869
+ const routePortChecks = await Promise.all(
5870
+ liveRoutes.map(async (route) => {
5871
+ const validPort = isValidTcpPort(route.port);
5872
+ return {
5873
+ route,
5874
+ invalidPort: !validPort,
5875
+ listening: validPort ? await isPortListening(route.port) : false
5876
+ };
5877
+ })
5878
+ );
5879
+ for (const { route, invalidPort, listening } of routePortChecks) {
5880
+ if (invalidPort) {
5881
+ add(
5882
+ "warn",
5883
+ `Route ${route.hostname} has invalid port ${route.port}.`,
5884
+ route.pid === 0 ? "Remove or recreate the alias." : "Run: portless prune"
5885
+ );
5886
+ continue;
5887
+ }
5888
+ if (listening) continue;
5889
+ add(
5890
+ "warn",
5891
+ `Route ${route.hostname} points to port ${route.port}, but nothing is listening there.`,
5892
+ route.pid === 0 ? "Remove the alias or start that service." : "The app may still be starting."
5893
+ );
5894
+ }
5895
+ if (state.lanMode || isLanEnvEnabled()) {
5896
+ const mdns = isMdnsSupported();
5897
+ if (mdns.supported) {
5898
+ add("ok", "mDNS publishing support is available for LAN mode.");
5899
+ } else {
5900
+ add("fail", `LAN mode is enabled but mDNS publishing is unavailable: ${mdns.reason}`);
5901
+ }
5902
+ if (state.lanIp) {
5903
+ add("ok", `LAN IP is recorded: ${state.lanIp}`);
5904
+ } else {
5905
+ add("warn", "LAN mode is enabled but no LAN IP is recorded.");
5906
+ }
5907
+ } else if (liveRoutes.length > 0) {
5908
+ const managedHosts = new Set(getManagedHostnames());
5909
+ const resolutionChecks = await Promise.all(
5910
+ liveRoutes.map(async (route) => ({
5911
+ hostname: route.hostname,
5912
+ resolves: await checkHostResolution(route.hostname),
5913
+ managed: managedHosts.has(route.hostname)
5914
+ }))
5915
+ );
5916
+ const unresolved = resolutionChecks.filter((result) => !result.resolves);
5917
+ if (unresolved.length === 0) {
5918
+ add("ok", "Registered hostnames resolve through the system resolver.");
5919
+ } else {
5920
+ add(
5921
+ "warn",
5922
+ `${pluralize(unresolved.length, "hostname")} did not resolve through the system resolver.`,
5923
+ "Run: portless hosts sync"
5924
+ );
5925
+ for (const result of unresolved.slice(0, 5)) {
5926
+ const hostState = result.managed ? "present in hosts block" : "missing from hosts block";
5927
+ add("warn", `${result.hostname} is ${hostState}.`);
5928
+ }
5929
+ }
5930
+ }
5931
+ for (const finding of findings) {
5932
+ printDoctorFinding(finding);
5933
+ }
5934
+ const failures = findings.filter((finding) => finding.status === "fail").length;
5935
+ const warnings = findings.filter((finding) => finding.status === "warn").length;
5936
+ console.log("");
5937
+ if (failures > 0) {
5938
+ console.log(
5939
+ colors_default.red(`Summary: ${pluralize(failures, "failure")}, ${pluralize(warnings, "warning")}.`)
5940
+ );
5941
+ process.exit(1);
5942
+ }
5943
+ console.log(colors_default.green(`Summary: 0 failures, ${pluralize(warnings, "warning")}.`));
5944
+ }
5404
5945
  async function handleProxy(args) {
5405
5946
  if (args[1] === "stop") {
5406
5947
  let explicitPort;
@@ -5441,6 +5982,7 @@ ${colors_default.bold("Usage:")}
5441
5982
  ${colors_default.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
5442
5983
  ${colors_default.cyan("portless proxy start -p 1355")} Start on a custom port (no sudo)
5443
5984
  ${colors_default.cyan("portless proxy start --tld test")} Use .test instead of .localhost
5985
+ ${colors_default.cyan("portless proxy start --tld localhost --tld test")} Serve both TLDs
5444
5986
  ${colors_default.cyan("portless proxy start --wildcard")} Allow unregistered subdomains to fall back to parent
5445
5987
  ${colors_default.cyan("portless proxy stop")} Stop the proxy
5446
5988
 
@@ -5502,34 +6044,41 @@ ${colors_default.bold("LAN mode (--lan):")}
5502
6044
  }
5503
6045
  hasExplicitPort = true;
5504
6046
  }
5505
- let tld;
6047
+ let tlds;
5506
6048
  try {
5507
- tld = getDefaultTld();
6049
+ tlds = getDefaultTlds();
5508
6050
  } catch (err) {
5509
6051
  console.error(colors_default.red(`Error: ${err.message}`));
5510
6052
  process.exit(1);
5511
6053
  }
5512
- const tldIdx = args.indexOf("--tld");
5513
- if (tldIdx !== -1) {
5514
- const tldValue = args[tldIdx + 1];
5515
- if (!tldValue || tldValue.startsWith("-")) {
5516
- console.error(colors_default.red("Error: --tld requires a TLD value (e.g. test, localhost)."));
5517
- process.exit(1);
6054
+ const tldFlagValues = [];
6055
+ for (let i = 0; i < args.length; i += 1) {
6056
+ if (args[i] === "--tld") {
6057
+ const tldValue = args[i + 1];
6058
+ if (!tldValue || tldValue.startsWith("-")) {
6059
+ console.error(colors_default.red("Error: --tld requires a TLD value (e.g. test, localhost)."));
6060
+ process.exit(1);
6061
+ }
6062
+ tldFlagValues.push(tldValue);
6063
+ i += 1;
5518
6064
  }
5519
- tld = tldValue.trim().toLowerCase();
5520
- const tldErr = validateTld(tld);
5521
- if (tldErr) {
5522
- console.error(colors_default.red(`Error: ${tldErr}`));
6065
+ }
6066
+ if (tldFlagValues.length > 0) {
6067
+ try {
6068
+ tlds = normalizeTlds2(tldFlagValues.flatMap((value) => parseTldList(value)));
6069
+ } catch (err) {
6070
+ console.error(colors_default.red(`Error: ${err.message}`));
5523
6071
  process.exit(1);
5524
6072
  }
5525
6073
  }
6074
+ let tld = primaryTld2(tlds);
5526
6075
  const useWildcard = args.includes("--wildcard") || isWildcardEnvEnabled();
5527
6076
  const explicit = {
5528
6077
  useHttps: hasHttpsFlag || hasNoTls || customCertPath !== null || customKeyPath !== null || process.env.PORTLESS_HTTPS !== void 0,
5529
6078
  customCert: customCertPath !== null || customKeyPath !== null,
5530
6079
  lanMode: process.env.PORTLESS_LAN !== void 0,
5531
6080
  lanIp: process.env.PORTLESS_LAN_IP !== void 0,
5532
- tld: tldIdx !== -1 || process.env.PORTLESS_TLD !== void 0,
6081
+ tlds: tldFlagValues.length > 0 || process.env.PORTLESS_TLD !== void 0,
5533
6082
  useWildcard: args.includes("--wildcard") || process.env.PORTLESS_WILDCARD !== void 0
5534
6083
  };
5535
6084
  let stateDir = resolveStateDir(proxyPort);
@@ -5547,13 +6096,13 @@ ${colors_default.bold("LAN mode (--lan):")}
5547
6096
  const desiredConfig = resolveProxyConfig({
5548
6097
  persistedLanMode,
5549
6098
  explicit,
5550
- defaultTld: getDefaultTld(),
6099
+ defaultTlds: getDefaultTlds(),
5551
6100
  useHttps: wantHttps || !!(customCertPath && customKeyPath),
5552
6101
  customCertPath,
5553
6102
  customKeyPath,
5554
6103
  lanMode: isLanEnvEnabled(),
5555
6104
  lanIp: process.env.PORTLESS_LAN_IP || null,
5556
- tld,
6105
+ tlds,
5557
6106
  useWildcard
5558
6107
  });
5559
6108
  const lanMode = desiredConfig.lanMode;
@@ -5561,31 +6110,35 @@ ${colors_default.bold("LAN mode (--lan):")}
5561
6110
  customCertPath = desiredConfig.customCertPath;
5562
6111
  customKeyPath = desiredConfig.customKeyPath;
5563
6112
  tld = desiredConfig.tld;
6113
+ tlds = desiredConfig.tlds;
5564
6114
  const desiredWildcard = desiredConfig.useWildcard;
5565
6115
  let lanIp = desiredConfig.lanIpExplicit ? desiredConfig.lanIp : null;
5566
6116
  if (!hasExplicitPort && runningPort === null) {
5567
6117
  proxyPort = getDefaultPort(useHttps);
5568
6118
  stateDir = resolveStateDir(proxyPort);
5569
6119
  }
5570
- if (lanMode && tldIdx !== -1) {
5571
- const userTld = args[tldIdx + 1];
5572
- if (userTld && userTld !== "local") {
6120
+ if (lanMode && tldFlagValues.length > 0) {
6121
+ const userTlds = tldFlagValues.join(", ");
6122
+ if (userTlds !== "local") {
5573
6123
  console.warn(
5574
6124
  chalk.yellow(
5575
- `Warning: --lan forces .local TLD (mDNS requirement). Ignoring --tld ${userTld}.`
6125
+ `Warning: --lan forces .local TLD (mDNS requirement). Ignoring --tld ${userTlds}.`
5576
6126
  )
5577
6127
  );
5578
6128
  }
5579
6129
  }
5580
- const riskyReason = RISKY_TLDS.get(tld);
5581
- if (riskyReason && !lanMode) {
5582
- console.warn(colors_default.yellow(`Warning: .${tld}: ${riskyReason}`));
6130
+ for (const configuredTld of tlds) {
6131
+ const riskyReason = RISKY_TLDS.get(configuredTld);
6132
+ if (riskyReason && !lanMode) {
6133
+ console.warn(colors_default.yellow(`Warning: .${configuredTld}: ${riskyReason}`));
6134
+ }
5583
6135
  }
5584
6136
  const syncDisabled = process.env.PORTLESS_SYNC_HOSTS === "0" || process.env.PORTLESS_SYNC_HOSTS === "false";
5585
- if (tld !== DEFAULT_TLD && !lanMode && syncDisabled) {
6137
+ const nonDefaultTlds = tlds.filter((configuredTld) => configuredTld !== DEFAULT_TLD);
6138
+ if (nonDefaultTlds.length > 0 && !lanMode && syncDisabled) {
5586
6139
  console.warn(
5587
6140
  colors_default.yellow(
5588
- `Warning: .${tld} domains require ${HOSTS_DISPLAY} entries to resolve to 127.0.0.1.`
6141
+ `Warning: ${formatTldList2(nonDefaultTlds)} domains require ${HOSTS_DISPLAY} entries to resolve to 127.0.0.1.`
5589
6142
  )
5590
6143
  );
5591
6144
  console.warn(colors_default.yellow("Hosts sync is disabled. To add entries manually, run:"));
@@ -5647,6 +6200,7 @@ ${colors_default.bold("LAN mode (--lan):")}
5647
6200
  lanIp: desiredConfig.lanIpExplicit ? lanIp : null,
5648
6201
  lanIpExplicit: desiredConfig.lanIpExplicit,
5649
6202
  tld,
6203
+ tlds,
5650
6204
  useWildcard: desiredWildcard
5651
6205
  };
5652
6206
  if (!isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
@@ -5663,6 +6217,7 @@ ${colors_default.bold("LAN mode (--lan):")}
5663
6217
  lanIp: desiredConfig.lanIpExplicit ? lanIp : null,
5664
6218
  lanIpExplicit: desiredConfig.lanIpExplicit,
5665
6219
  tld,
6220
+ tlds,
5666
6221
  useWildcard: desiredWildcard,
5667
6222
  foreground: isForeground,
5668
6223
  includePort: true,
@@ -5780,13 +6335,22 @@ ${colors_default.bold("LAN mode (--lan):")}
5780
6335
  cert,
5781
6336
  key,
5782
6337
  ca,
5783
- SNICallback: createSNICallback(stateDir, cert, key, tld, ca)
6338
+ SNICallback: createSNICallback(stateDir, cert, key, tlds, ca)
5784
6339
  };
5785
6340
  }
5786
6341
  }
5787
6342
  if (isForeground) {
5788
6343
  console.log(chalk.blue.bold("\nportless proxy\n"));
5789
- startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, desiredWildcard ? false : void 0);
6344
+ startProxyServer(
6345
+ store,
6346
+ proxyPort,
6347
+ tld,
6348
+ tlds,
6349
+ tlsOptions,
6350
+ lanIp,
6351
+ desiredWildcard ? false : void 0,
6352
+ !!(customCertPath && customKeyPath)
6353
+ );
5790
6354
  return;
5791
6355
  }
5792
6356
  store.ensureDir();
@@ -5810,6 +6374,7 @@ ${colors_default.bold("LAN mode (--lan):")}
5810
6374
  lanIp: desiredConfig.lanIpExplicit ? lanIp : null,
5811
6375
  lanIpExplicit: desiredConfig.lanIpExplicit,
5812
6376
  tld,
6377
+ tlds,
5813
6378
  useWildcard: desiredWildcard,
5814
6379
  foreground: true,
5815
6380
  includePort: true,
@@ -5903,7 +6468,7 @@ async function handleDefaultSingle(cwd, scriptName, appConfig) {
5903
6468
  }
5904
6469
  const worktree = detectWorktreePrefix(cwd);
5905
6470
  const effectiveName = worktree ? `${worktree.prefix}.${baseName}` : baseName;
5906
- const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
6471
+ const { dir, port, tls: tls2, tlds, lanMode, lanIp } = await discoverState();
5907
6472
  const store = new RouteStore(dir, {
5908
6473
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
5909
6474
  });
@@ -5914,7 +6479,7 @@ async function handleDefaultSingle(cwd, scriptName, appConfig) {
5914
6479
  effectiveName,
5915
6480
  resolved,
5916
6481
  tls2,
5917
- tld,
6482
+ tlds,
5918
6483
  false,
5919
6484
  { nameSource, prefix: worktree?.prefix, prefixSource: worktree?.source },
5920
6485
  appConfig?.appPort,
@@ -5954,13 +6519,13 @@ function pipeOutput(child, prefix) {
5954
6519
  prefixStream(child.stdout, process.stdout, prefix);
5955
6520
  prefixStream(child.stderr, process.stderr, prefix);
5956
6521
  }
5957
- async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tld, exitCodes) {
6522
+ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tlds, exitCodes) {
5958
6523
  const usesPortless = app.commandArgs[0] === "portless";
5959
6524
  const pkgEnv = { ...process.env };
5960
6525
  pkgEnv.PATH = augmentedPath(pkgEnv, app.pkg.dir);
5961
6526
  let env;
5962
6527
  let store = null;
5963
- let hostname = null;
6528
+ let hostnames = [];
5964
6529
  let displayUrl;
5965
6530
  if (usesPortless) {
5966
6531
  env = pkgEnv;
@@ -5970,17 +6535,17 @@ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tld, exitCodes) {
5970
6535
  onWarning: (msg) => console.warn(colors_default.yellow(`[${app.name}] ${msg}`))
5971
6536
  });
5972
6537
  const appPort = app.appPort ?? await findFreePort();
5973
- const protocol = tls2 ? "https" : "http";
5974
- const portSuffix = tls2 && proxyPort === 443 || !tls2 && proxyPort === 80 ? "" : `:${proxyPort}`;
5975
- const url = `${protocol}://${app.name}.${tld}${portSuffix}`;
6538
+ hostnames = buildHostnames(app.name, tlds);
6539
+ const urls = formatUrls(hostnames, proxyPort, tls2);
6540
+ const url = urls[0];
5976
6541
  displayUrl = url;
5977
- hostname = parseHostname(app.name, tld);
5978
- store.addRoute(hostname, appPort, process.pid);
6542
+ addRoutes(store, hostnames, appPort, process.pid);
5979
6543
  env = {
5980
6544
  ...pkgEnv,
5981
6545
  PORT: String(appPort),
5982
6546
  HOST: "127.0.0.1",
5983
- PORTLESS_URL: url
6547
+ PORTLESS_URL: url,
6548
+ __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: formatViteAllowedHosts(tlds)
5984
6549
  };
5985
6550
  if (tls2) {
5986
6551
  const caPath = path9.join(stateDir, "ca.pem");
@@ -5992,7 +6557,7 @@ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tld, exitCodes) {
5992
6557
  const child = spawnChildProcess(app.commandArgs, env, app.pkg.dir);
5993
6558
  pipeOutput(child, chalk.cyan(`[${app.name}]`));
5994
6559
  const capturedStore = store;
5995
- const capturedHostname = hostname;
6560
+ const capturedHostnames = hostnames;
5996
6561
  child.on("exit", (code, signal) => {
5997
6562
  exitCodes.set(app.name, code);
5998
6563
  if (code !== 0 && code !== null) {
@@ -6000,14 +6565,11 @@ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tld, exitCodes) {
6000
6565
  } else if (signal) {
6001
6566
  console.error(colors_default.yellow(`[${app.name}] killed by ${signal}`));
6002
6567
  }
6003
- if (capturedStore && capturedHostname) {
6004
- try {
6005
- capturedStore.removeRoute(capturedHostname);
6006
- } catch {
6007
- }
6568
+ if (capturedStore && capturedHostnames.length > 0) {
6569
+ removeRoutes(capturedStore, capturedHostnames, process.pid);
6008
6570
  }
6009
6571
  });
6010
- const route = store && hostname ? { store, hostname } : null;
6572
+ const route = store && hostnames.length > 0 ? { store, hostnames } : null;
6011
6573
  return { child, displayUrl, route };
6012
6574
  }
6013
6575
  function spawnTaskApp(app, exitCodes) {
@@ -6118,7 +6680,7 @@ async function handleDefaultMulti(wsRoot, globalScript, extraArgs = []) {
6118
6680
  console.log(chalk.blue.bold(`
6119
6681
  portless
6120
6682
  `));
6121
- let { dir, port, tls: tls2, tld } = await discoverState();
6683
+ let { dir, port, tls: tls2, tlds } = await discoverState();
6122
6684
  if (proxiedApps.length > 0) {
6123
6685
  let multiDesired;
6124
6686
  try {
@@ -6132,9 +6694,9 @@ portless
6132
6694
  dir = ensureResult.state.dir;
6133
6695
  port = ensureResult.state.port;
6134
6696
  tls2 = ensureResult.state.tls;
6135
- tld = ensureResult.state.tld;
6697
+ tlds = ensureResult.state.tlds;
6136
6698
  } else {
6137
- ({ dir, port, tls: tls2, tld } = await discoverState());
6699
+ ({ dir, port, tls: tls2, tlds } = await discoverState());
6138
6700
  }
6139
6701
  if (tls2 && !isCATrusted(dir)) {
6140
6702
  await handleTrust();
@@ -6142,12 +6704,12 @@ portless
6142
6704
  }
6143
6705
  const useTurbo = loaded?.config.turbo !== false && hasTurboConfig(wsRoot);
6144
6706
  if (useTurbo) {
6145
- await runWithTurbo(wsRoot, dir, port, tls2, tld, scriptName, proxiedApps, taskApps, extraArgs);
6707
+ await runWithTurbo(wsRoot, dir, port, tls2, tlds, scriptName, proxiedApps, taskApps, extraArgs);
6146
6708
  } else {
6147
- await runWithDirectSpawn(dir, port, tls2, tld, proxiedApps, taskApps);
6709
+ await runWithDirectSpawn(dir, port, tls2, tlds, proxiedApps, taskApps);
6148
6710
  }
6149
6711
  }
6150
- async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName, proxiedApps, taskApps, extraArgs = []) {
6712
+ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tlds, scriptName, proxiedApps, taskApps, extraArgs = []) {
6151
6713
  const store = new RouteStore(stateDir, {
6152
6714
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
6153
6715
  });
@@ -6161,17 +6723,17 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
6161
6723
  continue;
6162
6724
  }
6163
6725
  const appPort = app.appPort ?? await findFreePort();
6164
- const protocol = tls2 ? "https" : "http";
6165
- const portSuffix = tls2 && proxyPort === 443 || !tls2 && proxyPort === 80 ? "" : `:${proxyPort}`;
6166
- const url = `${protocol}://${app.name}.${tld}${portSuffix}`;
6726
+ const hostnames = buildHostnames(app.name, tlds);
6727
+ const urls = formatUrls(hostnames, proxyPort, tls2);
6728
+ const url = urls[0];
6167
6729
  appUrls.push({ label: app.label, url });
6168
- const hostname = parseHostname(app.name, tld);
6169
- store.addRoute(hostname, appPort, process.pid);
6170
- routes.push({ hostname });
6730
+ addRoutes(store, hostnames, appPort, process.pid);
6731
+ routes.push({ hostnames });
6171
6732
  const entry = {
6172
6733
  PORT: String(appPort),
6173
6734
  HOST: "127.0.0.1",
6174
- PORTLESS_URL: url
6735
+ PORTLESS_URL: url,
6736
+ __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: formatViteAllowedHosts(tlds)
6175
6737
  };
6176
6738
  if (tls2) {
6177
6739
  const caPath = path9.join(stateDir, "ca.pem");
@@ -6214,11 +6776,8 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
6214
6776
  killTree(turboChild, "SIGKILL");
6215
6777
  }
6216
6778
  }, SIGKILL_TIMEOUT_MS).unref();
6217
- for (const { hostname } of routes) {
6218
- try {
6219
- store.removeRoute(hostname);
6220
- } catch {
6221
- }
6779
+ for (const { hostnames } of routes) {
6780
+ removeRoutes(store, hostnames, process.pid);
6222
6781
  }
6223
6782
  removeManifest();
6224
6783
  };
@@ -6232,7 +6791,7 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
6232
6791
  process.exit(exitCode);
6233
6792
  }
6234
6793
  }
6235
- async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, taskApps) {
6794
+ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tlds, proxiedApps, taskApps) {
6236
6795
  const children = [];
6237
6796
  const exitCodes = /* @__PURE__ */ new Map();
6238
6797
  const appUrls = [];
@@ -6243,7 +6802,7 @@ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, t
6243
6802
  stateDir,
6244
6803
  proxyPort,
6245
6804
  tls2,
6246
- tld,
6805
+ tlds,
6247
6806
  exitCodes
6248
6807
  );
6249
6808
  children.push(child);
@@ -6278,11 +6837,8 @@ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, t
6278
6837
  }
6279
6838
  }
6280
6839
  }, SIGKILL_TIMEOUT_MS).unref();
6281
- for (const { store, hostname } of routeEntries) {
6282
- try {
6283
- store.removeRoute(hostname);
6284
- } catch {
6285
- }
6840
+ for (const { store, hostnames } of routeEntries) {
6841
+ removeRoutes(store, hostnames, process.pid);
6286
6842
  }
6287
6843
  };
6288
6844
  process.on("SIGINT", cleanup);
@@ -6341,7 +6897,7 @@ async function handleRunMode(args, globalScript) {
6341
6897
  }
6342
6898
  const worktree = detectWorktreePrefix();
6343
6899
  const effectiveName = worktree ? `${worktree.prefix}.${baseName}` : baseName;
6344
- const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
6900
+ const { dir, port, tls: tls2, tlds, lanMode, lanIp } = await discoverState();
6345
6901
  const store = new RouteStore(dir, {
6346
6902
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
6347
6903
  });
@@ -6352,7 +6908,7 @@ async function handleRunMode(args, globalScript) {
6352
6908
  effectiveName,
6353
6909
  parsed.commandArgs,
6354
6910
  tls2,
6355
- tld,
6911
+ tlds,
6356
6912
  parsed.force,
6357
6913
  { nameSource, prefix: worktree?.prefix, prefixSource: worktree?.source },
6358
6914
  parsed.appPort,
@@ -6378,7 +6934,7 @@ async function handleNamedMode(args) {
6378
6934
  }
6379
6935
  const safeName = parsed.name.split(".").map((label) => truncateLabel(label)).join(".");
6380
6936
  parseHostname(safeName, DEFAULT_TLD);
6381
- const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
6937
+ const { dir, port, tls: tls2, tlds, lanMode, lanIp } = await discoverState();
6382
6938
  const store = new RouteStore(dir, {
6383
6939
  onWarning: (msg) => console.warn(colors_default.yellow(msg))
6384
6940
  });
@@ -6389,7 +6945,7 @@ async function handleNamedMode(args) {
6389
6945
  safeName,
6390
6946
  parsed.commandArgs,
6391
6947
  tls2,
6392
- tld,
6948
+ tlds,
6393
6949
  parsed.force,
6394
6950
  void 0,
6395
6951
  parsed.appPort,
@@ -6492,7 +7048,7 @@ async function main() {
6492
7048
  args.shift();
6493
7049
  }
6494
7050
  const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
6495
- if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean" && args[0] !== "service")) {
7051
+ if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean" && args[0] !== "doctor" && args[0] !== "service")) {
6496
7052
  const parsed = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
6497
7053
  let commandArgs = parsed.commandArgs;
6498
7054
  if (commandArgs.length === 0 && (isRunCommand || args.length === 0)) {
@@ -6540,6 +7096,10 @@ async function main() {
6540
7096
  await handleList();
6541
7097
  return;
6542
7098
  }
7099
+ if (args[0] === "doctor") {
7100
+ await handleDoctor(args);
7101
+ return;
7102
+ }
6543
7103
  if (args[0] === "get") {
6544
7104
  await handleGet(args);
6545
7105
  return;