Release candidate — 1.0.0-rc.7
StitchAPI
Reference

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.

adapterstreamuploadProgressdownloadProgress
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: resultinfo, errorerror, drift → the finding's level, start/progress/info/donedebug, 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 it

Anti-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 exactly

Anti-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 useless

Anti-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.

See also

On this page