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 overPORTstop 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 seerestarting in 250mson theexitstream 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:
afteraccepts a name or a list; procs without aready: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 Nline and the rest keep streaming (addrestartto its spec to respawn it automatically). streamis"stdout" | "stderr" | "exit" | "ready"— route stderr to error level in your normalizer (but downgrade$ commandecho lines from script runners).FORCE_COLOR=0is set for children so ANSI noise doesn’t corrupt JSON parsing; let pino-pretty re-colorize at the end of the pipe.
Related
- CI load gates —
wait+ gated load runs as CI steps. - Log mining — the same lambda vocabulary over log files instead of live processes.