๐Ÿ”€

How Portless Works

myapp.localhost instead of localhost:3000 โ€” a proxy that replaces port numbers with names

Local development means port hell: localhost:3000, localhost:3001, localhost:5173... You can't remember which is which, and telling an AI agent 'connect to port 3000' breaks the next time it changes.

Portless solves this fundamentally. Run portless myapp next dev and access it at http://myapp.localhost.

Core Architecture: Name โ†’ Port Mapping Proxy

Browser                    Portless Proxy (443)           Dev Server
myapp.localhost โ”€โ”€โ”€โ”€โ”€โ”€โ†’  routes.json lookup โ”€โ”€โ”€โ”€โ”€โ”€โ†’  localhost:4231
api.myapp.localhost โ”€โ”€โ†’  subdomain match   โ”€โ”€โ”€โ”€โ”€โ”€โ†’  localhost:4582

Execution Flow: portless myapp next dev

  1. Proxy daemon check โ€” reads ~/.portless/proxy.pid. If not running, auto-starts as detached child process. Not a system service โ€” starts on first use.

  2. Port assignment โ€” random port in 4000โ€“4999 range. Tests with net.createServer().listen().

  3. Framework detection + port injection

  • Next.js, Express, Remix โ†’ honor PORT env var natively

  • Vite, Astro, Angular โ†’ don't honor PORT. CLI flags --port, --host auto-injected.

  1. Route registration โ€” writes { hostname: 'myapp', port: 4231, pid: 12345 } to routes.json

  2. Child process spawn โ€” runs with PORT=4231. Route auto-removed on exit.

Proxy Internals

Peeks first byte of TCP socket to branch TLS vs plaintext HTTP on a single port (443).

Routing: exact hostname match first, then subdomain match. Loop detection via x-portless-hops header (508 at 5+).

Why .localhost Works

.localhost is RFC 2606 reserved. Chrome/Firefox/Edge resolve *.localhost to 127.0.0.1 without /etc/hosts. Safari needs hosts file sync (portless handles this).

Auto HTTPS

  1. Self-signed CA (EC, 10-year validity)
  2. SNI callback generates per-hostname certs dynamically
  3. portless trust installs CA in system keychain
  4. NODE_EXTRA_CA_CERTS injected to child processes

File-Based State

routes.json re-read on every request (no cache). Directory-creation mutex for locking. Stale routes auto-GC'd by PID liveness check.

Worktree Support (v0.5.2+)

Detects git worktree branch, prepends to hostname: feat-auth.myapp.localhost

How It Works

1

CLI checks proxy daemon โ†’ auto-starts as detached process if absent (port 443)

2

Assign free port in 4000โ€“4999 + inject port per framework (PORT env or --port flag)

3

Register { hostname, port, pid } in routes.json โ†’ proxy reads this file on every request for routing

4

TCP first-byte peek (0x16 = TLS) branches HTTPS/H2 vs plain HTTP โ€” single port handles both

5

SNI callback dynamically generates per-hostname certs + caching. Self-signed CA registered in system trust store

6

Auto-remove from routes.json on child exit + zombie route GC via PID liveness check

Pros

  • No need to remember port numbers โ€” name-based URLs
  • Auto HTTPS + HTTP/2 โ€” cert generation and install in one step
  • Framework-agnostic โ€” supports Next.js, Vite, Express, Nuxt and more
  • Not always-on โ€” auto-starts on first use, no system service registration
  • Worktree + subdomain support for branch-independent access

Cons

  • Port 443 requires sudo on macOS/Linux
  • Safari doesn't auto-resolve *.localhost โ†’ /etc/hosts sync needed
  • routes.json read on every request โ€” possible I/O overhead under extreme request rates
  • Additional configuration needed inside Docker containers (force PID)
  • Requires Node.js 20+, no Windows support

Use Cases

Running multiple services in monorepo simultaneously (frontend.localhost, api.localhost, admin.localhost) Directing AI agents to dev servers via URL names instead of port numbers Local testing of HTTPS-required APIs (OAuth callbacks, etc.) Auto-assign independent URLs per git worktree (feat-auth.myapp.localhost)