Helpers
fetchAdapter, axiosAdapter, xhrAdapter, consoleSink, fileSink, createTrace, multiplex, loggerSink, the OTLP exporters, memoryStore, the secret-redaction hooks, and the duration, size, and rate parsers.
The exported helper functions you wire into a stitch: the default transport, the trace sinks (including OTLP export), the secret-redaction hooks, the default store, and the parsers behind the duration, size, and rate tokens. Each signature is below; the guides cover usage in depth and are linked under See also.
Transport
Signatures below; Transport & adapters is the guide — which transport to pick, what each can and can't do, and how to write your own.
fetchAdapter
The default fetch-based transport. Returns an Adapter backed by the global
fetch; it never throws on a non-2xx response — only network and abort errors
propagate, leaving the engine to decide what a status means.
import { } from 'stitchapi';
const = ();It accepts an optional FetchAdapterOptions to thread a per-stitch undici
dispatcher — an Agent for a proxy, a custom CA, or interface binding —
straight through as Node's non-standard dispatcher fetch init option. The
runtime stays zero-dependency, so StitchAPI never imports undici; you bring your
own Agent. An optional fetch override swaps the global fetch for testing or
custom runtimes. With no options, behavior is identical to fetchAdapter() (no
dispatcher is set).
import { , } from 'stitchapi';
// Your real `new Agent(...)` from undici is structurally an unknown dispatcher here —
// no undici import inside StitchAPI or this snippet.
declare const : unknown;
const = ({
: 'https://api.example.com/users/{id}',
: ({ : }),
});The axiosAdapter equivalent is axios's own httpAgent / httpsAgent, passed
through its defaults (axiosAdapter(axios, { httpsAgent })) — see below.
axiosAdapter
Route a stitch through a caller-supplied axios instance instead of fetch. The
runtime stays zero-dependency, so you pass your axios (or an axios.create())
rather than an import inside StitchAPI; body encoding, headers, and parsing match
fetchAdapter, so swapping transports never changes behavior. It is
buffered-only — it throws on a stream/sse surface; use fetchAdapter to
stream. It does report byte progress: the adapter wires axios's native
onUploadProgress/onDownloadProgress into onProgress (both phases), so an
upload bar works on a modern axios without leaving your instance.
import { type AxiosLike, , } from 'stitchapi';
// Your real axios instance is structurally an AxiosLike — no axios import needed here.
declare const : AxiosLike;
const = ({
: 'https://api.example.com/users/{id}',
: (),
});xhrAdapter
An XMLHttpRequest-backed transport whose reason to exist is upload progress
(fetch cannot report bytes sent). Browser-only by default; pass a constructor to
inject a polyfill or a fake. Buffered-only, like axiosAdapter.
import { , } from 'stitchapi';
const = ({
: 'POST',
: 'https://api.example.com/files',
: (), // defaults to globalThis.XMLHttpRequest
});Adapter capabilities
Each built-in adapter declares what it supports as an optional capabilities
descriptor on the returned function — { name?, supports }, where supports
lists the optional features the transport has ('stream', 'uploadProgress',
'downloadProgress'); anything not listed, it can't do.
| adapter | stream | uploadProgress | downloadProgress |
|---|---|---|---|
fetchAdapter | ✓ | — | ✓ |
xhrAdapter | — | ✓ | ✓ |
axiosAdapter | — | ✓ | ✓ |
Only fetchAdapter streams (xhr/axios buffer and reject stream); only fetch
cannot report upload progress. axiosAdapter wires axios's native
onUploadProgress/onDownloadProgress, so it needs an axios that supports them (v1+).
import { } from 'stitchapi';
// { name: 'fetchAdapter', supports: ['stream', 'downloadProgress'] }
const = ().;Declaring capabilities is opt-in: a custom adapter is still just
(req) => Promise<AdapterResponse>, and one that declares nothing is treated as
unknown — no checks run against it.
The engine checks supports for 'uploadProgress' to turn a silent footgun into a
teaching note. If a call passes onProgress with a request body and the active
adapter's supports omits 'uploadProgress' (only fetch, among the built-ins), the stitch emits one
info event — topic adapter.upload-progress-unsupported — through your trace
sink, pointing you at xhrAdapter:
onProgress is set with a request body, but fetchAdapter cannot report upload progress — only 'direction: download' events fire. Use xhrAdapter() to draw an upload progress bar.
It is a note, not an error: fetch still reports direction: 'download'
progress, so a download bar on a POST keeps working — only the upload phase is
dark. To see the note, attach any trace sink (e.g. consoleSink); to act on it,
switch the call to xhrAdapter().
Tracing & OTLP
Tracing is off by default — a stitch traces nothing until you pass a trace
sink (or set STITCH_TRACE_*). These are the sinks you reach for; see
Trace sinks for the full opt-in model.
consoleSink
A console-only sink: the colored one-line-per-event stream to stderr, nothing on
disk. The trace: 'console' shorthand resolves to this.
import { } from 'stitchapi';
const = ();fileSink
A file-only sink: append every event as JSONL to path (defaults to
~/.stitch/runs/proto.jsonl). Writing to disk is a side effect, so you pass it
explicitly — a stitch never opens a trace file on its own. An optional second
argument tunes privacy: body (a character cap, default 2048; { chars: false } for full capture)
caps body/result truncation,
and redactHeaders widens the redaction denylist.
import { } from 'stitchapi';
const = ('./runs/today.jsonl', { : { : false } });createTrace
Build a zero-infra trace sink: a compact one-line-per-event summary on the
console and/or an appended JSONL record per event. console defaults to true;
file defaults to a path under $HOME, or false to disable the JSONL stream;
body and redactHeaders tune the JSONL sink's
privacy defaults
(header/URL redaction and body truncation are always applied to the file stream).
(This is the low-level builder behind consoleSink/fileSink; the off-by-default
behavior lives at the stitch's trace field, not here.)
import { } from 'stitchapi';
const = ({ : true, : false });multiplex
Fan one event stream out to several sinks — for example console/JSONL alongside OTLP — flushing each in turn.
import { , , } from 'stitchapi';
const = ((), .());loggerSink
Bridge the event stream to any host logger — pino, winston, the console,
anything with error / warn / info / debug methods (LoggerLike). It is
payload-free (logs only metadata — name, method, scrubbed URL, status, attempt
counts, drift path/level, timing — never the body, the result's data, or a delta
chunk) and maps each event to a level: result → info, error → error,
drift → the finding's level, start/progress/info/done → debug, and
delta is never logged. Override per type with levels. The logger-agnostic core
twin of @stitchapi/nest's nestLoggerSink.
import { } from 'stitchapi';
// `console` satisfies LoggerLike; pass `pino()` or a winston logger just the same.
const = (, { : { : 'debug' } });otlp.sink
An opt-in OpenTelemetry sink: it maps each stitch call's events to a single OTel
CLIENT span and hands finished spans to a SpanExporter. With no exporter
supplied it builds otlp.exporter(). Configured by OtlpOptions (below).
import { } from 'stitchapi';
const = .({ : 'https://api.example.com:4318' });otlp groups the three layers of one export path the same way
duration groups a parse/format pair: one name on the barrel, the
role at the call site. They are layers, not siblings — sink builds spans and
hands them to exporter, which POSTs what json serialized — so reach for the
one you actually need. otlp.sink() alone covers the common case.
otlp.exporter
The default exporter: POST spans as OTLP/JSON to ${endpoint}/v1/traces. The
endpoint defaults to OTEL_EXPORTER_OTLP_ENDPOINT or http://localhost:4318.
Fire-and-forget — a missing collector never breaks a stitch call.
import { } from 'stitchapi';
const = .({
: 'https://api.example.com:4318',
: { : 'Bearer token' },
});otlp.json
Serialize finished spans to the OTLP/JSON ResourceSpans shape a collector
accepts on /v1/traces. This is the seam for a transport core doesn't ship —
gRPC, a queue, a file — so a custom SpanExporter reuses the same wire mapping
rather than re-deriving it.
import { } from 'stitchapi';
const = .([]);OtlpOptions
Prop
Type
Secret redaction
One namespace over one denylist. Reach for it when a credential of yours rides
under a name the built-in list doesn't know. Before an event reaches a sink, the
value of any secret-bearing key is replaced with REDACTED — matched by exact
name (key, sig, access_key) or by a contained stem (token, secret,
signature, credential, api_key), which is how access_token,
client_secret, and x-amz-signature are all caught without listing every
vendor spelling. A host that calls its credential session_ref matches neither,
so you name it yourself.
secrets groups the three calls that share that denylist the same way
duration groups a parse/format pair: one name on the barrel, the
verb at the call site.
secrets.register
Add one key name to the denylist. Every scrubber reads from it: the console and
JSONL start.url, the OTLP url.full attribute, the structured input.query,
and the traced request body.
The query bindings read from it too. @stitchapi/query-core derives every
cache key through the same predicate, so a registered name is redacted from the
query keys built by React,
Vue, Svelte,
Solid and Angular, and
from swrKey. That reach matters more than a trace
sink's: query keys are persisted by cache providers and shown in devtools, so a
credential in one outlives the process.
import { , } from 'stitchapi';
.('session_ref');
const = ({
: 'https://api.example.com',
: '/users',
: 'console',
});
// The traced URL reads …/users?role=admin&session_ref=REDACTED
await ({ : { : 'admin', : 'sr_live_x' } });Additive and process-wide, mirroring the built-in list — a name goes in and never
comes out. Registering the same name twice is a no-op, and the match is
case-insensitive, so one lowercase registration covers SESSION_REF too.
apiKey({ in: 'query', name }) registers its configured name at construction,
so a key the apiKey strategy puts in the URL is
covered already. This is the hook for a credential no strategy declares — one
your own code appends to a query, or one that arrives in a body field.
secrets.has
The predicate the scrubbers run, exported so you can ask it yourself: does this
key name — a query param, a body field — already redact? Audit your own config
with it before deciding whether secrets.register has anything left to add.
import { } from 'stitchapi';
.('access_token'); // true — carries the `token` stem
.('Client_Secret'); // true — the match is case-insensitive
.('page'); // false — benign params survive for observability
.('session_ref'); // false — until you register itAnti-pattern: don't reach for this as your only test of whether a
header is secret. It answers about query params and body keys, so
secrets.has('authorization') and secrets.has('x-api-key') both come back
false even though both headers are redacted. Headers get their own denylist:
at the trace-sink boundary you widen it with
redactHeaders,
not with secrets.register.
Query keys are where the two meet. The key builders check their own header
denylist and then fall through to secrets.has, so a name you register is
redacted from a cache key even though secrets.has is the wrong question to
ask about a header on its own.
secrets.redact
Deep-clone a value and replace every secret-named key inside it with REDACTED,
so a payload you are about to log or forward carries no credential. It walks
plain objects and arrays, leaves primitives alone, and hands back a new value —
the input is never mutated.
import { } from 'stitchapi';
const = .({
: 7,
: { : 'ada@example.com', : 'sk_live_x' },
});
// { id: 7, profile: { email: 'ada@example.com', api_key: 'REDACTED' } }A second argument adds patterns on top of the shared denylist — an exact key
name, a dotted path, a * single-segment wildcard, or a prefix that claims
everything under it — for a field only this payload treats as sensitive:
import { } from 'stitchapi';
.({ : 'kept', : { : 'gone' } }, ['meta.note']);
// { note: 'kept', meta: { note: 'REDACTED' } }This is what
.inspect({ redact: true }) runs
over its otherwise-unredacted raw body, and an array there
({ redact: ['meta.note'] }) is forwarded as this second argument. Call it
directly for a value that didn't come from .inspect().
Store
memoryStore
The default in-memory StitchStore: single-process, with TTL and atomic
increment. Swap it for a Redis/Postgres-backed store to make throttling
distributed and sessions shared across workers, with no change to the call site.
import { } from 'stitchapi';
const = ();Duration, size, and rate tokens
Three grammars, one namespace each, every one a parse/format pair. A stitch
reads its own config through parse; the pair is exported so a peer package that
takes an authored token — a distributed limiter's rate, a command runner's buffer
cap — resolves it to the same number a stitch would, instead of mirroring the
grammar in a second regexp that then drifts from it.
format is the exact inverse, not a pretty-printer: parse(format(v))
returns v unchanged for every value parse can produce. That is what makes it
safe to write a token back — into a config file, a CLI flag, an error message
quoting the limit it enforced — and it is where these differ from ms, whose
ms(90_000) is '2m' and reads back as 120 000.
duration
5_000, '5s', '1m' → milliseconds. This is the grammar behind every authored
duration on a stitch: timeout.total, timeout.each, cache.ttl,
backoff.base, circuit.cooldown, a surface interpret hook's after. A
number is already ms; <number><unit> takes ms | s | m | h | d, with
fractions allowed.
duration.format picks the largest unit that stays exact and readable, and
falls back to plain milliseconds when no unit divides the value cleanly.
import { } from 'stitchapi';
.('1.5s'); // 1500
.('2m'); // 120_000
.(5_000); // 5000 — a number is already ms
.('soon'); // undefined
.(90_000); // '1.5m'
.(3_600_000); // '1h'
.(90_001); // '90001ms' — no unit divides it exactlyAnti-pattern: don't format a duration StitchAPI emitted into a field
something else will read — keep it a number. Every emitted duration
(event.waited, a result's elapsed, retryAfter) is raw milliseconds,
and that is the contract a consumer parses against. format is for values a
person reads, or for writing a token back into config; putting one in an
emitted payload turns a number every reader can use into a string each of
them has to parse.
size
4096, '64kb', '1mb' → bytes, in powers of 1024 — the npm-bytes
convention every Node config parser already speaks, so '1mb' is 1_048_576.
Units are b | kb | mb | gb | tb, case-insensitive, and the IEC
spellings (kib, mib, …) name the same values for callers who want the base
explicit. It backs the byte caps — serve.body.max, and buffer.max on
@stitchapi/shell.
size.format emits the short spelling, and prefers the base unit over an exact
but unreadable quotient — 1024 is a power of two, so almost every byte count
divides into kb exactly and almost none of them divide into it legibly.
import { } from 'stitchapi';
.('64kb'); // 65_536
.('1mb'); // 1_048_576
.(4096); // 4096 — a number is already bytes
.('big'); // undefined
.(1_048_576); // '1mb'
.(1536); // '1.5kb'
.(1537); // '1537b' — '1.5009765625kb' is exact and uselessAnti-pattern: don't reach for this on a chars field —
stream.buffer.chars and
trace.body.chars count UTF-16 code units of decoded text, not bytes off
the socket, so a '64kb' token there measures the wrong thing. Those fields
take a bare number and reject a string at compile time. Worth holding onto
now that the name is size rather than parseBytes — the call site no
longer says "bytes" out loud, so the type is the only thing left warning
you.
rate
'2/s', '1000/h' → { count, per }, where per is the window length in ms.
The denominator is a duration token and a bare unit means one of that unit
('2/s' ≡ '2/1s'), so this is the grammar behind
throttle.rate, in-process
and store-backed alike. rate.format writes the pair back, using the bare-unit
spelling for a one-unit window.
import { } from 'stitchapi';
.('2/s'); // { count: 2, per: 1000 }
.('1000/h'); // { count: 1000, per: 3_600_000 }
.('100/15m'); // { count: 100, per: 900_000 }
.({ : 2, : 1000 }); // '2/s'
.({ : 100, : 900_000 }); // '100/15m'A rate declares a minimum spacing rather than a bucket, so '2/500ms', '4/s'
and '240/m' all describe the same limiter. format returns the token for the
pair it was handed rather than reducing it to one canonical member of that set —
{ count: 2, per: 500 } comes back as '2/500ms', the numbers the author wrote.
An unreadable token throws here, where the other two resolve to undefined.
The divergence runs with their fallback rather than against it. For a cap,
falling back to the field's default leaves the ceiling where it was, so a typo
can never widen it to "unbounded". A rate has no such safe default —
undefined means no rate limit at all — so a fallback would let a typo
silently remove the limit instead of narrowing it. 'fast', '0/s', and
'1/30d' (a spacing past the ~24.8-day timer ceiling) all throw, and
rate.format rejects the same three rather than writing a token that would
throw on the way back in.