Here's the full implementation plan:
Phase 1: Default-route fallback
Goal: When a request arrives with a non-portless Host header and exactly one app is registered, route to it automatically.
File: proxy.ts -- modify findRoute() (line 76):
ts
This means:
myapp.localhost -> normal routing
unknown.localhost -> 404 with "active apps" page (existing behavior)
abc123.ngrok-free.app -> falls back to the single registered app
- Multiple apps registered + unknown host -> 404 (requires Phase 2 alias)
Thread tldSuffix (already available as a local in createProxyServer) into the two findRoute() call sites at lines 146 and ~280 (WebSocket handler).
~15 lines changed, zero new dependencies.
Phase 2: Tunnel hostname aliasing
Goal: Map external hostnames to portless routes so multi-app tunneling works.
New: aliases.json in state dir
json
File: routes.ts -- extend RouteStore
Add methods:
addAlias(externalHostname: string, portlessHostname: string) -- writes to aliases.json with file locking
removeAlias(externalHostname: string) -- removes entry
removeAliasesForRoute(portlessHostname: string) -- removes all aliases pointing at a route
loadAliases(): Record<string, string> -- reads aliases.json
File: proxy.ts -- extend route resolution
ts
File: cli.ts -- new portless alias command
portless alias <name> <hostname> Map external hostname to a portless app
portless alias remove <hostname> Remove a hostname alias
portless alias list Show all aliases
Example: portless alias myapp abc123.ngrok-free.app
File: cli.ts -- wire aliases into proxy startup
In startProxyServer(), load and cache aliases alongside routes, watch aliases.json for changes.
Phase 3: Managed tunnel integration
Goal: portless myapp --tunnel ngrok next dev handles everything.
New file: tunnel.ts
ts
ngrok provider:
- Availability check:
ngrok version (exit code 0)
- Start:
ngrok http <proxyPort> --log=stdout --log-format=json
- URL discovery: poll
http://127.0.0.1:4040/api/tunnels (ngrok's local API)
- Stop: kill process, which closes the tunnel
cloudflare provider:
- Availability check:
cloudflared version (exit code 0)
- Start:
cloudflared tunnel --url http://localhost:<proxyPort>
- URL discovery: parse
*.trycloudflare.com URL from stderr output
- Stop: kill process
File: cli.ts -- new --tunnel flag
--tunnel <provider> Start a tunnel (ngrok, cloudflare)
New env var: PORTLESS_TUNNEL=ngrok (alternative to flag).
File: cli.ts -- runApp() changes
After registering the route and before spawning the child:
- Start the tunnel provider pointing at
proxyPort
- Wait for the tunnel URL (with timeout + error handling)
- Auto-register alias:
store.addAlias(tunnelHostname, hostname)
- Print tunnel URL alongside local URL
- Set
PORTLESS_TUNNEL_URL on child env
- On cleanup: stop tunnel + remove alias
New child process env vars:
PORTLESS_TUNNEL_URL=https://abc123.ngrok-free.app
End-user experience
$ portless myapp --tunnel ngrok next dev
portless
-- myapp.localhost (auto-resolves to 127.0.0.1)
-- Name "myapp" (from package.json)
-- Proxy is running
-- Using port 4123
-- Starting ngrok tunnel...
-> http://myapp.localhost:1355 (local)
-> https://abc123.ngrok-free.app (tunnel)
Running: PORT=4123 HOST=127.0.0.1 PORTLESS_URL=http://myapp.localhost:1355 PORTLESS_TUNNEL_URL=https://abc123.ngrok-free.app next dev
Files touched (summary)
| File | Phase | Changes |
|---|
proxy.ts | 1, 2 | findRoute() gains fallback + alias lookup |
types.ts | 2 | ProxyServerOptions gains getAliases |
routes.ts | 2 | RouteStore gains alias methods |
tunnel.ts | 3 | New file: provider abstraction + ngrok/cloudflare impls |
cli.ts | 1, 2, 3 | Wire everything up, new commands/flags, help text |
index.ts | 3 | Export tunnel.ts |
README.md | 1, 2, 3 | Document tunnel features |
skills/portless/SKILL.md | 1, 2, 3 | Agent skill updates |
| Tests | 1, 2, 3 | Unit tests for each phase |
Want me to start implementing?