One dev tail

Merge every dev process into one tagged stream — with readiness probes, dependency ordering, crash-loop-capped restarts, and CI-friendly wait.

Goal: run the whole dev stack with one command and read one coherent log, instead of three terminals fighting for attention — and reuse the same process supervisor for CI boot ordering.

The shape of it

procs({name: "command", …}) spawns each command and streams { proc, stream, line } for every stdout/stderr line (plus an exit marker), merged as lines arrive. Children die with the pipeline — Ctrl-C stops the lot.

procs({web: "bun run dev:web", api: "bun api.ts", tsc: "tsc -b --watch"}) \
  | (l => l.proc + " | " + l.line)

Object specs — per-proc env and auto-restart

A spec value can be an object instead of a bare command string: {cmd, env?, restart?, ready?, live?, after?}.

procs({
  web: {cmd: "bun run dev:web", env: {PORT: "3001", LOG_FORMAT: "json"}},
  api: {cmd: "bun api.ts", env: {PORT: "3002"}, restart: true},
  tsc: "tsc -b --watch"
}) | (l => l.proc + " | " + l.line)
  • env — extra environment for that process only, merged over the inherited environment. Two services fighting over PORT stop being a reason for two terminals.
  • restart: true — respawn on unexpected exit, with backoff starting at 250ms and doubling to a 2s cap. You’ll see restarting in 250ms on the exit stream between attempts. A user kill (Ctrl-C / SIGTERM) never respawns — restart is for crashy dev servers, not for fighting your own shutdown.

Liveness — live: restarts the unhealthy-but-alive

A readiness probe converges once and stops watching; live: keeps watching for the wedge readiness can’t see. Same target forms as ready: (":3001/health" / "port:5432"), long form {url?, port?, intervalMs?, probeTimeoutMs?, failures?, graceMs?} — defaults 5s cadence / 3 consecutive failures. Probe progress lands on a live stream (probe failed (1/3), recovered after 1 failed probe(s), unhealthy after 3 consecutive failed probe(s)); at the fatal streak a restartable proc is killed through the normal escalation and respawned (liveness re-arms after the next ready), while a non-restartable one fails the whole pipeline — CI semantics, exactly like a ready-timeout. The restart strike counter’s “healthy stretch” ends when the fatal streak began, so a proc that answers ready and then wedges still runs out of {max} instead of restarting forever.

procs({api: {cmd: "bun api.ts", ready: ":3001/health", live: {url: ":3001/health", failures: 3}, restart: {max: 5}}})

Capping crash loops — restart: {max: N}

Bare restart: true retries forever, which is right for a dev server you’re actively editing and wrong for one that can’t boot at all. restart: {max: N} gives up after N consecutive restarts:

procs({flaky: {cmd: "sh -c \"echo boom; exit 1\"", restart: {max: 3}}}) \
  | (l => l.proc + " [" + l.stream + "] " + l.line)
flaky [stdout] boom
flaky [exit] exited with code 1
flaky [exit] restarting in 250ms
flaky [stdout] boom
flaky [exit] exited with code 1
flaky [exit] restarting in 500ms
flaky [stdout] boom
flaky [exit] exited with code 1
flaky [exit] restarting in 1000ms
flaky [stdout] boom
flaky [exit] exited with code 1
flaky [exit] giving up after 3 restart(s)

The counter resets after a stretch of more than 10s of uptime — it guards against crash loops, not against a long-lived server ever crashing twice.

Boot order — ready: probes and after: dependencies

The classic dev-stack race: the API boots faster than Postgres accepts connections, spews reconnect noise, and sometimes wedges. ready: gives a process a definition of “up”, and after: makes dependents wait for it:

procs({
  db:  {cmd: "docker compose up pg", ready: "port:5432"},
  api: {cmd: "bun api.ts", after: "db", ready: ":3001/health", restart: {max: 3}},
  web: {cmd: "bun run dev", after: "api", env: {PORT: "3001"}}
}) | (l => l.proc + " | " + l.line)

Probe forms: ":3001/health" / "http(s)://…" (ready = any 2xx) or "port:5432" (ready = TCP connect succeeds). Long form {url?, port?, timeoutMs?, intervalMs?} — defaults 30s / 250ms. Probe progress arrives on a ready stream, so the merged log shows the choreography:

migrate [ready] waiting for db
db [stdout] db accepting connections
db [ready] ready after 755ms (port:5432)
migrate [stdout] running migrations
migrate [stdout] migrations done
migrate [exit] exited with code 0

The semantics that make this CI-usable, not just cosmetic:

  • after accepts a name or a list; procs without a ready: probe count as ready once spawned. Unknown names, self-dependencies, and cycles throw before anything spawns.
  • Gating is one-shot: a dependency restarting later never re-blocks a dependent. But a dependency that dies or gives up before ever becoming ready fails the whole pipeline with dependency "db" exited before becoming ready — no half-booted stack.
  • On probe timeout, a restartable proc is killed and respawned (readiness is re-awaited after every restart); a non-restartable one fails the pipeline. In CI that’s exactly what you want: a service that can’t get healthy is a red build, not a hung one.
  • Teardown is thorough: children are spawned in their own process group and the whole group is SIGTERM’d when the pipeline ends, escalating to SIGKILL after 3s — grandchildren (a dev server spawned via sh -c, say) don’t outlive the pipeline.

wait — readiness as a pipeline stage

Sometimes the process is started elsewhere (docker compose, a CI service container) and you just need to block until it answers. wait replaces the curl-sleep retry loop:

wait :3001/api/health --timeout 40s --interval 2s
# {"target":":3001/api/health","ready":true,"ms":7,"attempts":1}

wait port:5432 --timeout 30s
# {"target":"port:5432","ready":true,"ms":2,"attempts":1}

Same probe grammar as ready: — any 2xx for URLs, TCP connect for port:N — with durations like 300ms/30s/2m (defaults 30s / 500ms). Not ready in time → crust: wait: :3001/api/health not ready after 40s, exit 1. In a CI script that’s one honest line between “boot” and “test”:

crust -c 'wait :3000/health --timeout 40s --interval 2s'
crust -c "test-pipes 'tests/**/*.pipes' -b"

And because it emits a {target, ready, ms, attempts} record, it composes — wait :3000/health --timeout 40s | (r => "up after " + r.ms + "ms") if you want boot time in the log.

Interactive: logs procs(...)

Piping procs into a filter works, but changing the filter means restarting the whole dev stack. The logs builtin holds the process group open and buffers its recent output; every line typed at its prompt is a pipeline fragment run over that buffer, then live:

logs procs({web: "bun run dev", api: "bun api.ts"})
logs: items are {proc, stream, line} objects — try (l => l.line) or filter (l => l.proc === "web")
logs> (l => l.line) | grep -i ready
[web] ✓ Ready in 158ms          ← from the buffer, even if it scrolled by minutes ago
-- live --
^C
logs> filter (l => l.proc === "api") | (l => l.line)
-- live --
…                                ← only api's lines now, same stack still running
^C
logs> exit                       ← tears the whole group down (SIGTERM → SIGKILL)

Ctrl-C ends the current view without touching the held processes; exit (or Ctrl-D) is what stops the stack. Commands with &&/;/pipes can’t appear inside the logs line itself — put them in a wrapper script and use procs({web: "sh dev-web.sh"}).

Pretty rendering works inside the session too: unwrap the {proc, stream, line} envelope with the normalizer lambda from the pino-pretty section below, then end the fragment in pino-pretty --colorize --singleLine — the buffered past and the live stream both render colorized. (Shell stages get npm-run-style PATH, so the bare name resolves from your project’s node_modules/.bin.)

With pino-pretty

If your services log NDJSON, normalize each line and hand the merged stream to pino-pretty — plain lines from bundlers/watchers get wrapped, structured lines pass through with an app tag:

procs({web: "LOG_FORMAT=json bun run dev", tsc: "tsc -b --watch"}) \
  | (l => { let o = null; try { o = JSON.parse(l.line); } catch (e) {} \
      return JSON.stringify(o && o.msg !== undefined \
        ? Object.assign(o, { app: l.proc }) \
        : { level: l.stream === "stderr" ? 50 : 30, time: Date.now(), msg: l.line, app: l.proc }); }) \
  | pino-pretty --colorize --singleLine --messageFormat "[{app}] {msg}" --ignore app

Every line arrives timestamped, colorized by level, tagged with its app — structured fields from your server logs render as inline JSON context.

Knobs

  • One process crashing does not kill the others; you get an exited with code N line and the rest keep streaming (add restart to its spec to respawn it automatically).
  • stream is "stdout" | "stderr" | "exit" | "ready" — route stderr to error level in your normalizer (but downgrade $ command echo lines from script runners).
  • FORCE_COLOR=0 is set for children so ANSI noise doesn’t corrupt JSON parsing; let pino-pretty re-colorize at the end of the pipe.
  • CI load gateswait + gated load runs as CI steps.
  • Log mining — the same lambda vocabulary over log files instead of live processes.