portless 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -323,6 +323,20 @@ Set `PORTLESS_TAILSCALE=1` in your shell profile or `.env` to share every app by
323
323
 
324
324
  Requires the Tailscale CLI to be installed and connected (`tailscale up`), with Tailscale HTTPS certificates enabled.
325
325
 
326
+ ## ngrok sharing
327
+
328
+ Expose your dev server to the public internet with [ngrok](https://ngrok.com):
329
+
330
+ ```bash
331
+ portless myapp --ngrok next dev
332
+ # -> https://myapp.localhost (local)
333
+ # -> https://abc123.ngrok.app (public)
334
+ ```
335
+
336
+ Set `PORTLESS_NGROK=1` in your shell profile or `.env` to enable ngrok by default when portless runs an app. `portless list` shows both local and ngrok URLs. The ngrok tunnel is cleaned up automatically when the app exits.
337
+
338
+ Requires the ngrok CLI to be installed and authenticated. If ngrok reports an authentication error, run `ngrok config add-authtoken <token>` and try again.
339
+
326
340
  ## Commands
327
341
 
328
342
  ```bash
@@ -334,6 +348,7 @@ portless alias <name> <port> # Register a static route (e.g. for Docker)
334
348
  portless alias <name> <port> --force # Overwrite an existing route
335
349
  portless alias --remove <name> # Remove a static route
336
350
  portless list # Show active routes
351
+ portless doctor # Check proxy, routes, DNS, and CA trust
337
352
  portless trust # Add local CA to system trust store
338
353
  portless clean # Remove state, CA trust entry, and hosts block
339
354
  portless prune # Kill orphaned dev servers from crashed sessions
@@ -378,6 +393,7 @@ portless service uninstall # Remove the startup service
378
393
  --app-port <number> Use a fixed port for the app (skip auto-assignment)
379
394
  --tailscale Share the app on your Tailscale network (tailnet)
380
395
  --funnel Share the app publicly via Tailscale Funnel
396
+ --ngrok Share the app publicly via ngrok
381
397
  --force Kill the existing process and take over its route
382
398
  --name <name> Use <name> as the app name
383
399
  ```
@@ -396,6 +412,7 @@ PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to p
396
412
  PORTLESS_SYNC_HOSTS=0 Disable auto-sync of /etc/hosts (on by default)
397
413
  PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
398
414
  PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
415
+ PORTLESS_NGROK=1 Share apps publicly via ngrok (same as --ngrok)
399
416
  PORTLESS_STATE_DIR=<path> Override the state directory
400
417
 
401
418
  # Injected into child processes
@@ -403,10 +420,11 @@ PORT Ephemeral port the child should listen on
403
420
  HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
404
421
  PORTLESS_URL Public URL (e.g. https://myapp.localhost)
405
422
  PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
423
+ PORTLESS_NGROK_URL ngrok URL of the app (when --ngrok is active)
406
424
  NODE_EXTRA_CA_CERTS Path to the portless CA (when HTTPS is active)
407
425
  ```
408
426
 
409
- > **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.
427
+ > **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.
410
428
 
411
429
  ## Uninstall / reset
412
430
 
@@ -431,6 +449,10 @@ portless hosts clean # Clean up later
431
449
 
432
450
  Auto-syncs `/etc/hosts` for route hostnames by default (`.localhost`, custom TLDs, LAN `.local`). Set `PORTLESS_SYNC_HOSTS=0` to disable.
433
451
 
452
+ ## Troubleshooting
453
+
454
+ 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.
455
+
434
456
  ## Proxying Between Portless Apps
435
457
 
436
458
  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.
@@ -486,3 +508,4 @@ pnpm format # Format all files with Prettier
486
508
  - Node.js 24+
487
509
  - macOS, Linux, or Windows
488
510
  - Tailscale CLI (optional, for `--tailscale` and `--funnel`)
511
+ - ngrok CLI (optional, for `--ngrok`)
@@ -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
  }
@@ -375,6 +384,9 @@ function createProxyServer(options) {
375
384
  delete proxyReqHeaders[key];
376
385
  }
377
386
  }
387
+ if (!proxyReqHeaders.host) {
388
+ proxyReqHeaders.host = getRequestHost(req);
389
+ }
378
390
  const proxyReq = http.request(
379
391
  {
380
392
  hostname: "127.0.0.1",
@@ -460,6 +472,9 @@ function createProxyServer(options) {
460
472
  delete proxyReqHeaders[key];
461
473
  }
462
474
  }
475
+ if (!proxyReqHeaders.host) {
476
+ proxyReqHeaders.host = getRequestHost(req);
477
+ }
463
478
  const proxyReq = http.request({
464
479
  hostname: "127.0.0.1",
465
480
  port: route.port,
@@ -869,13 +884,17 @@ var RouteStore = class _RouteStore {
869
884
  try {
870
885
  parsed = JSON.parse(raw);
871
886
  } catch {
887
+ this.onWarning?.(`Corrupted routes file (invalid JSON): ${this.routesPath}`);
872
888
  return [];
873
889
  }
874
890
  if (!Array.isArray(parsed)) {
891
+ this.onWarning?.(`Corrupted routes file (expected array): ${this.routesPath}`);
875
892
  return [];
876
893
  }
877
894
  return parsed.filter(isValidRoute);
878
- } catch {
895
+ } catch (err) {
896
+ const message = err instanceof Error ? err.message : String(err);
897
+ this.onWarning?.(`Could not read routes file: ${message}`);
879
898
  return [];
880
899
  }
881
900
  }
@@ -920,22 +939,48 @@ var RouteStore = class _RouteStore {
920
939
  const routes = this.loadRoutes(true);
921
940
  const route = routes.find((r) => r.hostname === hostname);
922
941
  if (!route) return;
923
- if (fields.tailscaleUrl !== void 0) route.tailscaleUrl = fields.tailscaleUrl;
924
- if (fields.tailscaleHttpsPort !== void 0)
925
- route.tailscaleHttpsPort = fields.tailscaleHttpsPort;
926
- if (fields.tailscaleFunnel !== void 0) route.tailscaleFunnel = fields.tailscaleFunnel;
942
+ if ("tailscaleUrl" in fields) {
943
+ if (fields.tailscaleUrl === null) delete route.tailscaleUrl;
944
+ else if (fields.tailscaleUrl !== void 0) route.tailscaleUrl = fields.tailscaleUrl;
945
+ }
946
+ if ("tailscaleHttpsPort" in fields) {
947
+ if (fields.tailscaleHttpsPort === null) delete route.tailscaleHttpsPort;
948
+ else if (fields.tailscaleHttpsPort !== void 0)
949
+ route.tailscaleHttpsPort = fields.tailscaleHttpsPort;
950
+ }
951
+ if ("tailscaleFunnel" in fields) {
952
+ if (fields.tailscaleFunnel === null) delete route.tailscaleFunnel;
953
+ else if (fields.tailscaleFunnel !== void 0)
954
+ route.tailscaleFunnel = fields.tailscaleFunnel;
955
+ }
956
+ if ("ngrokUrl" in fields) {
957
+ if (fields.ngrokUrl === null) delete route.ngrokUrl;
958
+ else if (fields.ngrokUrl !== void 0) route.ngrokUrl = fields.ngrokUrl;
959
+ }
960
+ if ("ngrokPid" in fields) {
961
+ if (fields.ngrokPid === null) delete route.ngrokPid;
962
+ else if (fields.ngrokPid !== void 0) route.ngrokPid = fields.ngrokPid;
963
+ }
927
964
  this.saveRoutes(routes);
928
965
  } finally {
929
966
  this.releaseLock();
930
967
  }
931
968
  }
932
- removeRoute(hostname) {
969
+ /**
970
+ * Remove a route by hostname. When `ownerPid` is provided, the entry is
971
+ * only removed while it is still owned by that pid. Exit cleanups must
972
+ * pass their own pid: after a `--force` takeover the killed process would
973
+ * otherwise deregister the route the new owner just registered.
974
+ */
975
+ removeRoute(hostname, ownerPid) {
933
976
  this.ensureDir();
934
977
  if (!this.acquireLock()) {
935
978
  throw new Error("Failed to acquire route lock");
936
979
  }
937
980
  try {
938
- const routes = this.loadRoutes(true).filter((r) => r.hostname !== hostname);
981
+ const routes = this.loadRoutes(true).filter(
982
+ (r) => r.hostname !== hostname || ownerPid !== void 0 && r.pid !== ownerPid
983
+ );
939
984
  this.saveRoutes(routes);
940
985
  } finally {
941
986
  this.releaseLock();
@@ -946,6 +991,7 @@ var RouteStore = class _RouteStore {
946
991
  export {
947
992
  fixOwnership,
948
993
  isErrnoException,
994
+ isProcessAlive,
949
995
  escapeHtml,
950
996
  formatUrl,
951
997
  parseHostname,