-
Notifications
You must be signed in to change notification settings - Fork 0
Cookbook: requests and responses
Inspectors are the hooks for cross-cutting concerns. io.inspect.request(fn, match?) runs after a request is prepared but before it is dispatched; io.inspect.response(fn, match?) runs after a response lands but before a bad status throws. The optional match — a URL prefix string, a RegExp, or a predicate (url) => boolean — scopes the inspector to matching request URLs. Inspectors run in registration order and can mutate in place or return a replacement.
A request inspector receives the prepared {url, method, headers, body, signal} and the resolved options. headers is a live Headers object; url is a plain string. Mutating either sticks — no return needed.
import io from 'double-meh';
io.inspect.request(request => {
console.log(request.method, request.url); // trace every call
});Give short, environment-independent tags to your services and expand them to real origins at request time. Callers never hardcode a host, and you swap prod/staging in one place.
const bases = {
'@api': 'https://api.example.com',
'@auth': 'https://auth.example.com'
};
io.inspect.request(request => {
const slash = request.url.indexOf('/');
const tag = slash < 0 ? request.url : request.url.slice(0, slash);
if (bases[tag]) request.url = bases[tag] + request.url.slice(tag.length);
});
await io.get('@api/users/1'); // -> https://api.example.com/users/1
await io.post('@auth/login', creds);Attach credentials per host, and only when the caller has not already set them. Register this after any URL-rewriting inspector so the URL is already absolute when it runs.
const tokens = {
'api.example.com': () => sessionStorage.getItem('api_token'),
'auth.example.com': () => sessionStorage.getItem('auth_token')
};
io.inspect.request(request => {
if (request.headers.has('authorization')) return;
const getToken = tokens[new URL(request.url).host];
const token = getToken && getToken();
if (token) request.headers.set('Authorization', 'Bearer ' + token);
});For a single origin, scope the inspector with match instead of testing inside it:
io.inspect.request(request => {
request.headers.set('Authorization', 'Bearer ' + sessionStorage.getItem('api_token'));
}, 'https://api.example.com/');Platform objects work directly: a Headers instance for options.headers, a URLSearchParams as the query (positional or options.query). A few options cover the common transport tweaks:
await io.get('https://api.example.com/search', new URLSearchParams('q=meh&limit=10'), {
headers: new Headers({'X-Trace': '1'}),
timeout: 5000, // ms; throws io.TimedOut when exceeded
bust: true // append a uniquifying query parameter to defeat intermediary caches
});-
timeoutcomposes with your ownsignaland surfaces asio.TimedOut. -
bust: trueappendsio-bust=<unique>; a string picks the parameter name. Busted requests are never stored inio.cache. -
assets the requestContent-Typeonly when a body exists — a GET withasno longer sends a bodylessContent-Typeheader. - Positional data on a DELETE becomes the query, like the other read verbs — but an explicit
options.dataon a DELETE is sent as the body, an escape hatch for APIs like Elasticsearch:
await io.delete('https://search.example.com/things/_delete_by_query', null, {
data: {query: {match: {status: 'stale'}}}
});Decoding normally follows the response Content-Type; decode overrides it — 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData', or a function (response, options) => any. onDownloadProgress(info) fires as body bytes arrive, with info = {loaded, total, lengthComputable}.
const text = await io.get(url, null, {decode: 'text'}); // keep mislabeled JSON raw
const blob = await io.get(imageUrl, null, {
decode: 'blob',
onDownloadProgress: ({loaded, total, lengthComputable}) => {
if (lengthComputable) renderProgress(loaded / total);
}
});Note: malformed JSON on a JSON content-type throws io.FailedIO (the SyntaxError is on .cause, the response is attached); an empty body with a JSON content-type decodes to undefined.
onUploadProgress(info) mirrors the download side for the request body. A stream body meters per chunk as the transport pulls it (lengthComputable: false — the total is unknowable); a buffered body stays buffered (streaming it would break HTTP/1.1 and non-Chromium browsers) and reports one completion event when the response arrives.
const upload = fileInput.files[0]; // a Blob: one event with loaded === total === blob.size
await io.put('/media/avatar', upload, {
onUploadProgress: ({loaded, total, lengthComputable}) => {
if (lengthComputable) renderProgress(loaded / total);
}
});
// granular progress needs a stream body (CLI: any HTTP version; browsers: Chromium + HTTP/2)
await io.post(
'/bulk',
{readable: makeReadable()},
{
onUploadProgress: ({loaded}) => renderBytes(loaded)
}
);The meter counts bytes handed to the transport, after compression; mock-served requests and cache hits report nothing.
A response inspector receives the envelope and the request context. Use it to unwrap an envelope-in-an-envelope, normalize shapes, or record timing. Mutating envelope.data in place is enough.
io.inspect.response(envelope => {
const body = envelope.data;
if (body && typeof body === 'object' && 'result' in body) {
envelope.data = body.result; // unwrap {result: ...} into the bare payload
}
});
io.inspect.response((envelope, ctx) => {
for (const metric of envelope.serverTiming) {
console.debug(ctx.key, metric.name, metric.dur);
}
});Wrap the verbs once and classify failures in a single place. All I/O failures extend io.IOError. io.BadStatus means the server answered with a non-2xx (it carries the full envelope — use .status / .data); io.FailedIO means the request never got an answer — offline, DNS, CORS, or a timeout (io.TimedOut, a FailedIO subclass). The original error is preserved on .cause. A user abort is never wrapped — it surfaces as the platform AbortError; test with the exported isAbort(error).
import {io, isAbort} from 'double-meh';
const request = async fn => {
try {
return await fn();
} catch (error) {
if (isAbort(error)) throw error; // the caller cancelled — not a failure
if (error instanceof io.BadStatus) {
if (error.status === 401 || error.status === 403) {
location.assign('/login'); // expired or missing credentials
}
// error.problem is the parsed error envelope even when a legacy service mislabels it
if (error.problem?.detail) showErrorBanner(error.problem.detail);
throw error;
}
if (error instanceof io.FailedIO) {
showOfflineBanner(); // no response — treat as offline
}
throw error;
}
};
const user = await request(() => io.get('@api/users/1'));Guides
Concepts
Services
Modules
- fetch transport
- keys & URLs
- helpers
- records
- sse
- storage backends
- compression encoders
- Service Worker integration
Cookbook