Building air, from an empty repo to a published package
I wrote a 300-line fetch wrapper and published it to npm. Most of the work happened after it already worked: three bugs the test suite could not see, and two features I removed instead of fixing.
Every project I work on eventually grows the same file. Something called api.ts or http.ts that wraps fetch, parses the response, throws when the status is not 2xx, joins a base URL, and serializes a body. None of that is difficult, and I had rewritten it from scratch every time because copying it over felt worse than retyping it.
So I wrote it once properly. The result is air: around 300 lines, zero runtime dependencies, roughly 2 kB over the wire. That part took an afternoon. The weeks after it worked are where everything interesting happened.
Writing the constraints down first
Before any code I put the philosophy in the repo as its own document, with an explicit list of things the library is not allowed to become: no interceptor chains, no plugin system, no caching layer, no Node-specific escape hatches that break in a browser.
That is a lot of ceremony for a 300-line package and I still think it earned its place. Pressure to add features does not come from users on day one. It comes from me at 11 p.m. thinking a small option would be convenient. Once the rule is written down I have to argue with it before I can break it.
One decision shaped the API
air had to work two ways: as a direct wrapper you call with air.get(url), and as a factory that produces configured clients with air.create({ baseURL }). Implement that the obvious way and you get two code paths, a default instance and a constructor, which drift as options get added to one and forgotten in the other.
The fix was to stop treating the root export as special. air is a client created with empty defaults. There is one implementation, so there is nothing to keep in sync.
// The root export is a client built with empty defaults,
// so both entry points run through the same implementation.
export function create(defaults: AirOptions = {}): AirClient {
const call = <T>(url: AirURL, options?: AirOptions) =>
request<T>(url, merge(defaults, options))
const shortcut =
(method: string) =>
<T>(url: AirURL, options?: AirOptions) =>
request<T>(url, { ...merge(defaults, options), method })
return Object.assign(call, {
get: shortcut('GET'),
post: shortcut('POST'),
// ...the rest of the verbs
create: (options?: AirOptions) => create(merge(defaults, options))
})
}
export const air = create()The rest fell into seven flat files: url, body, parse, error, client, types, index. None of them is longer than about a hundred lines. No directory tree, no barrel files.
Three bugs the tests could not see
The first version passed everything I had written for it. It was still wrong in three places, and the suite could not have caught any of them, because it imported the source and mocked fetch, which is where the bugs actually were.
The worst one was timeout. I implemented it the obvious way: an AbortController, a timer that aborts it, the caller's own signal forwarded in, and a finally that cleans both up when the request finishes. But fetch() resolves when the headers arrive, not when the body has been read, so that cleanup disarmed the timer right as the download started. Pointed at an endpoint that trickles its body over ten seconds, with a 500 ms timeout and an explicit abort fired at 50 ms, the request hung forever.
The second was isAirError, which used instanceof. An application can end up with two copies of a package loaded, either two versions in the tree or a bundled copy beside a resolved one, and each copy brings its own class, so instanceof returns false across them. It now checks a Symbol.for('air.error') brand, since the symbol registry is global and every copy agrees on it.
The third was a dead end rather than a bug. Auto-parsing responses is convenient until you need a header: Link for pagination, ETag for caching, anything about rate limits. There was no way to reach the Response on a successful call. A rule like 'less is better' will justify any omission you like, and the cost of what you left out never shows up in the issue tracker.
Deleting features instead of fixing them
The fix for the timeout bug was to remove the option. AbortSignal.timeout(ms) is native, AbortSignal.any([...]) composes it with the caller's own signal, and air now forwards signal straight to fetch. There is no bridge left to tear down.
That went well enough that I did the same to retry and pulled it into a standalone helper. The helper had a bug of its own. A retry loop needs to tell a transient failure apart from a request the caller cancelled deliberately, and mine did it by checking the error's name for AbortError. That holds until someone calls controller.abort(new Error("user navigated away")). Now the name is Error, the check says transient, and it makes three attempts at a request that was explicitly cancelled. I measured it doing exactly that.
The predicate could not be fixed where it was. The reliable source of truth for whether something was cancelled on purpose is the AbortSignal, and a generic helper that receives a callback and an error never has the signal in scope. I had moved the decision out of the library and left the information it needed behind. Retry came out completely. Written in userland the same loop is five lines and the signal is right there.
// The caller has the signal in scope, so the loop checks it directly
// instead of interrogating an error the caller controls.
async function withRetry(fn, signal, attempts = 3) {
for (let attempt = 1; ; attempt++) {
try {
return await fn()
} catch (error) {
if (attempt >= attempts || signal.aborted || !transient(error)) throw error
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 100))
}
}
}Reading ofetch
Once the design settled I cloned ofetch and read all 800 lines of it. A mature library in the same problem space has already met the edge cases I had not reached yet, and its choices tell you something whether you agree with them or not.
- Took: trimming the library's own frames from thrown stack traces, so an error points at the caller instead of at internals. One line, and every error the library throws gets quieter.
- Took: accepting a
URLobject as a request target. Nativefetchalready does, and my signature was narrower than the thing it wraps for no reason I could defend. - Refused: lifecycle hooks. Seeing what they cost in a real implementation, a context object threaded through four optional slots, settled the question.
- Refused: silently
JSON.stringify-ing nested query values. MyQuerytype rejects them at compile time, so aDateis an error you see immediately instead of a locale-dependent string you find in production.
Shipping was its own project
The last stretch had nothing to do with HTTP. dist/ was gitignored while files pointed at it, so publishing from a clean checkout would have shipped a package with no code in it. I caught that by running npm publish --dry-run in a fresh clone instead of trusting the config. The name air was already taken on npm, so it went out scoped. npm had removed the 2FA-bypass tokens CI used to rely on, so the release workflow authenticates through OIDC trusted publishing, with no stored credential at all.
Then GitHub Actions had a major outage on release day, the first publish went out manually behind a passkey, and the registry took 144 seconds to propagate metadata while the package page was already rendering.
What is left is 70 tests, seven modules, and a contributing guide that records why each removal happened, including one change I made, tested, and reverted within the hour when a new test proved it wrong. A missing feature leaves no trace in the code, so without that file the next person to read it, probably me, would put it back.