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 +23 -7
- package/dist/{chunk-OZM4AEYL.js → chunk-SD2PIWJU.js} +52 -8
- package/dist/cli.js +729 -169
- package/dist/index.d.ts +17 -2
- package/dist/index.js +5 -1
- package/package.json +1 -1
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,
|
|
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,
|
|
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,
|
|
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
|
|
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>
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
|
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"}://<backend>${escapeHtml(
|
|
366
|
+
target: "${reqTls ? "https" : "http"}://<backend>${escapeHtml(primaryTldSuffix)}${reqTls ? "" : ":<port>"}",
|
|
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
|
|
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 =
|
|
712
|
+
var LOCK_TIMEOUT_MS = 15e3;
|
|
683
713
|
var LOCK_RETRY_BASE_MS = 10;
|
|
684
|
-
var LOCK_RETRY_CAP_MS =
|
|
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
|
-
|
|
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(
|
|
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,
|