ports-manager
v1.1.0
Published
Run paired frontend/backend projects on free ports, with the frontend automatically wired to wherever the backend landed and CORS handled for you.
Maintainers
Readme
🚦 Ports Manager
Why Ports Manager?
Running a second project when 3000 and 5000 are already busy usually means editing
vite.config.ts, editing .env, and adding a new origin to your CORS allowlist — every
time. Ports Manager does that for you, for the duration of the run, without touching your
source files.
cd my-project
npx ports-managerRoot: C:\work\my-project
Backend: express / npm run dev / port 5001
Frontend: vite / npm run dev / port 5175
Wiring: vite dev proxy retargeted to backend 5001
CORS: not needed (same origin)
Open: http://localhost:5175Start the same project again — or a different one — and the second run steps aside:
Backend: express / npm run dev / port 3000 (5001 busy)
Frontend: vite / npm run dev / port 3001 (5175 busy)
Wiring: vite dev proxy retargeted to backend 3000
Open: http://localhost:3001What it does:
- 🔎 Detects your
frontend+backendorclient+server - 📦 Checks the dev binaries exist before starting, and installs them if they don't
- 🎯 Prefers each project's own port, falls back only when it is taken
- 🔗 Rewrites the frontend's dev proxy so
/apireaches the relocated backend - 🛡️ Sets the CORS origin variables your backend already reads
- 🧹 Stops both process trees cleanly on
Ctrl+C
No application source file is modified. The only file written is a temporary Vite config
next to your own, named per process and deleted when the run ends. If you want it out of
version control entirely, add this to .gitignore:
.ports-manager.*.vite.config.mjsInstall
npm install --global ports-managerOr run it without installing:
npx ports-managerQuick start
Your project should look like one of these:
my-project/
├── frontend/ # or client/
│ └── package.json
└── backend/ # or server/
└── package.jsonRun Ports Manager from my-project, not from inside either child. It prefers
npm run dev and falls back to npm start.
How the wiring works
Moving the backend is the easy half. The half that breaks apps is everything that still points at the old port. Ports Manager picks the best available strategy:
| Frontend | Strategy | Result |
|---|---|---|
| Vite with a server.proxy | Generates a config extending yours with the proxy target repointed | Same origin, native HMR, no extra port |
| CRA, Next.js, generic | Same-origin reverse proxy in front of both services | One URL serves the app and /api |
| No proxy anywhere | Sets the API env var your source actually references | Cross-origin, CORS variables injected |
The proxy rewrite is deliberately narrow. Given this config:
proxy: {
'/api': { target: 'http://localhost:5001' }, // your backend
'/qc-api': { target: 'http://localhost:5000' } // some other service
}only /api is repointed, because only it targets the backend's own port. /qc-api is
left alone, and both decisions are printed at startup.
CORS
When the browser talks to a single origin — the Vite proxy or the same-origin proxy — there is no CORS to fix, and Ports Manager says so rather than pretending to act.
When requests really are cross-origin, it scans your backend for the origin variables it
already reads (CORS_ORIGIN, ALLOWED_ORIGINS, CLIENT_URL, FRONTEND_URL, and
similar) and sets those to the frontend's origin for the run. Only names your code
references are set. An existing value in .env is extended, not replaced:
CORS: injecting CORS_ORIGIN=https://app.example.com,http://localhost:3001If your backend reads no recognizable variable, --force-cors preloads a shim that
applies CORS headers at response-send time, overriding whatever the app set:
npx ports-manager --force-cors[!WARNING] The shim is intentionally permissive and meant only for local development.
Missing dependencies
A dev script naming a binary that was never installed used to fail with a bare
'vite' is not recognized as an internal or external command. Ports Manager now resolves
every binary a dev script needs before spawning anything, and installs with the package
manager your lockfile implies (npm, pnpm, yarn, or bun):
frontend: dependencies not installed
[frontend] running `npm install` in C:\work\my-project\frontendPrefer to do it yourself:
npx ports-manager --no-installdependencies are missing and --no-install was given
frontend: `vite` not found in C:\work\my-project\frontend
fix: cd C:\work\my-project\frontend && npm installRunning several projects at once
Ports are held open from the moment they are chosen until the child processes start, so a
second instance launched at the same moment cannot pick the same one. Each instance also
records its ports in ~/.ports-manager/leases.json, keyed by pid, so concurrent runs
avoid each other; entries whose process has exited are pruned automatically.
Common recipes
# Preview without installing or starting anything
npx ports-manager --dry-run
# Custom folder names
npx ports-manager --frontend-dir apps/web --backend-dir apps/api
# Restrict the search range and keep some ports free
npx ports-manager --range 4100-4900 --ban 4200,4300
# Pin one side
npx ports-manager --backend-port 5001
# Force the same-origin proxy even for Vite
npx ports-manager --proxy --api-prefix /api
# Let the backend come up first
npx ports-manager --wait-for-backendSupported stacks
| Role | Detected | Port strategy |
|---|---|---|
| Frontend | Vite | --port + --strictPort, plus a generated config |
| Frontend | Create React App | PORT environment variable |
| Frontend | Next.js | -p argument |
| Backend | Express, Koa, Fastify, NestJS | PORT environment variable |
| Either | Generic Node project | PORT environment variable |
The natural port is read from .env, then the dev script, then vite.config.*, then a
schema default such as PORT: z.coerce.number().default(5001) — so a backend that reads
its port through a config module is handled correctly.
Backends must honour PORT. Rather than guessing from a static scan, Ports Manager
watches whether the port is actually bound and tells you if it was not:
[Ports Manager] Backend never bound port 5001.
It may ignore the PORT environment variable. Set PORT in the backend .env,
or pin it with --backend-port 5001.Configuration
Create ports-manager.config.json in the project root:
{
"pairs": [["frontend", "backend"], ["client", "server"]],
"portRange": [3000, 5999],
"bannedPorts": [],
"apiPrefix": "/api",
"install": true,
"cors": true,
"proxy": null,
"env": {
"frontend": { "CUSTOM_API_URL": "http://127.0.0.1:{backendPort}" }
}
}proxy accepts true (always), false (never), or null (use it only when the
frontend cannot proxy for itself). Environment values may use {frontendPort},
{backendPort}, {proxyPort}, and {apiPrefix}. CLI flags override the file.
CLI reference
--frontend-dir PATH Override frontend/client directory
--backend-dir PATH Override backend/server directory
--frontend-port PORT Pin the frontend port
--backend-port PORT Pin the backend port
--proxy-port PORT Pin the same-origin proxy port
--range MIN-MAX Port search range (default 3000-5999)
--ban PORTS Never use these comma-separated ports
--proxy / --no-proxy Force or forbid same-origin proxy mode
--api-prefix PATH Path routed to the backend (default /api)
--no-install Fail instead of installing missing dependencies
--no-cors Do not inject backend CORS origin variables
--force-cors Also preload a response-time CORS shim
--no-cors-credentials Shim omits Access-Control-Allow-Credentials
--wait-for-backend[=MS] Wait for the backend before starting the frontend
--config PATH Explicit JSON config
--dry-run Show the plan without installing or starting anything
--version, -v Show version and author
--help, -h Show all optionsLimitations
- Next.js has no
--configflag, so its rewrites cannot be retargeted; it uses the same-origin proxy or env wiring instead. - CRA reads
proxyfrompackage.json, which cannot be overridden per run, so it also uses the same-origin proxy. - Framework and natural-port detection are heuristics; pin a port when they guess wrong.
- Monorepos and workspace layouts beyond a single
frontend/backendpair are untested.
Development
npm install
npm test