Skip to content

Library size

The root import (@itsy/corgicorgi) is ~1.4 KB gzipped.

Second-smallest of the popular options, and it guards the most footguns.

library~gzipthrows on non-2xxbody passthroughempty-JSON safetimeoutcompose engine
redaxios~1 KByespartial (corrupts binary/stream)yesnoneno
@itsy/corgi~1.4 KByesfullyesopt-inyes
wretch~2 KByespartialno (throws on empty)opt-inchainable
ky~4 KByesexplicit json:no (throws on empty)yeshooks
ofetch~6 KB+yesfullyesnativeinterceptors
native fetch0no(manual)nomanualno

Where the ~1.4 KB goes

Approximate share of the always-shipped client.

cluster~sharewhat it does
corgi.ts~35%the client: build URL/query/body/headers, run the pipeline, parse, throw, transform, verb shortcuts, extend/raw
parse.ts~17%turn a Response into a value without the classic parse footguns
url.ts~14%base-URL join + query-string merge that don't silently corrupt
error.ts~12%HttpError + error guards
body.ts~7%serialize a body without clobbering FormData/binary/stream
core.ts~6%the compose engine (shared chunk)
headers.ts~3%case-insensitive header merge
scaffolding~6%import/export wiring

Run pnpm size for exact per-export gzip budgets, or pnpm size:exports for the full per-export breakdown of the root import.

The footgun catalog

Each entry is a trap, the exact failure it causes, and what Corgi does about it. The source files carry the same notes inline.

1. Native fetch resolves on 404/500 (error.ts)

fetch only rejects on network failure; a 500 is a successful promise. You must remember if (!res.ok) on every call.

Corgi throws HttpError on any non-2xx, with err.status, a best-effort parsed err.data, and a re-readable err.response (cloned before the body was read). Opt out per-call with throwOnError: false, or use .raw() for the untouched Response.

2. new URL(path, base) silently drops the base path (url.ts)
js
new URL('/users', 'https://api.com/v1'); // → https://api.com/users  (the /v1 is GONE)
new URL('users', 'https://api.com/v1'); // → https://api.com/users  (drops last segment too)
new URL('users', '/api'); // → THROWS (relative base)

Corgi's joinURL does a real prefix-join that preserves the base path, supports relative bases, and lets an absolute or protocol-relative URL override.

3. res.json() throws on an empty body (parse.ts)

A 200/204 with content-type: application/json and an empty body (common on DELETE and empty POST) makes res.json() throw "Unexpected end of JSON input".

Corgi reads text first and returns undefined for an empty body.

4. Guessing JSON blows up on HTML error pages (parse.ts)

"Smart" parsers assume JSON and then explode on an nginx/Cloudflare HTML 502.

Corgi returns text for unknown and text/* content types, never guessing JSON, and returns a Blob for binary types. It also short-circuits 204/205/304 and HEAD (no body to read), and matches +json suffixes like application/problem+json (RFC 7807) and application/vnd.api+json.

5. JSON-stringifying a body destroys FormData / binary (body.ts)

JSON.stringify(new FormData()) is "{}", which wipes the multipart boundary, so your upload silently sends nothing. Same class of bug for Blob, ArrayBuffer, typed arrays, and ReadableStream.

Corgi JSON-encodes only plain objects and arrays. Every real BodyInit passes through untouched, and streams get duplex: 'half' (Node/undici require it or they throw).

What makes this one nasty is that the failure is silent: the request goes out looking fine and sends garbage, with no error to tell you.

That's the "body passthrough" column up top. Full means every real BodyInit (FormData, URLSearchParams, Blob/File, ArrayBuffer, typed arrays, DataView, ReadableStream) is sent exactly as you passed it, and only plain objects and arrays are JSON-encoded. Partial means the library JSON.stringifys any body it doesn't special-case, and grows its exception list reactively.

redaxios stopped stringifying FormData after #28 and Blob after #70, so today it keeps anything with an .append method (FormData, URLSearchParams) or a .text method (Blob, File). ArrayBuffer, typed arrays, DataView, and ReadableStream were never added and still fall through to "{}" / {"0":…}, with no bug filed for them at the time of writing. That per-type patching is what "partial" means: whatever body type nobody has reported yet is a silent footgun. wretch is the same class of issue. ky sidesteps it by making you opt into JSON with an explicit json: key.

6. Object spread can't merge headers (headers.ts)

{ 'Content-Type': a } and { 'content-type': b } are two different keys to a plain object, so both get sent.

Corgi routes every source through Headers, collapsing case-insensitively (last source wins).

7. instanceof lies across iframes, workers, and duplicate copies (error.ts)

instanceof silently returns false when the error came from another context (an iframe, web worker, or vm) or a second bundled copy of the library.

Corgi's isHttpError / isTimeoutError / isAbortError check the error name, so they stay correct everywhere. (axios uses a flag for the same reason.)

8. Detached fetch throws on Cloudflare Workers (core.ts)

const f = globalThis.fetch; f(url) throws "Illegal invocation" on Workers, where the receiver is brand-checked. Hides in dev, bites in prod.

Corgi calls the global through an arrow so the receiver stays correct on every runtime.

9. Timeouts, done right (timeout.ts / timeout-modern.ts)

A naive timeout plugin that replaces init.signal orphans the caller's signal, so abortPrevious and total-budget AbortSignal.timeout stop working, and it leaks a timer per request.

Corgi forwards the caller's abort (preserving its reason, so a user-cancel stays AbortError and a timeout stays TimeoutError) and cleans up the timer and listener on settle. Two implementations, same behaviour: the hand-rolled /timeout (2022-safe floor) and the smaller /timeout-modern (AbortSignal.any + AbortSignal.timeout, Baseline-2024 runtimes).

Released under the MIT License.