Sidecars
A sidecar is a long-lived child process that ferridriver launches and
drives over a private pipe, exposed to scripts as sidecars.connect(name).
Use one to hand work to a tool written in another language — a formatter, a
data generator, a domain service — without shelling out per call or exposing
arbitrary process spawning to scripts.
The transport is the same one the browser backends use internally: the child
reads requests on fd 3 and writes responses on fd 4, each frame a
UTF-8 JSON object terminated by a single \0 byte. ferridriver wires the
file descriptors; the child never sees them as arguments.
Declaring a sidecar
Sidecars are declared in ferridriver.toml at the top level (a sibling of
[mcp] and [test]), because both the test runner and the run / MCP paths
consume them. A script can only connect to a sidecar that was declared —
there is no way to spawn an arbitrary process from JavaScript.
A duplicate name or an empty command is a configuration error and fails
the load.
The JavaScript API
sidecars.connect(name) resolves a handle. The child is spawned on the first
connect for a name; later connects in the same session reuse the warm
process. A handle exposes request/response calls plus pushed events.
send(method, params?) → Promise<result>
Sends { id, method, params } and resolves with the matching response's
result. Rejects on a child { error } reply, a timeout, or a closed
transport. Requests are correlated by id, so concurrent sends are safe.
sendMany(calls) → Promise<result[]>
Issues a batch of requests in one call. calls is an array of
{ method, params? }; the result array is positional (result[i] is the
reply to calls[i]). Rejects on the first child { error } reply, timeout,
or closed transport — the same semantics as
Promise.all(calls.map((c) => sc.send(c.method, c.params))), which it
replaces.
Prefer it when issuing many calls at once: the whole batch is written in a
single pipe write and resolves through one promise instead of one per call,
so it avoids the per-call promise overhead that otherwise dominates a large
fan-out. The win grows with the real child's ability to pipeline — measured
at roughly 1.7x over Promise.all against a trivial echo child and ~2.9x
against a real request-serving child.
on(event, cb) → () => void
Registers cb for every pushed frame whose method equals event; cb
receives the frame's params. Returns a function that removes exactly this
listener. The first on (or once) starts a single background pump per
handle that dispatches events into the script context.
once(event) → Promise<params>
Resolves with the next matching event's params, then auto-unsubscribes.
off(event, cb?)
With cb, removes that one listener (by identity); without it, removes every
listener for event.
close() → Promise<void>
Stops the event pump, closes the request pipe (the child sees EOF), and reaps the process. Idempotent.
The wire protocol
Implement the child in any language; it only has to speak NUL-delimited JSON on fd 3 (read) and fd 4 (write):
- Request:
{"id": <number>, "method": <string>, "params": <any>} - Response:
{"id": <number>, "result": <any>}or{"id": <number>, "error": {"code": <number>, "message": <string>}} - Event (server-pushed, no
id):{"method": <string>, "params": <any>}
The event channel is bounded; if a listener falls far enough behind that the buffer laps it, those events are dropped (a warning is logged). Treat events as best-effort notifications, not a guaranteed-delivery queue.