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.

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

CommandWhat it does
run PORT … -- CMDStart a broker on PORT (unless --attach) and launch CMD as N workers. Ctrl-C stops everything it owns.
serve PORTRun only the broker, for launchers to attach to.
intercept PORT … -- CMDShorthand for run PORT --attach --intercept -- CMD.
status PORTPrint a snapshot of the broker’s workers and counters.

Flags

FlagDefaultDescription
--workers N1Number of workers, 1–128.
--interceptoffUse native interception for hardcoded-port apps.
--attachoffJoin an existing broker instead of creating one.
--startup-timeout S10Seconds each worker has to become ready, 1–3600.
--watchoffstatus: refresh every second.
--jsonoffstatus: machine-readable output.

Environment

VariableSet byMeaning
PORTPortshareThe worker’s private port (configurable-port mode only).
HOSTPortshareAlways 127.0.0.1 (configurable-port mode only).
PORTSHARE_WORKERPortshareWorker index, starting at 1.
PORTSHARE_SOCKETYouCustom 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:

No interception handshakeA shell/shebang wrapper may have stripped DYLD_*, or the executable blocks injection. Launch the runtime directly, or use configurable-port mode.
Helper loaded, no matching listenerCheck the app’s hardcoded port and bind address. IPv6-only binds are unsupported.
Configured port not readyThe app ignored its assigned port. Read PORT or use {port} in its arguments.
Process launch / early exitThe command failed to start or exited before it listened. The group stops and siblings are cleaned up.

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.

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.