Skip to content

Requests and options

Eugene Lazutkin edited this page Jul 10, 2026 · 7 revisions

Requests & options

Every call is verb(url, data?, options?). url is a string, a URL, or an options bag; the positional data is the request's payload; options is the override bag below. Args merge with a small deep merge — scalars replace, headers/query merge per key, url is pinned to the 1st arg.

Three terms are used consistently on this page and in the API: the query is what lowers into the URL's query string, the body is what is sent as the request body, and the positional data is one or the other by direction — the query on reads (GET/HEAD/DELETE/OPTIONS), the body on writes (POST/PUT/PATCH). The explicit options always name their meaning: options.query is the query on any verb, options.data is the body on any verb.

await io.put(endpoint, body, {as: 'json', ifMatch: etag, retry: true});

Body & content type

Option Meaning
data (2nd arg, writes) request body. An object → JSON; a string/Blob/File/FormData/URLSearchParams/ArrayBuffer/ReadableStream → sent as-is; a {readable, writable} duplex streams its readable side; null → JSON null. On a DELETE the positional arg is still the query, but an explicit options.data is sent as the body (escape hatch for APIs like Elasticsearch).
as Content-Type shorthand — set only when a body exists. Aliases resolve through the mutable io.mimeTypes registry (json, ndjson/jsonlapplication/x-ndjson, text, csv, form, octet, html, xml, merge-patch, json-patch); a full media-type string (with /) passes through verbatim. An explicit headers['content-type'] always wins.
headers extra request headers: a plain dict (values may be string or string[]) or a platform Headers instance.
compress compress the request body and set Content-Encoding: an encoder name from io.encoders (gzip/deflate built in; br/zstd via the opt-in zlib module), true for gzip, or an inline encoder function (sets no header — see compression encoders). Bodyless requests ignore it.
io.mimeTypes.geojson = 'application/geo+json'; // register your own
await io.put(url, feature, {as: 'geojson'});

Query (reads)

Option Meaning
data (2nd arg, reads) query. An object bag (defined below) or a URLSearchParams; a scalar → raw query (io.get(url, 123)?123); empty string / null / undefined → no query.
query explicit query bag — same shapes as above; the query on any verb (e.g. a POST with query params, where the 2nd arg is the body).
fields, sort, expand string[] → comma-joined query params (?fields=a,b) by default; an explicit listSeparator overrides.
page {offset, limit} or {cursor}?offset=40&limit=20 / ?cursor=…. io.paginate follows pages for you.
bust true or a param name → appends a uniquifying query parameter (io-bust=<value> by default) to defeat intermediary caches. Busted requests are never stored in io.cache.
listSeparator how arrays serialize in the query: unset/null → repeated keys (the default); a string (',', '|', …) joins each list into one parameter. Set it per API via io.defaults (below).

The object bag, precisely: a shallow object — property names become query-parameter names, and values are stringified: numbers, strings, booleans, and any object with a custom toString() all work. An array of those serializes per listSeparator: by default (unset or null) it repeats the key — {tags: ['new', 'sale']}?tags=new&tags=sale, ambiguity-free since a separator character inside an item cannot be confused with the separator; a string joins each list into a single parameter — listSeparator: ','?tags=new,sale (shorter URLs, one key to process; note URLSearchParams percent-encodes the separator on the wire, so a comma ships as %2C — servers decode before splitting). null/undefined values and empty arrays contribute nothing. Nested plain objects are not serialized (there is no a[b]=c convention — flatten first). A URLSearchParams always rides verbatim.

An explicit listSeparator also governs fields/sort/expand; left unset, those keep their own protocol default of ',' (field names contain no separators, and ?fields=a,b is their convention).

await io.get(
  '/products',
  {q: 'lamp', inStock: true, tags: ['new', 'sale']},
  {
    fields: ['id', 'name', 'price'], // ?fields=id,name,price
    sort: ['-price'],
    page: {offset: 40, limit: 20},
    bust: true // one-off uniquifier; never cached
  }
);
// → /products?q=lamp&inStock=true&tags=new&tags=sale&fields=id,name,price&sort=-price&offset=40&limit=20&io-bust=…

await io.post('/units', {name: 'unit-1'}, {query: {dept: 42}}); // body AND query on a write

See keys & URLs for URL building.

Conditional writes & idempotency

Option Meaning
ifMatch If-Match header (optimistic concurrency).
ifNoneMatch If-None-Match header ('*' = create-only; an ETag = cache revalidation).
idempotencyKey true → a generated UUID (stable across retries), or a string you supply → Idempotency-Key header.
await io.put(url, doc, {ifMatch: etag}); // lost race → 412, not a lost write
await io.put(url, doc, {ifNoneMatch: '*'}); // create-only
await io.post(url, order, {idempotencyKey: true, retry: 3}); // effectively-once POST

For read-modify-write with automatic 412 conflict-retry, use io.update.

Response

Option Meaning
stream true → the bare body is the raw ReadableStream (see Streaming).
accept the Accept header (defaults to application/json).
decode force response parsing: 'json' / 'text' / 'blob' / 'arrayBuffer' / 'formData', or a function (response, options) => any.
onDownloadProgress (info) => void, info = {loaded, total, lengthComputable} — fires as response bytes arrive (buffered and streamed reads alike).
onUploadProgress same shape — meters request-body bytes handed to the transport (post-compression, real wire only). Stream bodies fire per chunk; a buffered body gets one completion event when the response arrives (fetch has no granular upload progress for buffered sends); FormData/URLSearchParams report nothing.
ignoreBadStatus do not throw on a non-2xx; the envelope is returned as-is.
const csv = await io.get(url, null, {accept: 'text/csv'}); // negotiate; the accept is part of the cache key
const raw = await io.get(url, null, {decode: 'text'}); // keep mislabeled JSON unparsed
const env = await io.full.get(url, null, {ignoreBadStatus: true}); // inspect a 4xx without a throw

Malformed JSON on a JSON content-type throws FailedIO (the SyntaxError on .cause, the response attached); an empty body with a JSON content-type decodes to undefined.

Services

Cache and track are on by default for GETs — the flags below opt out or tune them; they never apply to other verbs.

Option Meaning
cache false opts out; {ttl} overrides io.cache.defaultTtl (5 min). GET-only, never for stream: true or bust requests — see cache.
track false opts out of dedup; 'wait' registers interest without firing — a later real request resolves it (throws a TypeError on a non-GET). GET-only — track: true on a non-GET does nothing; streams are never tracked. See track.
retry true (defaults) / a number (max retries) / {retries, initDelay, force, nextDelay, continueRetries} — see retry.
mock false opts a request out of mocking — see mock.
await io.get(url, null, {cache: {ttl: 60_000}, retry: true}); // fresh minute, retried
const live = await io.get(url, null, {cache: false, track: false}); // bypass both defaults

Transport & fetch

Option Meaning
signal an AbortSignal to cancel the request. Aborts surface as the platform AbortError — never wrapped, never retried.
timeout milliseconds; on expiry the request rejects with TimedOut (composed with your signal via AbortSignal.any). See Response envelope.
transport name of a registered transport (default: fetch).
fetch a RequestInit sub-bag spread into the underlying fetch(){fetch: {credentials: 'include', mode: 'cors', redirect: 'manual', integrity, referrer, …}}. double-meh's own method/headers/body/signal always win. See fetch transport.
const controller = new AbortController();
const promise = io.get(url, null, {signal: controller.signal, timeout: 5000});
// your abort surfaces as AbortError; the 5s expiry as io.TimedOut — whichever comes first

Custom data

meta — a free-form Record<string, unknown> bag for your own per-request data; inspectors and services see it on options.meta. The Options type has no index signature, so unknown top-level keys are a type error.

await io.get(url, null, {meta: {traceId}}); // read back in inspectors: options.meta.traceId

Per-API defaults — io.defaults

Options like listSeparator, accept, or timeout are usually a property of the API, not of the call site. io.defaults(match?, bag) registers a scoped defaults bag — the lowest merge layer, applied when the request's raw URL matches, before the endpoint descriptor and the per-call options (which always win). match is the usual scoping convention — a URL prefix string, a RegExp, or a predicate; omit it for instance-global defaults. Matching bags accumulate in registration order (later wins); a bag never carries url.

io.defaults('https://legacy.example.com/', {listSeparator: ',', timeout: 5000});
io.defaults({accept: 'application/json'}); // unscoped: applies to every request

// call sites stay trivial — the API's conventions are configured once
await io.get('https://legacy.example.com/units', {tags: ['a', 'b']}); // → ?tags=a,b

Unlike inspectors, which see the prepared request (URL already built), defaults join the options before the query is encoded and before cache/dedup keys are computed — so they can shape both.

See also

Clone this wiki locally