Back to home
Apisurf Logo
wire

wire

Records a script's HTTP, WebSocket and SSE traffic into a local SQLite file you query with SQL.

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.

APIWhat one row isWhat the messages are
fetchOne request/response
WebSocketOne connectionEvery frame, both ways, plus the close
EventSourceOne connection attemptEvery 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

CommandDoes
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 --versionBuild version and the schema version it writes

wire execute

FlagMeaning
--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-dotenvRead 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-redactTurn 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
--quietSkip the run summary
--uiOpen 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 failureRecords, returns false, carries onRecords, then throws CheckFailedError
Run's exit_statusUnchangederror, like any uncaught throw
Process exit codeUnchanged1
Use it forEverything you want reported at onceThe 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.

RelationOne row per
v_runsRun, with request, connection, message, error and check counts
v_requestsRequest or connection, with kind as http, ws or sse
v_messagesWebSocket frame or SSE event, payload decoded
v_headersHeader, with kind as request or response
v_bodiesDeduplicated payload, text decoded
v_checksCheck, with its mode, status and message
tagsTag from --tag, per request
body_ftsFTS5 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"