Building requests
Corgi has a few extra features on top of the fetch API to make requests easier to build. Everything is optional, use the standard fetch API if preferred.
Base URL
baseURL is prepended to relative URLs on the client
const api = corgi.create({ baseURL: 'https://api.example.com/v1' });
await api.get('/users'); // -> https://api.example.com/v1/users
await api.get('users'); // -> https://api.example.com/v1/users
await api.get('https://other.com/x'); // absolute URL bypasses baseURL entirelyQuery
The query attribute is merged into the URL's query string.
await corgi.get('/search', {
query: { foo: 'bar', tag: ['a', 'b'], page: 2, cursor: null },
});
// -> /search?foo=bar&tag=a&tag=b&page=2 (cursor skipped)Values (and keys) are encoded via URLSearchParams, so spaces, &, unicode, etc. are escaped correctly.
How query is processed
- arrays expand to repeated keys
- any existing query and
#hashis preserved
Both null and undefined are dropped entirely. Corgi does not emit a value-less ?flag for null the way ofetch does. The web platform's URLSearchParams can't represent a bare key (it normalizes ?flag to flag=) and Corgi matches that behavior.
If you truly need a bare flag, put it in the URL string on a call that doesn't also pass query.
Body
// JSON:
await corgi.post('/users', { body: { name: 'Ada' } });
// FormData passes through — the runtime sets the multipart boundary:
const form = new FormData();
form.set('file', new Blob(['hi']), 'hi.txt');
await corgi.post('/upload', { body: form });How body is processed
A plain object or array is JSON-encoded and the content-type is set automatically.
Everything 'native' passes through untouched:
FormDataBlob/FileURLSearchParamsReadableStreamArrayBuffer- typed arrays
Streaming request bodies get duplex: 'half' automatically - Node/undici require it or they throw.
Headers
const api = corgi.create({ headers: { 'x-app': 'web' } });
await api.get('/me', { headers: { authorization: 'Bearer t' } });
// sends: x-app: web + authorization: Bearer tHow headers are processed
Headers are merged on a case-insensitive basis.
Order:
- an automatic
content-type - client defaults
- per-call headers
This means a caller header always wins.
Everything else
Any standard RequestInit field you set (signal, mode, cache, priority, etc.) is forwarded to fetch unchanged.
const controller = new AbortController();
await api.get('/slow', { signal: controller.signal, credentials: 'include' });A per-call deadline is a signal too — AbortSignal.timeout(ms) aborts the call (a TOTAL budget spanning any retries). For a per-attempt deadline use the withTimeout plugin.
await api.get('/slow', { signal: AbortSignal.timeout(5000) }); // this call: 5s max