portshift
v0.0.1
Published
Give a stack of dev servers the first block of ports that is free, the same offsets in every block
Maintainers
Readme
portshift
Gives a stack of dev servers the first block of ports that is free, with every server at the same offset in every block: the app on 3001, else 4001, else 5001. Several checkouts of one repository — git worktrees, parallel agent workspaces — then run their whole stacks side by side, and each app still finds the others, because every URL derived from the ports moves with the block. An agent in one checkout starts its servers, waits for them and tests them without ever being blocked by another checkout's.
Install
npm install --save-dev portshiftAdd a .portshift.json at the root of the stack (see Configuration), and run the dev servers through it:
{
"scripts": {
"dev": "portshift run -- turbo dev"
}
}Each server reads its own port from the environment, keeping the first block's port as the default for a script run on its own: next dev -p ${APP_PORT:-3001}. A URL one app builds for another is built from the same variables.
It needs Node 22 or later, on macOS or Linux.
Commands
| Command | What it does |
| --- | --- |
| portshift run -- <command…> | Picks a block, exports its ports to the command as environment variables, runs it, and answers with its exit code. When this directory's stack already runs, it joins that block instead of taking another, so a Stripe listener or an e2e run started beside the dev servers reaches the same stack. Every run leases the block for itself, joining or not, so the block stays held while any process of the directory runs, whichever started first. It prints the block, and the blocks it passed over and why, on stderr. A signal it receives (SIGINT, SIGTERM, SIGHUP) goes on to the command, and the block is given back once the command has exited. |
| portshift port <name> [--wait] [--timeout <seconds>] | The port <name> (APP_PORT) has in this directory's running stack, on stdout. Without --wait it fails at once when no stack runs; with it, it waits — two minutes by default — for the stack to take its block and for something to listen on the port. |
| portshift status [--json] | The block this directory's stack runs on, and every running stack's lease: block, pid, directory and command. |
For an agent
npm run dev & # Through portshift run
port=$(npx portshift port APP_PORT --wait) # Up, and where
curl -s "http://localhost:$port/"
npx portshift status # Each process on the block, with its pid and command- Start servers through
portshift run. A script that runs a server on its own takes the first block's port whoever holds it. - Wait without getting in the way.
port --waitasks by connecting to the port, never by binding it, so it cannot take the port from the server starting on it. - Never assume the first block. Ask
portshift portfor a port, rather than writing 3001. - When this checkout's stack already runs, use it. A second
portshift runjoins its block, where the first one's servers already hold the ports. - Stop what you started.
statuslists each process on the block with its pid, its command's pid and its command:kill <pid>of the one running the dev servers stops them, or its command's pid whenportshift runitself is gone. The block is free once every process of the directory has ended.
Picking a block
A block is taken when every one of its ports is free and no other running stack holds it. Every port must be free even when only some of the servers will start, so the URLs each app derives for the others always point at its own stack.
- Free means nothing listens on it at
0.0.0.0,127.0.0.1,::or::1. No single bind sees every listener: on macOS a server on127.0.0.1leaves0.0.0.0bindable. AirPlay Receiver holds 5000 and 7000 on macOS, so withWEB_PORTat offset 0 those blocks are passed over. - Refused by browsers means one of its ports is on the Fetch standard's list of bad ports, which Chromium, Firefox, Safari and Node's
fetchrefuse to open: a server there starts, and nothing reaches it. WithWEB_PORTat offset 0 that is 6000, X11's, so the 60xx block is passed over. - Leased means another
portshift runholds it. A dev server takes seconds to bind its port, so a probe alone would hand one block to two workspaces started together; the lease holds the block from the moment it is picked until the process that took it ends. Leases live in$TMPDIR/portshift-<uid>/, one file per process, in a directory only its user may write in:portshiftrefuses one that is a symlink, another user's or open to others, since anyone who could write there could plant a symlink where a lease is written and have it overwrite a file of yours. Users therefore do not see each other's leases, and a block another user's servers have bound is passed over as in use. A lease is dropped, the next time anyportshiftreads them, once its process and the command it started are both gone: aportshift runkilled outright cannot stop its command, which keeps running and binds the block's ports later, so the lease records the command's pid and lasts while either lives. Picking runs under a lock: port 2999 on127.0.0.1, held for the milliseconds of the pick. The system frees a port the moment its process ends, however it ends, so no lock is left behind for another process to judge abandoned and break; with a lock file, two processes breaking one at once could both pick the same block. A program other than portshift listening on 2999 makesrungive up after ten seconds, saying so. A lease whose pid an unrelated process has since reused looks alive, and only costs a block until that process ends.
Configuration
A .portshift.json at the root of the stack, found from any directory below it. The directory it sits in is the stack's identity: one lease per directory.
{
"blocks": { "first": 3000, "step": 1000, "last": 29000 },
"ports": { "WEB_PORT": 0, "APP_PORT": 1, "API_PORT": 10, "E2E_PORT": 99 }
}blocks gives the base ports tried, in order: first, first + step, up to last. ports maps each environment variable to its offset in a block, from 0 to below step, no two alike. Every setting is required, and one out of range is an error.
Keep last below the ports the system hands out to outgoing connections — from 32768 on Linux, from 49152 on macOS — where a port can be taken at any moment. The example stops at 29000 for that reason: 26 blocks once 60xx is passed over, 24 on macOS.
Developing
pnpm --filter portshift test:coverage # 100% coverage
pnpm --filter portshift check-types
pnpm --filter portshift build # dist/cli.js, what the package shipsThe source runs as TypeScript on Node 24, which strips the types itself. Node does not do that for a file inside node_modules, so the package ships it built (prepack).
