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/README.md CHANGED
@@ -34,7 +34,7 @@ HTTPS with HTTP/2 is enabled by default. On first run, portless generates a loca
34
34
 
35
35
  The proxy auto-starts when you run an app. A random port (4000-4999) is assigned via the `PORT` environment variable. Most frameworks (Next.js, Express, Nuxt, etc.) respect this automatically. For frameworks that ignore `PORT` (Vite, VitePlus, Astro, React Router, Angular, Expo, React Native), portless auto-injects the right `--port` flag and, when needed, a matching `--host` flag.
36
36
 
37
- When auto-starting, portless reuses the configuration (port, TLS, TLD) from the most recent proxy run, so a restart or reboot does not silently revert to defaults. Explicit env vars (`PORTLESS_PORT`, `PORTLESS_HTTPS`, etc.) always take priority.
37
+ When auto-starting, portless reuses the configuration (port, TLS, TLDs) from the most recent proxy run, so a restart or reboot does not silently revert to defaults. Explicit env vars (`PORTLESS_PORT`, `PORTLESS_HTTPS`, etc.) always take priority.
38
38
 
39
39
  In non-interactive environments (no TTY, or `CI=1`), portless exits with a descriptive error instead of prompting, so task runners like turborepo and CI scripts fail early with a clear message.
40
40
 
@@ -210,6 +210,17 @@ portless myapp next dev
210
210
 
211
211
  The proxy auto-syncs `/etc/hosts` for route hostnames (including `.test`), so those domains resolve on your machine.
212
212
 
213
+ Repeat `--tld` to serve the same app names under multiple TLDs from one proxy:
214
+
215
+ ```bash
216
+ portless proxy start --tld localhost --tld test
217
+ portless myapp next dev
218
+ # -> https://myapp.localhost
219
+ # -> https://myapp.test
220
+ ```
221
+
222
+ When multiple TLDs are configured, `PORTLESS_URL` uses the first TLD. `PORTLESS_TLD` also accepts a comma separated list, e.g. `PORTLESS_TLD=localhost,test`.
223
+
213
224
  Recommended: `.test` (IANA-reserved, no collision risk). Avoid `.local` (conflicts with mDNS/Bonjour) and `.dev` (Google-owned, forces HTTPS via HSTS).
214
225
 
215
226
  ## How it works
@@ -264,7 +275,7 @@ portless service uninstall
264
275
 
265
276
  The service uses portless defaults unless install options or `PORTLESS_*` environment variables are provided: HTTPS on port 443 with `.localhost` names. `service install` accepts the proxy options you would use with `proxy start`, including `--port`, `--no-tls`, `--lan`, `--ip`, `--tld`, `--wildcard`, `--cert`, and `--key`. Use `--state-dir <path>` or `PORTLESS_STATE_DIR=<path>` to choose where service state and logs are written.
266
277
 
267
- The chosen service configuration is written into launchd, systemd, or Task Scheduler and reused after reboot. `portless service status` reports the installed port, HTTPS mode, TLD, LAN mode, wildcard mode, and state directory. macOS and Linux install a root-owned service so port 443 can bind at boot. Windows installs a Task Scheduler startup task that runs as SYSTEM. Installation and removal may require administrator privileges. `portless clean` automatically removes the service.
278
+ The chosen service configuration is written into launchd, systemd, or Task Scheduler and reused after reboot. `portless service status` reports the installed port, HTTPS mode, TLDs, LAN mode, wildcard mode, and state directory. macOS and Linux install a root-owned service so port 443 can bind at boot. Windows installs a Task Scheduler startup task that runs as SYSTEM. Installation and removal may require administrator privileges. `portless clean` automatically removes the service.
268
279
 
269
280
  ## LAN mode
270
281
 
@@ -276,7 +287,7 @@ portless proxy start --lan --ip 192.168.1.42
276
287
 
277
288
  `--lan` switches the proxy to mDNS discovery: services are advertised as `<name>.local` and reachable from any device on the same network. Portless auto-detects your LAN IP and follows Wi-Fi/IP changes automatically, but you can pin another address with `--ip <address>` or by exporting `PORTLESS_LAN_IP`. Set `PORTLESS_LAN=1` in your shell (0/1 boolean) to make LAN mode the default whenever the proxy starts.
278
289
 
279
- Portless remembers LAN mode via `proxy.lan`, so if you stop a LAN proxy and start it again, it stays in LAN mode. All proxy settings (port, TLS, TLD, LAN) are persisted and reused on auto-start unless overridden by explicit flags or env vars. Use `PORTLESS_LAN=0` for one start to switch back to `.localhost` mode. If a proxy is already running with different explicit LAN/TLS/TLD settings, portless warns and asks you to stop it first.
290
+ Portless remembers LAN mode via `proxy.lan`, so if you stop a LAN proxy and start it again, it stays in LAN mode. All proxy settings (port, TLS, TLDs, LAN) are persisted and reused on auto-start unless overridden by explicit flags or env vars. Use `PORTLESS_LAN=0` for one start to switch back to `.localhost` mode. If a proxy is already running with different explicit LAN/TLS/TLD settings, portless warns and asks you to stop it first.
280
291
 
281
292
  LAN mode depends on the system mDNS tools that portless already spawns: macOS ships with `dns-sd`, while Linux uses `avahi-publish-address` from `avahi-utils` (install via `sudo apt install avahi-utils` or your distro’s equivalent). If the command is missing or your network isn’t reachable, `portless proxy start --lan` prints the relevant error and exits.
282
293
 
@@ -348,6 +359,7 @@ portless alias <name> <port> # Register a static route (e.g. for Docker)
348
359
  portless alias <name> <port> --force # Overwrite an existing route
349
360
  portless alias --remove <name> # Remove a static route
350
361
  portless list # Show active routes
362
+ portless doctor # Check proxy, routes, DNS, and CA trust
351
363
  portless trust # Add local CA to system trust store
352
364
  portless clean # Remove state, CA trust entry, and hosts block
353
365
  portless prune # Kill orphaned dev servers from crashed sessions
@@ -385,7 +397,7 @@ portless service uninstall # Remove the startup service
385
397
  --cert <path> Use a custom TLS certificate
386
398
  --key <path> Use a custom TLS private key
387
399
  --foreground Run proxy in foreground instead of daemon
388
- --tld <tld> Use a custom TLD instead of .localhost (e.g. test)
400
+ --tld <tld> Use a custom TLD instead of .localhost; repeat for more
389
401
  --wildcard Allow unregistered subdomains to fall back to parent route
390
402
  --state-dir <path> Use a custom state directory with service install
391
403
  --script <name> Run a specific package.json script (default: dev)
@@ -406,7 +418,7 @@ PORTLESS_APP_PORT=<number> Use a fixed port for the app (same as --app-por
406
418
  PORTLESS_HTTPS=0 Disable HTTPS (same as --no-tls)
407
419
  PORTLESS_LAN=1 Enable LAN mode when set to 1 (auto-detects LAN IP)
408
420
  PORTLESS_LAN_IP=<address> Pin a specific LAN IP for LAN mode
409
- PORTLESS_TLD=<tld> Use a custom TLD (e.g. test; default: localhost)
421
+ PORTLESS_TLD=<tld>[,<tld>] Use one or more TLDs (e.g. localhost,test)
410
422
  PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
411
423
  PORTLESS_SYNC_HOSTS=0 Disable auto-sync of /etc/hosts (on by default)
412
424
  PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
@@ -417,13 +429,13 @@ PORTLESS_STATE_DIR=<path> Override the state directory
417
429
  # Injected into child processes
418
430
  PORT Ephemeral port the child should listen on
419
431
  HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
420
- PORTLESS_URL Public URL (e.g. https://myapp.localhost)
432
+ PORTLESS_URL Primary public URL (e.g. https://myapp.localhost)
421
433
  PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
422
434
  PORTLESS_NGROK_URL ngrok URL of the app (when --ngrok is active)
423
435
  NODE_EXTRA_CA_CERTS Path to the portless CA (when HTTPS is active)
424
436
  ```
425
437
 
426
- > **Reserved names:** `run`, `get`, `alias`, `hosts`, `list`, `trust`, `clean`, `prune`, `proxy`, and `service` are subcommands and cannot be used as app names directly. Use `portless run <cmd>` to infer the name from your project, or `portless --name <name> <cmd>` to force any name including reserved ones.
438
+ > **Reserved names:** `run`, `get`, `alias`, `hosts`, `list`, `doctor`, `trust`, `clean`, `prune`, `proxy`, and `service` are subcommands and cannot be used as app names directly. Use `portless run <cmd>` to infer the name from your project, or `portless --name <name> <cmd>` to force any name including reserved ones.
427
439
 
428
440
  ## Uninstall / reset
429
441
 
@@ -448,6 +460,10 @@ portless hosts clean # Clean up later
448
460
 
449
461
  Auto-syncs `/etc/hosts` for route hostnames by default (`.localhost`, custom TLDs, LAN `.local`). Set `PORTLESS_SYNC_HOSTS=0` to disable.
450
462
 
463
+ ## Troubleshooting
464
+
465
+ Run `portless doctor` to inspect local health without changing state. It checks Node.js, the state directory, proxy liveness, route entries, HTTPS CA trust, hostname resolution, and LAN mode prerequisites, then prints suggested fixes.
466
+
451
467
  ## Proxying Between Portless Apps
452
468
 
453
469
  If your frontend dev server (e.g. Vite, webpack) proxies API requests to another portless app, make sure the proxy rewrites the `Host` header. Without this, portless routes the request back to the frontend in an infinite loop.
@@ -17,6 +17,15 @@ function fixOwnership(...paths) {
17
17
  function isErrnoException(err) {
18
18
  return err instanceof Error && "code" in err && typeof err.code === "string";
19
19
  }
20
+ function isProcessAlive(pid) {
21
+ if (pid <= 0) return false;
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ } catch (err) {
26
+ return isErrnoException(err) && err.code === "EPERM";
27
+ }
28
+ }
20
29
  function escapeHtml(str) {
21
30
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
22
31
  }
@@ -56,6 +65,18 @@ function parseHostname(input, tld = "localhost") {
56
65
  }
57
66
  return hostname;
58
67
  }
68
+ function parseHostnames(input, tlds = ["localhost"]) {
69
+ const uniqueTlds = [...new Set(tlds)];
70
+ let baseInput = input.trim().replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
71
+ for (const tld of uniqueTlds) {
72
+ const suffix = `.${tld}`;
73
+ if (baseInput.endsWith(suffix)) {
74
+ baseInput = baseInput.slice(0, -suffix.length);
75
+ break;
76
+ }
77
+ }
78
+ return uniqueTlds.map((tld) => parseHostname(baseInput, tld));
79
+ }
59
80
 
60
81
  // src/proxy.ts
61
82
  import * as http from "http";
@@ -313,11 +334,13 @@ function createProxyServer(options) {
313
334
  getRoutes,
314
335
  proxyPort,
315
336
  tld = "localhost",
337
+ tlds = [tld],
316
338
  strict = true,
317
339
  onError = (msg) => console.error(msg),
318
340
  tls
319
341
  } = options;
320
- const tldSuffix = `.${tld}`;
342
+ const tldSuffixes = [...new Set(tlds.length > 0 ? tlds : [tld])].map((value) => `.${value}`);
343
+ const primaryTldSuffix = tldSuffixes[0] ?? ".localhost";
321
344
  const handleRequest = (req, res) => {
322
345
  const reqTls = isEncrypted(req);
323
346
  res.setHeader(PORTLESS_HEADER, "1");
@@ -340,7 +363,7 @@ function createProxyServer(options) {
340
363
  "Loop Detected",
341
364
  `<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: {
342
365
  "/api": {
343
- target: "${reqTls ? "https" : "http"}://&lt;backend&gt;${escapeHtml(tldSuffix)}${reqTls ? "" : ":&lt;port&gt;"}",
366
+ target: "${reqTls ? "https" : "http"}://&lt;backend&gt;${escapeHtml(primaryTldSuffix)}${reqTls ? "" : ":&lt;port&gt;"}",
344
367
  changeOrigin: true,
345
368
  },
346
369
  }</pre></div></div>`
@@ -351,7 +374,8 @@ function createProxyServer(options) {
351
374
  const route = findRoute(routes, host, strict);
352
375
  if (!route) {
353
376
  const safeHost = escapeHtml(host);
354
- const strippedHost = host.endsWith(tldSuffix) ? host.slice(0, -tldSuffix.length) : host;
377
+ const matchedSuffix = tldSuffixes.find((suffix) => host.endsWith(suffix));
378
+ const strippedHost = matchedSuffix ? host.slice(0, -matchedSuffix.length) : host;
355
379
  const safeSuggestion = escapeHtml(strippedHost);
356
380
  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, reqTls))}" 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>';
357
381
  res.writeHead(404, { "Content-Type": "text/html" });
@@ -375,6 +399,9 @@ function createProxyServer(options) {
375
399
  delete proxyReqHeaders[key];
376
400
  }
377
401
  }
402
+ if (!proxyReqHeaders.host) {
403
+ proxyReqHeaders.host = getRequestHost(req);
404
+ }
378
405
  const proxyReq = http.request(
379
406
  {
380
407
  hostname: "127.0.0.1",
@@ -460,6 +487,9 @@ function createProxyServer(options) {
460
487
  delete proxyReqHeaders[key];
461
488
  }
462
489
  }
490
+ if (!proxyReqHeaders.host) {
491
+ proxyReqHeaders.host = getRequestHost(req);
492
+ }
463
493
  const proxyReq = http.request({
464
494
  hostname: "127.0.0.1",
465
495
  port: route.port,
@@ -679,9 +709,9 @@ function checkHostResolution(hostname) {
679
709
  import * as fs3 from "fs";
680
710
  import * as path2 from "path";
681
711
  var STALE_LOCK_THRESHOLD_MS = 1e4;
682
- var LOCK_TIMEOUT_MS = 5e3;
712
+ var LOCK_TIMEOUT_MS = 15e3;
683
713
  var LOCK_RETRY_BASE_MS = 10;
684
- var LOCK_RETRY_CAP_MS = 500;
714
+ var LOCK_RETRY_CAP_MS = 100;
685
715
  var FILE_MODE = 420;
686
716
  var DIR_MODE = 493;
687
717
  function isValidRoute(value) {
@@ -869,13 +899,17 @@ var RouteStore = class _RouteStore {
869
899
  try {
870
900
  parsed = JSON.parse(raw);
871
901
  } catch {
902
+ this.onWarning?.(`Corrupted routes file (invalid JSON): ${this.routesPath}`);
872
903
  return [];
873
904
  }
874
905
  if (!Array.isArray(parsed)) {
906
+ this.onWarning?.(`Corrupted routes file (expected array): ${this.routesPath}`);
875
907
  return [];
876
908
  }
877
909
  return parsed.filter(isValidRoute);
878
- } catch {
910
+ } catch (err) {
911
+ const message = err instanceof Error ? err.message : String(err);
912
+ this.onWarning?.(`Could not read routes file: ${message}`);
879
913
  return [];
880
914
  }
881
915
  }
@@ -947,13 +981,21 @@ var RouteStore = class _RouteStore {
947
981
  this.releaseLock();
948
982
  }
949
983
  }
950
- removeRoute(hostname) {
984
+ /**
985
+ * Remove a route by hostname. When `ownerPid` is provided, the entry is
986
+ * only removed while it is still owned by that pid. Exit cleanups must
987
+ * pass their own pid: after a `--force` takeover the killed process would
988
+ * otherwise deregister the route the new owner just registered.
989
+ */
990
+ removeRoute(hostname, ownerPid) {
951
991
  this.ensureDir();
952
992
  if (!this.acquireLock()) {
953
993
  throw new Error("Failed to acquire route lock");
954
994
  }
955
995
  try {
956
- const routes = this.loadRoutes(true).filter((r) => r.hostname !== hostname);
996
+ const routes = this.loadRoutes(true).filter(
997
+ (r) => r.hostname !== hostname || ownerPid !== void 0 && r.pid !== ownerPid
998
+ );
957
999
  this.saveRoutes(routes);
958
1000
  } finally {
959
1001
  this.releaseLock();
@@ -964,9 +1006,11 @@ var RouteStore = class _RouteStore {
964
1006
  export {
965
1007
  fixOwnership,
966
1008
  isErrnoException,
1009
+ isProcessAlive,
967
1010
  escapeHtml,
968
1011
  formatUrl,
969
1012
  parseHostname,
1013
+ parseHostnames,
970
1014
  PORTLESS_HEADER,
971
1015
  createProxyServer,
972
1016
  createHttpRedirectServer,