Skip to content
Eugene Lazutkin edited this page Jul 4, 2026 · 5 revisions

Request keys & URLs

double-meh derives a canonical request key and the final URL from options. The key (io.makeKey) identifies a request for tracking and caching; io.buildUrl produces the URL actually sent, applying double-meh's query construction rules.

import io from 'double-meh';

io.buildUrl({url: 'https://api.example.com/x', query: {a: 1, b: 2}});
// 'https://api.example.com/x?a=1&b=2'

io.makeKey({url: 'https://api.example.com/x', query: {a: 1, b: 2}});
// 'GET https://api.example.com/x?a=1&b=2'

io.makeKey(options)

The key is METHOD (upper-cased) + a space + the WHATWG-normalized URL with its query sorted and its fragment dropped. Two requests that differ only in query order or #hash produce the same key.

const a = io.makeKey({url: 'https://api.example.com/x?b=2&a=1#frag'});
const b = io.makeKey({url: 'https://api.example.com/x', query: {a: 1, b: 2}});
a === b; // true
a; // 'GET https://api.example.com/x?a=1&b=2'

The representation dimension

A request that asks for a different representation is a different identity: the effective Accept (the accept option, or an Accept header) extends the key when it differs from the prepared default (application/json) — so a JSON read and a CSV read of the same URL neither dedup into one envelope nor overwrite each other in the cache. The default and an explicit accept: 'application/json' are deliberately the same identity.

io.makeKey({url: 'https://api.example.com/x'});
// 'GET https://api.example.com/x'
io.makeKey({url: 'https://api.example.com/x', accept: 'text/csv'});
// 'GET https://api.example.com/x accept=text/csv'

io.buildUrl(options)

Builds the query from the options and merges it into options.url via new URL() (relative URLs resolve against location.href where one exists). Parameters already on the URL are kept and the new ones appended after them; unlike the key, the sent URL keeps the query in insertion order. A #fragment survives, with the query inserted before it.

io.buildUrl({url: 'https://api.example.com/x'}); // no query -> unchanged
io.buildUrl({url: 'https://api.example.com/x?p=1', query: {q: 2}});
// 'https://api.example.com/x?p=1&q=2'
io.buildUrl({url: 'https://api.example.com/x#top', query: {q: 2}});
// 'https://api.example.com/x?q=2#top' — query goes before the fragment

Query construction

The query source is options.query, or for read verbs (GET/HEAD/OPTIONS/DELETE) the positional data argument (an explicit options.data on a DELETE is sent as the body instead). An object becomes a parameter bag; array values repeat the key. A URLSearchParams instance is accepted as-is, its pairs appended in order.

await io.get(url, {tag: ['a', 'b'], page: 2}); // ?tag=a&tag=b&page=2
await io.get(url, new URLSearchParams('a=1&a=2')); // ?a=1&a=2

A scalar (string, number, boolean) becomes a raw, keyless query segment:

await io.get(url, 'a=1&b=2'); // ?a=1&b=2
await io.get(url, 123); // ?123

An empty string, null, or undefined contributes no query:

await io.get(url, ''); // no query
await io.get(url, null); // no query
await io.get(url, undefined); // no query

fields, sort, expand

These array options are joined with commas into their own params, alongside any object/scalar query above.

io.buildUrl({
  url: 'https://api.example.com/x',
  query: {q: 'text'},
  fields: ['id', 'name'],
  sort: ['-created'],
  expand: ['author']
});
// 'https://api.example.com/x?q=text&fields=id,name&sort=-created&expand=author'

bust

bust: true appends a uniquifying io-bust=<value> parameter (after everything above) to defeat intermediary caches; bust: 'name' picks the parameter name. Busted requests are never stored in io.cache.

io.buildUrl({url: 'https://api.example.com/x', bust: true});
// 'https://api.example.com/x?io-bust=<unique>'
io.buildUrl({url: 'https://api.example.com/x', bust: '_'});
// 'https://api.example.com/x?_=<unique>'

See also

Clone this wiki locally