How it works
tsp is a path-routing reverse proxy that sits between a single Tailscale entry
and your many local dev servers.
Architecture
One Tailscale entry terminates TLS and forwards plain HTTP to the local tsp
proxy, which routes by the first path segment to the matching dev server.
Discovery pipeline
Every --interval seconds:
- List listeners — TCP sockets in
LISTENwithin the port range, with PID (macOS/Linux vialsof+ps, Windows vianetstat+tasklist). - Classify runtime from the executable name (
node,bun,deno,python/uvicorn/gunicorn,ruby/puma,php,go run,java,dotnet,elixir,docker-proxy, …). - Slug = the nearest project-root folder name (markers:
package.json,.git,go.mod,pyproject.toml,Cargo.toml,mix.exs, …). - Resolve duplicates — within one project folder the process on the lowest
port is the main service (clean, port-free slug); any other process in the same
folder gets a
-<port>suffix so it stays reachable. A single process on several ports collapses to its lowest port. Distinct projects sharing a folder name also get a-<port>suffix to stay unique.
Routing
For /<segment>/<rest…>?<query>:
- Hit → forward to
http://127.0.0.1:<port>/<rest…>(segment stripped,Hostrewritten tolocalhost). Streaming flushes immediately; WebSocket upgrades are relayed. - Miss / empty →
404listing the registered services. - Dead backend →
502.
Cookie route-affinity
Apps assume they live at the root, so their HTML references absolute paths
(/_next/..., /api/..., HMR). When you open …/<slug>/, tsp sets a
tsp_route cookie pinning that browser tab to the project, so prefix-less requests
follow it to the right backend — and the page renders exactly like localhost.
Lifecycle
- A
RouteStoreis refreshed on a ticker. A service missing from discovery is kept forderegisterCyclesscans before removal (no flapping on restarts). - On
Ctrl-C, the server drains andtailscale serve|funnel resetruns before exit — never leaving a Funnel pointing at a dead port. - A single bounded
http.Transportkeeps connections to dev servers from accumulating.