@apisurf/wire
v0.1.4
Published
wire — record a script's HTTP traffic into a local SQLite file, then query it.
Downloads
526
Maintainers
Readme
@apisurf/wire
Run a script, get every HTTP request, WebSocket frame and server-sent event it made in a local SQLite file, then query it.
wire execute ./scripts/sync.ts # quiet: only the script's own output
wire execute ./scripts/sync.ts --verbose # ...plus a run summary on stderr
wire ls requests --run 1 # what did it call?
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"No server, no daemon, no account. One file (./wire.sqlite) that you can copy,
delete, or open in any SQLite client.
Install
pnpm add -D @apisurf/wire # or: npm i -D @apisurf/wire
pnpm wire execute ./scripts/sync.tsNeeds Node >= 20.12.
How it works
wire execute compiles your JS/TS entry point in memory with esbuild and runs
it with globalThis.fetch, globalThis.WebSocket and globalThis.EventSource
instrumented, writing every request, frame and event to the database — created
and migrated if it does not exist.
Traffic that bypasses those globals for node:http — axios's default adapter,
for one — is not captured.
wire execute is quiet by default: it prints only what the script prints, plus
the stack trace if it throws. --verbose adds, on stderr, each failed check as
it happens and a run summary:
$ wire execute ./flow.ts --verbose
run 1 · 3 requests · 1 failed · 5 checks passed · 78 B · 22 ms wall
3 api.example.com
default · .env
./wire.sqlite
wire sql "SELECT method, host, path, status, duration_ms
FROM v_requests WHERE run_id = 1 ORDER BY seq"
wire ls checks --run 1Commands
wire execute, schema, get, ls, env and sql. Each takes
-h, --help; all but env take --db <path> (default ./wire.sqlite).
wire help checks is a topic page rather than a command.
$ wire --version
wire 0.1.0 (schema 4, node v22.22.2)The schema number is the one to compare against wire schema when a database
written by a newer build refuses to open. --json prints the parts.
wire execute <file>
There does not have to be a file: -e runs source directly and - reads it
from stdin. Inline source is treated like a file in the working directory —
bare imports resolve against the same node_modules, top-level await works,
and the snippet is stored on the run (wire get run <id> --fields entry_source).
wire execute ./scripts/sync.ts
wire execute -e 'console.log((await fetch("https://example.com")).status)'
echo 'await fetch(process.env.API + "/health")' | wire execute - --env staging| Option | |
| ------------------------ | -------------------------------------------------------------------------------- |
| -e, --eval <code> | Run this source instead of a file |
| --env <name> | Also read .env.<name> and .env.<name>.local. Errors if neither exists |
| --dotenv <path> | Read exactly these files instead of discovering any. Repeatable; later files win |
| --no-dotenv | Read no .env files. The shell and --var only |
| --var <key=value> | Override one variable. Repeatable. Beats the shell |
| --label <text> | Name this run. Default: the entry filename, or inline for -e and stdin |
| --tag <key=value> | Tag every capture in this run. Repeatable. Query them in tags |
| --sample-rate <0..1> | Fraction of requests to capture. Default 1 |
| --max-body-bytes <n> | Truncate payloads above this size. Default 1048576 |
| --redact-header <name> | Mask this header's value. Repeatable |
| --redact-body <path> | Mask a dot-path in JSON payloads, e.g. user.*.token. Repeatable |
| --no-redact | Disable redaction entirely, including the defaults |
| --verbose | Print failed checks as they happen and a run summary, to stderr. Off by default |
| --quiet | The default. Accepted so existing commands keep working |
| --ui | Open the browse UI after the run. Needs @apisurf/wireui |
authorization, cookie and set-cookie are masked unless --no-redact.
The command exits with the script's own exit code. It is quiet by default;
with --verbose the summary goes to stderr. Without it, the run you just made
is wire ls runs --limit 1.
wire get <entity> <id...> and wire ls <entity>
Reads without SQL. Every read is projected to an explicit field list, and no
default list contains a payload column — a body is reached through a bounded
preview, or by naming text.
$ wire ls requests --run 1
id seq method host path status duration_ms
── ─── ────── ─────────────── ─────────── ────── ───────────
1 0 POST api.example.com /auth/login 200 6.516
2 1 GET api.example.com /products 200 1.48
3 2 GET api.example.com /missing 404 0.61
3 requests| Entity | Default fields |
| ---------------------- | ------------------------------------------------------------------ |
| run / runs | id, uid, label, started_at, exit_status, request_count |
| request / requests | id, seq, method, host, path, status, duration_ms |
| check / checks | id, run_id, seq, name, status, message |
| body / bodies | id, size, stored_size, truncated, is_text, preview |
| message / messages | id, request_id, seq, direction, name, bytes |
| header / headers | request_id, kind, name, value — keyed by request id, many per id |
Both take --fields <a,b,c> (or --fields '*'), --max-field-bytes <n>
(default 4096, 0 disables), --max-width <n>, --json and --csv. ls
adds --limit <n> (default 20, 0 for no limit), --run <id> (requests,
messages, checks) and --request <id> (messages, headers). Run either with no
entity for the index.
wire get request 42 43 44 --fields status,duration_ms,url
wire get body 7 --fields size,is_text,preview
wire ls messages --request 12 --limit 100wire sql "<query>"
Any statement SQLite accepts. The file is opened without migrating it, so a query never changes the schema. Rows go to stdout, counts and errors to stderr; exits 1 if the database is missing or the query fails.
$ wire sql "SELECT host, COUNT(*) n, ROUND(AVG(duration_ms),1) avg_ms
FROM v_requests GROUP BY host"
host n avg_ms
─────────────── ─ ──────
api.example.com 3 2.9
1 row (0.91 ms)--json, --csv, --max-width <n> (default 60, 0 disables) and --ui.
Query the views, not the raw tables:
| | |
| ------------ | -------------------------------------------------------------------------------------------------- |
| v_runs | One per execute — label, entry, env, timings, exit status, counters |
| v_requests | One per HTTP request or socket connection — method, host, path, status, phase timings, byte counts |
| v_headers | request_id, kind ('request' \| 'response'), ord, name, value |
| v_bodies | id, size, stored_size, truncated, is_text, text |
| v_messages | WebSocket frames and SSE events, with direction and payload |
| v_checks | Assertions the run made — name, mode, status, message |
| tags | request_id, key, value — from execute --tag |
| body_fts | FTS5 index over text payloads. MATCH it, then join on body_id |
wire schema [relation]
With no argument, the views worth querying; with a name, that relation's
columns and types — read out of the file at runtime, so it describes the
database in hand. --all includes base tables, --json adds the entity map
get and ls use.
wire env [name]
The .env files a run reads, and what they set. --json to parse it. Exits 1
when a named environment has no .env file of its own. No value is ever
printed — wire cannot tell a hostname from a token, and assumes the token.
Environments
A script reads process.env.API and the like; wire fills that in from .env
files before the script is imported, so it imports nothing and is passed
nothing. Files are read from one directory — the nearest above the working
directory that has any of them:
.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 environmentPrecedence, lowest first:
.env < .env.local < .env.<name> < .env.<name>.local < the shell < --varThe shell beating every file is what makes a committed .env safe: CI exports
the real token, the file holds a placeholder. An empty value from the shell
counts as unset; an empty value in a file is a value.
WIRE_ENV, WIRE_RUN_UID, WIRE_DB and WIRE_ENTRY are always injected.
The environment name is stored on the run: SELECT ... FROM v_runs WHERE env = 'staging'.
Checks
A recording says a request returned 200 in 40 ms. Only the script knows whether
that was the right answer. @apisurf/wire/kit writes that judgement into the
run:
import { assert, check } from "@apisurf/wire/kit";
const res = await fetch(`${process.env.API}/auth/login`, { method: "POST", body });
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 records the outcome and returns the boolean; a failure never ends the
run and never changes the exit code, so one run reports every failure it found.
assert records the outcome and then throws CheckFailedError — the run ends
as any uncaught error would (exit_status 'error', exit code 1) — and
TypeScript narrows on it. The failure is written before the throw, so an
aborted run still explains itself.
Both are inert when nothing is recording, so a script that uses them still runs
under plain node. isRecording() says which it is.
A failed check prints nothing unless the run was started with --verbose,
which prints each failure as it happens and again in the run summary.
wire ls checks --run 1
wire sql "SELECT run_id, status, message FROM v_checks
WHERE name = 'login returns 200' ORDER BY ts DESC"Full page: wire help checks.
Browse UI
The UI is a separate package — @apisurf/wireui, the wireui command — so
that installing the CLI does not drag a React app and a server along with it.
wire execute --ui and wire sql --ui are convenience wrappers that run
wireui --db <path> and hold the terminal until Ctrl-C.
npm i -g @apisurf/wireui
wireui # opens ./wire.sqliteLicense
ISC. Full documentation: https://github.com/apisurf/wire#readme
