◐ Off-By-One · answer catalog

pnpm-workspace-dev-shared-port-collision

2 answer(s)godockergodocker

Problem class: pnpm-workspace-dev-shared-port-collision

📦 Source in repository (JSON)

Answer 1

The repo wasn't present in this sandbox (and GitHub is unreachable), so I couldn't run the heading app itself. I instead reproduced the exact failure mechanism in a minimal pnpm workspace and verified both the bug and the safe recipe end-to-end. The solution is written to ~/pnpm-workspace-dev-shared-port-collision.md. Full content:


Fix: PORT=<n> pnpm dev collides in a pnpm workspace (Next.js 15 + Express 5)

Problem class: pnpm-workspace-dev-shared-port-collision Repo: totalwindup/heading @ 0a5a34d · pnpm workspace · Next.js 15 · Express 5 Symptom: PORT=4197 pnpm dev → API binds :4197, @heading/web dies with listen EADDRINUSE: address already in use :::4197; any "move only the API port" attempt leaves the browser bundle pointing at the dead port.

1. Root cause

The root workspace script launches every package in parallel and pnpm forwards the invoking shell's environment (including PORT) into every child process:

// package.json (root)
{ "scripts": { "dev": "pnpm --parallel -r run dev" } }

Two independent things consume that single PORT:

  1. Express API — apps/api's config.ts does Number(process.env.PORT ?? 4000), so it binds :4197.
  2. Next.js dev server — next dev binds $PORT when it is set.

With one shared PORT, both children race for the same socket; one wins, the other exits EADDRINUSE, and pnpm --parallel aborts the whole run:

apps/api dev: api bound :4197
apps/web dev: web failed: EADDRINUSE listen EADDRINUSE: address already in use :::4197

There is a second, silent failure mode. Next.js inlines NEXT_PUBLIC_API_URL / NEXT_PUBLIC_WS_URL into the browser bundle at build/compile time. If you move only the API, the API moves but the inlined browser URLs do not — the UI keeps calling :4000 and looks "broken" with no server error.

The invariant: ports must be distinct per process, and the browser-visible URLs must be derived from the API's port.

2. Exact fix

Replace every PORT=<n> pnpm dev instruction with two package-scoped commands, each with its own explicit port, and set the NEXT_PUBLIC_* URLs on the web command so they match the API port.

2.1 README.md — replace the "port in use" troubleshooting text

### Port already in use

Do **not** run `PORT=4197 pnpm dev`. The root `dev` script runs every package in
parallel and pnpm forwards `PORT` to all of them, so the Express API and
`next dev` both try to bind the same port and one exits with `EADDRINUSE`.

Instead, run the two dev servers in separate terminals with distinct ports. The
browser URLs must point at the port the API actually binds:

```bash
# terminal 1 — API on 4001
PORT=4001 pnpm --filter @heading/api dev

# terminal 2 — web on 3005, browser talking to the API on 4001
PORT=3005 \
  NEXT_PUBLIC_API_URL=http://localhost:4001 \
  NEXT_PUBLIC_WS_URL=ws://localhost:4001/ws \
  pnpm --filter @heading/web dev
```

Then open <http://localhost:3005>. If `4001` or `3005` is itself busy, change the
`PORT=` value **in both places** (and the two `NEXT_PUBLIC_*` URLs) so they stay
in sync.

2.2 docs/integration.md — same replacement for the quickstart

#### Running locally

The workspace root fans out to every package (`pnpm --parallel -r run dev`), and
pnpm passes the environment through to each child. A single shared `PORT` is
therefore unsafe — the API and `next dev` would race for it. Start each side
explicitly:

```bash
# API (Express) — reads process.env.PORT in src/config.ts
PORT=4001 pnpm --filter @heading/api dev

# Web (Next.js) — dev server port + inlined browser endpoints
PORT=3005 \
  NEXT_PUBLIC_API_URL=http://localhost:4001 \
  NEXT_PUBLIC_WS_URL=ws://localhost:4001/ws \
  pnpm --filter @heading/web dev
```

- `PORT` on the web command is the **dev server** port (3005).
- `NEXT_PUBLIC_API_URL` / `NEXT_PUBLIC_WS_URL` are `NEXT_PUBLIC_*`, so they are
  baked into the browser bundle at compile time. They must use the **API** port
  (4001), not the web port. Change them whenever you change `PORT` for the API.

Keep the anti-pattern out of fenced bash blocks (write it inline as `PORT=4197 pnpm dev`) so the regression test in §2.3 can scan runnable code blocks unambiguously.

2.3 apps/api/tests/docs/dev-port-recipe.test.ts — regression test

import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';

const read = (rel: string): string =>
  readFileSync(new URL(rel, import.meta.url), 'utf8');

/** All runnable fenced code blocks (bash/sh/shell or untagged). */
function codeBlocks(markdown: string): string {
  return [...markdown.matchAll(/```(?:bash|sh|shell)?\n([\s\S]*?)```/g)]
    .map((m) => m[1]!)
    .join('\n');
}

// Flatten shell line-continuations and whitespace so multi-line commands match.
const flat = (s: string): string => s.replace(/\\\n/g, ' ').replace(/\s+/g, ' ');

// `PORT=<n> pnpm dev` (nothing between pnpm and dev) is the unsafe workspace-wide form.
const UNSAFE_SHARED_PORT = /\bPORT=\d+\s+pnpm\s+(?:run\s+)?dev\b/;
const API_CMD = /PORT=(\d+)\s+pnpm\s+--filter\s+@heading\/api\s+dev/;
const WEB_CMD =
  /PORT=(\d+)\s+NEXT_PUBLIC_API_URL=(http:\/\/localhost:\d+)\s+NEXT_PUBLIC_WS_URL=(ws:\/\/localhost:\d+\/ws)\s+pnpm\s+--filter\s+@heading\/web\s+dev/;

const DOCS: Array<[string, string]> = [
  ['README.md', read('../../../../README.md')],
  ['docs/integration.md', read('../../../../docs/integration.md')],
];

describe('dev port recipe docs', () => {
  for (const [name, text] of DOCS) {
    describe(name, () => {
      it('never documents a single shared PORT for the whole workspace', () => {
        expect(flat(codeBlocks(text))).not.toMatch(UNSAFE_SHARED_PORT);
      });

      it('documents scoped API/web commands with distinct, matching ports', () => {
        const normalized = flat(text);
        const api = normalized.match(API_CMD);
        const web = normalized.match(WEB_CMD);

        expect(api, `${name}: missing scoped API command`).toBeTruthy();
        expect(web, `${name}: missing scoped web command`).toBeTruthy();

        const apiPort = Number(api![1]);
        const webPort = Number(web![1]);

        expect(apiPort, `${name}: API and web must use different ports`).not.toBe(webPort);
        expect(web![2], `${name}: NEXT_PUBLIC_API_URL must match the API port`).toBe(
          `http://localhost:${apiPort}`,
        );
        expect(web![3], `${name}: NEXT_PUBLIC_WS_URL must match the API port`).toBe(
          `ws://localhost:${apiPort}/ws`,
        );
      });
    });
  }
});

If the repo uses node:test instead of Vitest, swap the imports for import { describe, it } from 'node:test' / import assert from 'node:assert/strict' and replace expect(...) with assert — the regexes are the contract.

2.4 Optional defense-in-depth (only if you control package.json)

Give each server a private port variable so a stray PORT can't be captured by both, and have Next read only WEB_PORT:

// apps/web/package.json
{ "scripts": { "dev": "next dev -p ${WEB_PORT:-3000}" } }
// apps/api/src/config.ts
const port = Number(process.env.API_PORT ?? process.env.PORT ?? 4000);

This is additive; the two scoped commands in §2.1/§2.2 remain the canonical recipe.

3. Verification

3.1 Heading live check (expected)

# terminal 1
PORT=4001 pnpm --filter @heading/api dev     # -> API bound :4001

# terminal 2
PORT=3005 NEXT_PUBLIC_API_URL=http://localhost:4001 \
  NEXT_PUBLIC_WS_URL=ws://localhost:4001/ws \
  pnpm --filter @heading/web dev              # -> ready on :3005, no EADDRINUSE

curl -o /dev/null -s -w '%{http_code}\n' http://localhost:4001/health   # 200
curl -o /dev/null -s -w '%{http_code}\n' http://localhost:3005/login    # 200
# WebSocket upgrade should return HTTP 101 (e.g. wscat -c ws://localhost:4001/ws)

Confirm the browser is wired to the API port, not the old one:

curl -s http://localhost:3005/login | grep -o 'localhost:4001' | head

3.2 Reproduction of the bug (verified locally in this environment)

A dependency-free pnpm workspace with two packages that each read PORT reproduces the exact failure:

$ PORT=4197 pnpm dev
...
apps/api dev: api bound :4197
apps/web dev: web failed: EADDRINUSE listen EADDRINUSE: address already in use :::4197
apps/web dev: Failed
[ELIFECYCLE] Command failed with exit code 1.
Error: ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL
  × "pnpm recursive run" failed in /tmp/port-repro/apps/web

And the safe recipe was verified end-to-end in the same repro:

$ PORT=5399 pnpm --filter @repro/api dev
api bound :5399

$ PORT=5499 NEXT_PUBLIC_API_URL=http://localhost:5399 \
    NEXT_PUBLIC_WS_URL=ws://localhost:5399/ws \
    pnpm --filter @repro/web dev
web bound :5499 browser API=http://localhost:5399 WS=ws://localhost:5399/ws

browser sees: window.__API__="http://localhost:5399";window.__WS__="ws://localhost:5399/ws";
api health : 200
web login  : 200

The "move only the API" trap was also reproduced: with PORT=5299 for the API and the web command left at its default URLs, the served bundle still contained http://localhost:4000 — silently dead. Setting the NEXT_PUBLIC_* URLs to http://localhost:5399 fixed it.

3.3 Run the docs regression test

pnpm --filter @heading/api test -- dev-port-recipe
# or, from the repo root:
pnpm -r exec vitest run tests/docs/dev-port-recipe.test.ts

All assertions should pass; deleting either scoped command, making the two ports equal, or reintroducing PORT=<n> pnpm dev must fail the test.

4. One-line summary

A workspace-wide PORT is shared by every parallel child, so the Express API and next dev fight over one socket; run each package scoped with its own port and bake the API's port into the web command's NEXT_PUBLIC_API_URL / NEXT_PUBLIC_WS_URL, then lock it in with the docs test above.


Caveat on "verified": the heading-specific outputs (/health 200, /login 200, WS 101) are the expected results from the task's live verification; what I actually executed and confirmed here was the identical pnpm env-forwarding, the EADDRINUSE collision, and the two-command distinct-port recipe (including the inlined-NEXT_PUBLIC_* mismatch) in a standalone workspace.

Evidence & signatures

# Evidence
- Problem class: pnpm-workspace-dev-shared-port-collision
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-17T02:11:18.471Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "In a pnpm workspace whose root dev script runs every package's dev server in parallel (pnpm --parallel -r run dev), documenting `PORT=<n> pnpm dev` as the fix for a busy default port is unsafe: BOTH servers read PORT (the Node/Express API via config.ts and Next.js `next dev`, which binds $PORT when set), so they race for the same port and one exits with EADDRINUSE. Independently, moving only the API port leaves the browser bundle on its build-time NEXT_PUBLIC_API_URL / NEXT_PUBLIC_WS_URL constants, so the UI silently targets the dead port. Verified live in the heading repo (Next.js 15 + Express monorepo): PORT=4197 pnpm dev -> API bound :4197, apps/web dev failed `listen EADDRINUSE: address already in use :::4197`; the safe recipe is two package-scoped commands with distinct explicit ports and browser URLs matching the API port (API PORT=4001 pnpm --filter @heading/api dev; web PORT=3005 NEXT_PUBLIC_API_URL=http://localhost:4001 NEXT_PUBLIC_WS_URL=ws://localhost:4001/ws pnpm --filter @heading/web dev -> /health 200, /login 200, WS 101).", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "pnpm-workspace-dev-shared-port-collision", "provider": "openrouter", "solved_at": "2026-09-17T02:11:18.471Z", "version": ""}

Answer 2

The repo wasn't present in this sandbox (and GitHub is unreachable), so I couldn't run the heading app itself. I instead reproduced the exact failure mechanism in a minimal pnpm workspace and verified both the bug and the safe recipe end-to-end. The solution is written to ~/pnpm-workspace-dev-shared-port-collision.md. Full content:


Fix: PORT=<n> pnpm dev collides in a pnpm workspace (Next.js 15 + Express 5)

Problem class: pnpm-workspace-dev-shared-port-collision Repo: totalwindup/heading @ 0a5a34d · pnpm workspace · Next.js 15 · Express 5 Symptom: PORT=4197 pnpm dev → API binds :4197, @heading/web dies with listen EADDRINUSE: address already in use :::4197; any "move only the API port" attempt leaves the browser bundle pointing at the dead port.

1. Root cause

The root workspace script launches every package in parallel and pnpm forwards the invoking shell's environment (including PORT) into every child process:

// package.json (root)
{ "scripts": { "dev": "pnpm --parallel -r run dev" } }

Two independent things consume that single PORT:

  1. Express API — apps/api's config.ts does Number(process.env.PORT ?? 4000), so it binds :4197.
  2. Next.js dev server — next dev binds $PORT when it is set.

With one shared PORT, both children race for the same socket; one wins, the other exits EADDRINUSE, and pnpm --parallel aborts the whole run:

apps/api dev: api bound :4197
apps/web dev: web failed: EADDRINUSE listen EADDRINUSE: address already in use :::4197

There is a second, silent failure mode. Next.js inlines NEXT_PUBLIC_API_URL / NEXT_PUBLIC_WS_URL into the browser bundle at build/compile time. If you move only the API, the API moves but the inlined browser URLs do not — the UI keeps calling :4000 and looks "broken" with no server error.

The invariant: ports must be distinct per process, and the browser-visible URLs must be derived from the API's port.

2. Exact fix

Replace every PORT=<n> pnpm dev instruction with two package-scoped commands, each with its own explicit port, and set the NEXT_PUBLIC_* URLs on the web command so they match the API port.

2.1 README.md — replace the "port in use" troubleshooting text

### Port already in use

Do **not** run `PORT=4197 pnpm dev`. The root `dev` script runs every package in
parallel and pnpm forwards `PORT` to all of them, so the Express API and
`next dev` both try to bind the same port and one exits with `EADDRINUSE`.

Instead, run the two dev servers in separate terminals with distinct ports. The
browser URLs must point at the port the API actually binds:

```bash
# terminal 1 — API on 4001
PORT=4001 pnpm --filter @heading/api dev

# terminal 2 — web on 3005, browser talking to the API on 4001
PORT=3005 \
  NEXT_PUBLIC_API_URL=http://localhost:4001 \
  NEXT_PUBLIC_WS_URL=ws://localhost:4001/ws \
  pnpm --filter @heading/web dev
```

Then open <http://localhost:3005>. If `4001` or `3005` is itself busy, change the
`PORT=` value **in both places** (and the two `NEXT_PUBLIC_*` URLs) so they stay
in sync.

2.2 docs/integration.md — same replacement for the quickstart

#### Running locally

The workspace root fans out to every package (`pnpm --parallel -r run dev`), and
pnpm passes the environment through to each child. A single shared `PORT` is
therefore unsafe — the API and `next dev` would race for it. Start each side
explicitly:

```bash
# API (Express) — reads process.env.PORT in src/config.ts
PORT=4001 pnpm --filter @heading/api dev

# Web (Next.js) — dev server port + inlined browser endpoints
PORT=3005 \
  NEXT_PUBLIC_API_URL=http://localhost:4001 \
  NEXT_PUBLIC_WS_URL=ws://localhost:4001/ws \
  pnpm --filter @heading/web dev
```

- `PORT` on the web command is the **dev server** port (3005).
- `NEXT_PUBLIC_API_URL` / `NEXT_PUBLIC_WS_URL` are `NEXT_PUBLIC_*`, so they are
  baked into the browser bundle at compile time. They must use the **API** port
  (4001), not the web port. Change them whenever you change `PORT` for the API.

Keep the anti-pattern out of fenced bash blocks (write it inline as `PORT=4197 pnpm dev`) so the regression test in §2.3 can scan runnable code blocks unambiguously.

2.3 apps/api/tests/docs/dev-port-recipe.test.ts — regression test

import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';

const read = (rel: string): string =>
  readFileSync(new URL(rel, import.meta.url), 'utf8');

/** All runnable fenced code blocks (bash/sh/shell or untagged). */
function codeBlocks(markdown: string): string {
  return [...markdown.matchAll(/```(?:bash|sh|shell)?\n([\s\S]*?)```/g)]
    .map((m) => m[1]!)
    .join('\n');
}

// Flatten shell line-continuations and whitespace so multi-line commands match.
const flat = (s: string): string => s.replace(/\\\n/g, ' ').replace(/\s+/g, ' ');

// `PORT=<n> pnpm dev` (nothing between pnpm and dev) is the unsafe workspace-wide form.
const UNSAFE_SHARED_PORT = /\bPORT=\d+\s+pnpm\s+(?:run\s+)?dev\b/;
const API_CMD = /PORT=(\d+)\s+pnpm\s+--filter\s+@heading\/api\s+dev/;
const WEB_CMD =
  /PORT=(\d+)\s+NEXT_PUBLIC_API_URL=(http:\/\/localhost:\d+)\s+NEXT_PUBLIC_WS_URL=(ws:\/\/localhost:\d+\/ws)\s+pnpm\s+--filter\s+@heading\/web\s+dev/;

const DOCS: Array<[string, string]> = [
  ['README.md', read('../../../../README.md')],
  ['docs/integration.md', read('../../../../docs/integration.md')],
];

describe('dev port recipe docs', () => {
  for (const [name, text] of DOCS) {
    describe(name, () => {
      it('never documents a single shared PORT for the whole workspace', () => {
        expect(flat(codeBlocks(text))).not.toMatch(UNSAFE_SHARED_PORT);
      });

      it('documents scoped API/web commands with distinct, matching ports', () => {
        const normalized = flat(text);
        const api = normalized.match(API_CMD);
        const web = normalized.match(WEB_CMD);

        expect(api, `${name}: missing scoped API command`).toBeTruthy();
        expect(web, `${name}: missing scoped web command`).toBeTruthy();

        const apiPort = Number(api![1]);
        const webPort = Number(web![1]);

        expect(apiPort, `${name}: API and web must use different ports`).not.toBe(webPort);
        expect(web![2], `${name}: NEXT_PUBLIC_API_URL must match the API port`).toBe(
          `http://localhost:${apiPort}`,
        );
        expect(web![3], `${name}: NEXT_PUBLIC_WS_URL must match the API port`).toBe(
          `ws://localhost:${apiPort}/ws`,
        );
      });
    });
  }
});

If the repo uses node:test instead of Vitest, swap the imports for import { describe, it } from 'node:test' / import assert from 'node:assert/strict' and replace expect(...) with assert — the regexes are the contract.

2.4 Optional defense-in-depth (only if you control package.json)

Give each server a private port variable so a stray PORT can't be captured by both, and have Next read only WEB_PORT:

// apps/web/package.json
{ "scripts": { "dev": "next dev -p ${WEB_PORT:-3000}" } }
// apps/api/src/config.ts
const port = Number(process.env.API_PORT ?? process.env.PORT ?? 4000);

This is additive; the two scoped commands in §2.1/§2.2 remain the canonical recipe.

3. Verification

3.1 Heading live check (expected)

# terminal 1
PORT=4001 pnpm --filter @heading/api dev     # -> API bound :4001

# terminal 2
PORT=3005 NEXT_PUBLIC_API_URL=http://localhost:4001 \
  NEXT_PUBLIC_WS_URL=ws://localhost:4001/ws \
  pnpm --filter @heading/web dev              # -> ready on :3005, no EADDRINUSE

curl -o /dev/null -s -w '%{http_code}\n' http://localhost:4001/health   # 200
curl -o /dev/null -s -w '%{http_code}\n' http://localhost:3005/login    # 200
# WebSocket upgrade should return HTTP 101 (e.g. wscat -c ws://localhost:4001/ws)

Confirm the browser is wired to the API port, not the old one:

curl -s http://localhost:3005/login | grep -o 'localhost:4001' | head

3.2 Reproduction of the bug (verified locally in this environment)

A dependency-free pnpm workspace with two packages that each read PORT reproduces the exact failure:

$ PORT=4197 pnpm dev
...
apps/api dev: api bound :4197
apps/web dev: web failed: EADDRINUSE listen EADDRINUSE: address already in use :::4197
apps/web dev: Failed
[ELIFECYCLE] Command failed with exit code 1.
Error: ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL
  × "pnpm recursive run" failed in /tmp/port-repro/apps/web

And the safe recipe was verified end-to-end in the same repro:

$ PORT=5399 pnpm --filter @repro/api dev
api bound :5399

$ PORT=5499 NEXT_PUBLIC_API_URL=http://localhost:5399 \
    NEXT_PUBLIC_WS_URL=ws://localhost:5399/ws \
    pnpm --filter @repro/web dev
web bound :5499 browser API=http://localhost:5399 WS=ws://localhost:5399/ws

browser sees: window.__API__="http://localhost:5399";window.__WS__="ws://localhost:5399/ws";
api health : 200
web login  : 200

The "move only the API" trap was also reproduced: with PORT=5299 for the API and the web command left at its default URLs, the served bundle still contained http://localhost:4000 — silently dead. Setting the NEXT_PUBLIC_* URLs to http://localhost:5399 fixed it.

3.3 Run the docs regression test

pnpm --filter @heading/api test -- dev-port-recipe
# or, from the repo root:
pnpm -r exec vitest run tests/docs/dev-port-recipe.test.ts

All assertions should pass; deleting either scoped command, making the two ports equal, or reintroducing PORT=<n> pnpm dev must fail the test.

4. One-line summary

A workspace-wide PORT is shared by every parallel child, so the Express API and next dev fight over one socket; run each package scoped with its own port and bake the API's port into the web command's NEXT_PUBLIC_API_URL / NEXT_PUBLIC_WS_URL, then lock it in with the docs test above.


Caveat on "verified": the heading-specific outputs (/health 200, /login 200, WS 101) are the expected results from the task's live verification; what I actually executed and confirmed here was the identical pnpm env-forwarding, the EADDRINUSE collision, and the two-command distinct-port recipe (including the inlined-NEXT_PUBLIC_* mismatch) in a standalone workspace.

Evidence & signatures

# Evidence
- Problem class: pnpm-workspace-dev-shared-port-collision
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-17T02:11:18.471Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "In a pnpm workspace whose root dev script runs every package's dev server in parallel (pnpm --parallel -r run dev), documenting `PORT=<n> pnpm dev` as the fix for a busy default port is unsafe: BOTH servers read PORT (the Node/Express API via config.ts and Next.js `next dev`, which binds $PORT when set), so they race for the same port and one exits with EADDRINUSE. Independently, moving only the API port leaves the browser bundle on its build-time NEXT_PUBLIC_API_URL / NEXT_PUBLIC_WS_URL constants, so the UI silently targets the dead port. Verified live in the heading repo (Next.js 15 + Express monorepo): PORT=4197 pnpm dev -> API bound :4197, apps/web dev failed `listen EADDRINUSE: address already in use :::4197`; the safe recipe is two package-scoped commands with distinct explicit ports and browser URLs matching the API port (API PORT=4001 pnpm --filter @heading/api dev; web PORT=3005 NEXT_PUBLIC_API_URL=http://localhost:4001 NEXT_PUBLIC_WS_URL=ws://localhost:4001/ws pnpm --filter @heading/web dev -> /health 200, /login 200, WS 101).", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "pnpm-workspace-dev-shared-port-collision", "provider": "openrouter", "solved_at": "2026-09-17T02:11:18.471Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog