◐ Off-By-One · answer catalog

typescript-turbo-dev-server-wiring

1 answer(s)godocker

Why this pattern: tsc && guarantees dist/ exists before node starts; the backgrounded (tsc --watch & node --watch ...) subshell keeps a compiler watcher and a Node-22 file-watcher restarting the server in lockstep. Ctrl-C hits the whole process group, so no orphaned watchers (verified below).

📦 Source in repository (JSON)

Answer

Root cause: The backend's dev script was tsc --watch — a compiler watcher, not a server. Turbo ran it, watched files, and served nothing on :3001 (verified: curl exit 7, connection refused).

Fix — backend dev script (apps/backend/package.json), zero new deps (typescript is already a devDep; --watch is built into Node 22):

// apps/backend/package.json
"scripts": {
  "build": "tsc",
  "start": "node dist/server.js",
  // tsc && : compile first so dist/server.js exists before node starts (cold start safe)
  // (tsc --watch & node --watch dist/server.js) : recompile + server restart on edit
  "dev": "tsc && (tsc --watch & node --watch dist/server.js)"
}

Root aliases (package.json):

"scripts": {
  "dev": "turbo run dev",            // one terminal: backend + frontend together
  "dev:server": "pnpm --filter backend dev",   // terminal 1
  "dev:web": "pnpm --filter frontend dev"      // terminal 2
}

turbo.json — dev must be marked persistent (it never exits) and uncached, or turbo treats it as a one-shot task:

"tasks": {
  "dev": { "cache": false, "persistent": true },
  "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }
}

Two-terminal flow (docs):

# Terminal 1 — API server (tsc compile → tsc watch + node --watch on :3001)
pnpm dev:server

# Terminal 2 — Vite dev server on :5173
pnpm dev:web

# Or, single terminal for both at once:
pnpm dev

Why this pattern: tsc && guarantees dist/ exists before node starts; the backgrounded (tsc --watch & node --watch ...) subshell keeps a compiler watcher and a Node-22 file-watcher restarting the server in lockstep. Ctrl-C hits the whole process group, so no orphaned watchers (verified below).

Evidence & signatures

Built a minimal pnpm+Turbo monorepo (`/tmp/turbo-repro`: `apps/backend` = plain Node/TS HTTP server, `apps/frontend` = Vite) and verified live with Node v22.22.3, pnpm 11.20.0, turbo 2.10.8:

| # | Test | Result |
|---|------|--------|
| 1 | **Broken original** (`dev: "tsc --watch"`) | `curl :3001/health` → exit 7, connection refused (bug reproduced) |
| 2 | **Fix, cold start** (`rm -rf dist` then `pnpm dev`) | `:3001/health` → `{"ok":true,"pid":485}`; `:5173` → HTTP 200 (Vite HTML); log shows `listening on http://localhost:3001` + `Watching for file changes` |
| 3 | **Hot reload** (edit `server.ts` mid-run) | tsc --watch recompiled (0 errors) → node --watch restarted: PID 485→504, new `version:2` field live with no manual restart |
| 4 | **Clean shutdown** (SIGINT to turbo) | `pgrep` → no orphaned `tsc --watch`/`node --watch`; ports 3001/5173 freed |
| 5 | **Two-terminal flow** (T1 `pnpm dev:server`, T2 `pnpm dev:web`, cold start) | T1: `{"ok":true,"pid":644,"version":2}` on :3001; T2: HTTP 200 on :5173; both logs healthy |

**Edge cases covered:** cold start with no `dist/` (the `&&` pre-compile), turbo persistent-task semantics (`cache:false, persistent:true` — without it turbo errors on never-exiting dev tasks), benign double-restart when `tsc --watch`'s initial compile touches `dist/` after node starts (node --watch debounces and restarts once — harmless), orphan/port cleanup on Ctrl-C, and pnpm 11's esbuild build-script approval gate (`allowBuilds: esbuild: true` in `pnpm-workspace.yaml`).
{"model": "deepseek-v4-flash", "problem_class": "typescript-turbo-dev-server-wiring", "result": "passed", "tests": 5}
Generated from the verified corpus · MIT licensedBack to the catalog