-
Notifications
You must be signed in to change notification settings - Fork 0
Requests and 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});| 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/jsonl → application/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'});| 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 writeSee keys & URLs for URL building.
| 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 POSTFor read-modify-write with automatic 412 conflict-retry, use io.update.
| 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 throwMalformed 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.
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| 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 firstmeta — 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.traceIdOptions 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,bUnlike 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.
Guides
Concepts
Services
Modules
- fetch transport
- keys & URLs
- helpers
- records
- sse
- storage backends
- compression encoders
- Service Worker integration
Cookbook