What is wire?
Run a script, get every HTTP request, WebSocket frame and server-sent event it made in a local SQLite file,
then read it back with ls, get or SQL. No
server, no daemon, no account — one file (./wire.sqlite) you can copy,
delete, or open in any SQLite client.
bash
wire execute ./scripts/sync.ts
wire execute -e 'console.log((await fetch("https://example.com")).status)'
wire ls requests --run 1 # what did it call?
wire ls checks --run 1 # what did it assert?
wire get request 42 --json # one request, compact
wire sql "SELECT method, host, path, status, duration_ms FROM v_requests
ORDER BY duration_ms DESC LIMIT 10"
Installation
wire needs Node 20.12 or newer and is published to GitHub Packages. Point the
@apisurf scope there and authenticate with a token that has
read:packages:
.npmrc
@apisurf:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
bash
pnpm add -D @apisurf/wire
pnpm wire execute ./scripts/sync.ts
pnpm add -D @apisurf/wireui # optional browse UI
pnpm wireui # ./wire.sqlite on http://127.0.0.1:3000
pnpm wireui --db ./runs/sync.sqlite --port 4000
The browse UI, @apisurf/wireui, is a separate package so the CLI stays
small. It binds to 127.0.0.1, never migrates the database, and moves to the
next free port if 3000 is taken.
How it works
wire execute loads your .env files,
compiles the entry point in memory with esbuild, swaps the global
fetch, WebSocket and
EventSource for instrumented ones, then imports it. Your script needs no
import or setup to be recorded.
flow
your script ──▶ wire execute
│ .env files ─▶ process.env
│ esbuild (in memory, ESM, top-level await OK)
│ globalThis.fetch = capture.fetch
│ globalThis.WebSocket = capture.WebSocket
│ globalThis.EventSource = capture.EventSource
▼
./wire.sqlite ◀── wire ls / get / sql
◀── wireui (optional)
Requests and responses are cloned for capture, so your code's body streams are untouched and a
capture failure never breaks a real call. Anything calling the globals is recorded, including most libraries;
one that uses node:http directly (axios's default adapter, for one) is not.
Stock Node has no EventSource, so wire provides one built on fetch.
| API | What one row is | What the messages are |
fetch | One request/response | — |
WebSocket | One connection | Every frame, both ways, plus the close |
EventSource | One connection attempt | Every event, with its name and id |
To keep a call out of the database, use the uninstrumented originals the run publishes on
globalThis:
typescript
const capture = (globalThis as any)[Symbol.for("wire.capture")];
await capture.native.fetch(`${process.env.API}/health`); // not recorded
Commands
| Command | Does |
wire execute <file> | Run a JS/TS file (or -e <code>, or - for stdin), recording its traffic |
wire ls <entity> | List records, bounded |
wire get <entity> <id...> | Fetch records by id |
wire sql "<query>" | Run any SQLite query. --json or --csv for machine output |
wire schema [relation] | List the views, or one relation's columns. --all adds base tables |
wire env [name] | List environments, or which file supplies each variable of one |
wire help [command] | Help for a command. wire help checks covers assertions |
wire --version | Build version and the schema version it writes |
wire execute
| Flag | Meaning |
--db <path> | Database file. Default ./wire.sqlite. ls, get, sql and schema take it too |
-e, --eval <code> | Run this source instead of a file. Stored on the run as entry_source |
--env <name> | Also read .env.<name> and .env.<name>.local |
--var <key=value> | Override one variable. Repeatable |
--dotenv <path> / --no-dotenv | Read exactly these files (repeatable, later wins), or none |
--label <text> | Name the run. Default: the entry filename, or inline |
--tag <key=value> | Tag every capture in the run, stored in tags. Repeatable |
--redact-header <name> | Mask a header. authorization, cookie and set-cookie are always masked |
--redact-body <path> | Mask a JSON dot-path, e.g. user.*.token. Repeatable |
--no-redact | Turn off redaction, including the defaults |
--max-body-bytes <n> | Truncate payloads above this size. Default 1 MiB |
--sample-rate <0..1> | Fraction of requests or connections to capture. Default 1 |
--quiet | Skip the run summary |
--ui | Open the browse UI after the run. Needs @apisurf/wireui |
A run prints a summary to stderr and exits with the script's own exit code.
wire sql --ui opens the browse UI without running anything.
Reading a recording
ls and get read a recording without SQL.
They share six entities — run,
request, check,
body, message,
header (plural for ls) — and the
same flags. Run either with no entity to see the index.
bash
wire ls runs
wire ls requests --run 7 --fields seq,method,path,status
wire ls messages --request 12 --limit 100
wire get request 42 43 44 --fields status,duration_ms,url
wire get body 7 --fields size,is_text,preview
wire get run 1 --fields '*' --json
No default field set includes a payload: a body is reached through a
bounded preview, or by naming text, so
output stays small enough for an agent to read. --run and
--request narrow a listing, --limit
defaults to 20 and --max-field-bytes to 4096; both take
0 for no bound. get header 42 returns
the headers of request 42.
Environments
Scripts read base URLs and credentials from process.env, filled in before
import from the same .env files your app already uses. They are read from
one directory: the nearest one at or above the working directory that has any of them.
which files
.env shared with the repo
.env.local yours alone — gitignore it
.env.<name> read when --env <name> is passed
.env.<name>.local yours alone, for that environment
precedence
--var > the shell > .env.<name>.local > .env.<name> > .env.local > .env
The shell beating every file keeps a committed .env safe: CI exports the
real token, the file holds a placeholder. --env prod stops the run if there
is no .env.prod or .env.prod.local,
rather than quietly running against the wrong host.
Every run also gets WIRE_ENV, WIRE_RUN_UID,
WIRE_DB and WIRE_ENTRY.
wire env shows which files exist and which one wins for each variable, but
never prints a value.
Checks
A recording says a request returned 200. Only your script knows whether that was the right answer.
check() and assert() from
@apisurf/wire/kit write that judgement into the run.
typescript
import { assert, check } from "@apisurf/wire/kit";
const res = await fetch(`${process.env.API}/auth/login`, { method: "POST", body });
// Nothing below works without a session, so stop here if it failed
assert("login returns 200", res.ok, `expected 200, got ${res.status}`);
const session = await res.json();
check("token is a JWT", session.accessToken?.split(".").length === 3);
check("expires within an hour", session.expiresIn <= 3600, `expiresIn was ${session.expiresIn}`);
| check(name, condition, message?) | assert(name, condition, message?) |
| On failure | Records, returns false, carries on | Records, then throws CheckFailedError |
Run's exit_status | Unchanged | error, like any uncaught throw |
| Process exit code | Unchanged | 1 |
| Use it for | Everything you want reported at once | The ones that make the rest moot |
Failures print as they happen and again in the run summary; an assert's failure is recorded before it
throws. The message is stored only on failure. Keep name stable — it
identifies the check across runs. Under plain node nothing is recorded, but
a failed assert still throws.
The schema
Query the views, not the raw tables. wire schema
lists them, with columns, for the database you have.
| Relation | One row per |
v_runs | Run, with request, connection, message, error and check counts |
v_requests | Request or connection, with kind as http, ws or sse |
v_messages | WebSocket frame or SSE event, payload decoded |
v_headers | Header, with kind as request or response |
v_bodies | Deduplicated payload, text decoded |
v_checks | Check, with its mode, status and message |
tags | Tag from --tag, per request |
body_fts | FTS5 index over text payloads, joined on body_id |
Underneath, repeated strings such as hosts and header names are interned, payloads are deduplicated by
SHA-256, and text payloads are indexed with FTS5. A WebSocket or SSE connection is a request row, told apart by
kind.
bash
# The slowest calls across every run
wire sql "SELECT run_id, method, host, path, duration_ms
FROM v_requests ORDER BY duration_ms DESC LIMIT 10"
# Error rate per host
wire sql "SELECT host, COUNT(*) AS n, SUM(status >= 400 OR status IS NULL) AS failed
FROM v_requests GROUP BY host ORDER BY failed DESC"
# Full-text search across response payloads
wire sql "SELECT r.method, r.url FROM body_fts f
JOIN v_requests r ON r.response_body_id = f.body_id
WHERE body_fts MATCH 'rate limit'"
# Has this check ever failed?
wire sql "SELECT run_id, status, message FROM v_checks
WHERE name = 'login returns 200' ORDER BY ts DESC"