Documentation
Portshare is a small macOS development TCP load balancer. It runs several app workers behind one public port, with optional native interception for apps that hardcode their port.
Want to see it before installing? The interactive demo simulates every command on this page.
Install
Portshare builds from source. You’ll need macOS, Rust/Cargo and Clang (the Xcode command-line tools).
$ git clone https://github.com/babsonmatt/portshare.git
$ cd portshare
$ cargo fetch --locked
$ make build
The native helper is built automatically by build.rs next to the Cargo executable. Keep portshare and libportshare.dylib together if you copy them elsewhere. The root ./portshare is a convenience symlink to target/release/portshare.
Quick start
The repo ships with examples/server.ts, a Bun server that deliberately hardcodes port 3030. Start two copies of it:
$ ./portshare run 3030 --workers 2 --intercept -- bun examples/server.ts
portshare ready on 127.0.0.1:3030
control /tmp/portshare-you-3030.sock
demo worker ready: 1, pid 48211, listening on 3030
joined 127.0.0.1:51842 (1 workers)
worker 1 ready: pid 48211, 3030 -> 127.0.0.1:51842 (interception confirmed)
demo worker ready: 2, pid 48212, listening on 3030
joined 127.0.0.1:55107 (2 workers)
worker 2 ready: pid 48212, 3030 -> 127.0.0.1:55107 (interception confirmed)
portshare: all 2 workers ready on http://127.0.0.1:3030 (last ready: 2)
Inspect: portshare status 3030 --watch
Both workers report port 3030; the helper gave each a separate private loopback listener. Now hit it repeatedly and watch the worker IDs and PIDs alternate:
$ curl http://127.0.0.1:3030/
The demo closes each HTTP connection so the rotation is visible. Ctrl-C stops the managed workers and the broker that command owns. If a worker exits unexpectedly, the whole group stops with an error — automatic restart isn’t implemented.
How it works
On Linux you can run several copies of a server with SO_REUSEPORT and let the kernel spread connections across them. macOS doesn’t load-balance listeners that way, so Portshare does it in userspace.
A broker listens on 127.0.0.1:PORT plus a Unix control socket. Each worker runs on its own private loopback port and registers with the broker once it’s actually listening. Every new TCP connection to the public port goes to the next registered worker, round-robin.
- Counts are TCP connections, not HTTP requests. Keep-alive, WebSockets and SSE stay on their selected worker for the connection’s lifetime.
- Forwarding uses two bounded 64 KiB buffers per connection and preserves independent half-close.
- The proxy doesn’t parse or capture message bodies, and includes no web server, browser UI or JavaScript runtime.
Apps with configurable ports
If your app reads PORT, you don’t need interception:
$ ./portshare run 3000 --workers 4 -- bun server.ts
Each app receives its own PORT, HOST=127.0.0.1 and a PORTSHARE_WORKER index. For apps that take the port as a CLI argument, use the {port} placeholder:
$ ./portshare run 3000 --workers 2 -- python3 -m http.server '{port}' --bind 127.0.0.1
run creates and owns its broker by default. A conflicting listener or control-socket path produces an error — it never silently takes over an existing session or deletes a pre-existing socket or file.
Apps with hardcoded ports
Add --intercept and Portshare loads libportshare.dylib into each worker via DYLD_INSERT_LIBRARIES. The helper interposes bind, listen, address lookups and close, moving the app’s listener for the public port onto a private one. Interception mode does not modify the app’s PORT value.
$ ./portshare run 3030 --workers 2 --intercept -- bun examples/server.ts
Interception is experimental. Launch the runtime directly (bun app.ts, node app.js), not through a shell script or shebang wrapper — those can strip DYLD_* variables. Protected executables may block injection entirely.
Readiness requires the helper to register an actual listener with the broker. Each worker gets a random correlation token, and a helper-load handshake is tracked separately from listener registration. A successful spawn or an app’s own log line isn’t enough.
Separate broker and launchers
Run the broker on its own, then attach worker groups from other terminals — even different runtimes behind the same port:
$ ./portshare serve 3000
# in another terminal
$ ./portshare run 3000 --attach --workers 2 -- bun server.ts
# or, for a hardcoded-port app
$ ./portshare run 3000 --attach --workers 2 --intercept -- bun server.ts
intercept 3000 -- COMMAND is shorthand for run 3000 --attach --intercept -- COMMAND. Stopping an attached launcher stops its workers but leaves the broker running. Stopping the broker tells every attached launcher to stop its worker group.
Status view
$ ./portshare status 3030 --watch
Portshare · 127.0.0.1:3030 · 2 registered workers ID PID PORT STATE ACTIVE CONNECTIONS TO APP TO CLIENT ERRORS 1 48211 51842 ready 0 14 1.1 KiB 4.6 KiB 0 2 48212 55107 ready 1 14 1.1 KiB 4.6 KiB 0 TCP connections, not HTTP requests. Byte counts include open streams. Connections rejected with no workers: 0 Refreshing every second. Ctrl-C closes this view; workers keep running.
The live table shows each registered or recent worker’s ID, PID, private port, state (ready, draining, left), active TCP connections, accepted connection count, bytes forwarded in each direction, and I/O errors. Byte counters update while streams remain open. Errors can include normal client-aborted streams as well as backend failures.
It’s an observer: Ctrl-C closes the view and leaves workers running. Use status 3030 for one snapshot or status 3030 --json for machine-readable output. With redirected output, --watch prints plain snapshots without cursor-control sequences.
Commands
| Command | What it does |
|---|---|
run PORT … -- CMD | Start a broker on PORT (unless --attach) and launch CMD as N workers. Ctrl-C stops everything it owns. |
serve PORT | Run only the broker, for launchers to attach to. |
intercept PORT … -- CMD | Shorthand for run PORT --attach --intercept -- CMD. |
status PORT | Print a snapshot of the broker’s workers and counters. |
Flags
| Flag | Default | Description |
|---|---|---|
--workers N | 1 | Number of workers, 1–128. |
--intercept | off | Use native interception for hardcoded-port apps. |
--attach | off | Join an existing broker instead of creating one. |
--startup-timeout S | 10 | Seconds each worker has to become ready, 1–3600. |
--watch | off | status: refresh every second. |
--json | off | status: machine-readable output. |
Environment
| Variable | Set by | Meaning |
|---|---|---|
PORT | Portshare | The worker’s private port (configurable-port mode only). |
HOST | Portshare | Always 127.0.0.1 (configurable-port mode only). |
PORTSHARE_WORKER | Portshare | Worker index, starting at 1. |
PORTSHARE_SOCKET | You | Custom control-socket path. Defaults to /tmp/portshare-USER-PORT.sock; set it consistently, including in the status terminal. |
Startup diagnostics
Each worker must become ready within the startup timeout. When one doesn’t, the CLI tells you which of these happened, stops the group and cleans up its siblings:
DYLD_*, or the executable blocks injection. Launch the runtime directly, or use configurable-port mode.PORT or use {port} in its arguments.Limits
Portshare is for local development. Direct executable launches worked for the runtime builds it was tested with; no security settings or installed executables are modified.
- The helper supports local IPv4 TCP and the tested dual-stack wildcard path. IPv6-only, explicit IPv6 loopback, other bind addresses, UDP, descriptor duplication/passing, fork without exec, and raw syscall paths aren’t fully supported.
- It doesn’t rewrite outbound connections or emulate a network namespace.
- Balancing is connection-level round-robin — no user/session affinity, active health checks, retries, connection migration, message inspection, or graceful draining on shutdown.
- Configurable-port mode keeps a small reserve/release-to-bind race.
- SIGKILL can bypass process-group cleanup and leave stale control files.
- The status view keeps bounded departed-worker history; it isn’t a persistent audit log.
Build & test
$ make build # release build + ./portshare symlink
$ make check # fmt, clippy, clang analyzer
$ make test # CLI + runtime compatibility suites
Runtime tests additionally need Bun, Node, Python, Go and permission to bind localhost sockets; the compatibility matrix requires port 39073 to be free. Reports land in work/test-reports/. Coverage includes multi-worker startup, hardcoded-port confirmation, half-close, live streaming counters, keep-alive affinity, binary echo, slow readers, startup failures, shell-wrapper diagnostics and child cleanup.