@sirmekus/oku
v1.0.2
Published
A lightweight, framework-agnostic HTTP client built on the native fetch API
Maintainers
Readme
oku
A lightweight, framework-agnostic HTTP client built on the native fetch API.
Features
- Promise-based error handling — rejects on non-2xx responses and network failures, so you use standard
try/catchor.catch(). - Lifecycle hooks —
onStartandonCompletelet you wire in any loading state, spinner, or analytics logic you already have. - Automatic body serialisation —
postdetectsFile/FileListvalues and switches betweenJSON.stringifyandFormDataautomatically. - Bring-your-own headers — no cookies, XSRF tokens, or auth headers are injected. Pass exactly what you need.
- TypeScript-first — all options and return types are fully typed and generic.
When to use oku
oku is a good fit when:
- You want fetch without the boilerplate. You need JSON serialisation, consistent error handling, and typed responses — but not a heavy dependency like axios with its interceptor pipeline and adapter system.
- You're building framework-agnostic code. oku has zero runtime dependencies and works in React, Vue, Svelte, SolidJS, vanilla JS, or any environment that supports the native
fetchAPI (browsers, Node.js 18+, Deno, Bun). - You need predictable error handling. Non-2xx responses reject the promise just like network failures, so every error flows through one
catchpath — no need to manually inspectresponse.ok. - You upload files alongside JSON data. oku detects
File/FileListvalues automatically and switches toFormData, so you never have to setContent-Typeor build aFormDataobject yourself. - You want loading state hooks without a global store.
onStartandonCompletecallbacks let you wire spinners, progress bars, or analytics into any request without coupling to a specific state library. - You're working without a bundler. The IIFE build loads directly from a CDN
<script>tag and exposes everything on a global, making it usable in plain HTML pages and browser extensions. - You need TypeScript generics on the response. Pass a type argument to
get<T>orpost<T>and thedatafield is typed automatically — no casting required.
oku is not the right tool when you need request cancellation (AbortController wiring), automatic retry logic, request deduplication, or a full interceptor pipeline. Reach for a more full-featured client (or your framework's data-fetching layer) in those cases.
oku vs axios
| | oku | axios |
|---|---|---|
| Bundle size | ~1 KB minified | ~10 KB minified |
| Runtime dependencies | Zero | Zero (but heavier core) |
| Underlying transport | Native fetch | XMLHttpRequest / fetch adapter |
| Error handling | Rejects on non-2xx | Rejects on non-2xx |
| File uploads | Auto-detects File/FileList, switches to FormData | Manual FormData construction |
| Loading hooks | Built-in onStart / onComplete | Requires interceptors or wrapper |
| Header injection | Explicit only — nothing injected by default | Auto-injects XSRF tokens, can attach cookies |
| Request interceptors | Not supported | Supported |
| Request cancellation | Not built-in | Built-in |
| CDN / no-bundler usage | IIFE build included | UMD build available |
| TypeScript generics | get<T>() / post<T>() | Full support |
| Node.js support | 18+ (native fetch) | All versions |
Where oku wins
Bundle size. At ~1 KB, oku is roughly 90% smaller than axios. In library code, CDN-served pages, or anywhere bytes matter, this is the single most impactful difference.
Automatic file upload handling. Pass a File or FileList anywhere in data and oku switches to FormData automatically. With axios you construct FormData by hand and manage Content-Type yourself.
// oku — nothing extra needed
await http.post({ url: '/upload', data: { file: fileInput.files[0], label: 'avatar' } });
// axios — manual FormData construction
const fd = new FormData();
fd.append('file', fileInput.files[0]);
fd.append('label', 'avatar');
await axios.post('/upload', fd);No magic header injection. Axios can silently attach XSRF tokens and cookies depending on your environment and config. oku only sends what you explicitly pass — fewer surprises and easier debugging.
Built-in lifecycle hooks. onStart / onComplete work with any state library or plain variables without setting up a global interceptor:
await http.get({
url: '/api/orders',
onStart: () => setLoading(true),
onComplete: () => setLoading(false),
});Pure fetch wrapper. oku doesn't reimplement browser networking — it wraps the platform API directly. Native caching, CORS, and streaming behaviour is preserved exactly.
Where axios wins
- You need request or response interceptors (e.g. to attach a refreshed token on every request).
- You need built-in request cancellation (axios exposes
CancelToken; with oku you wireAbortControlleryourself). - You target Node.js < 18, where native
fetchis unavailable. - You need automatic retry logic or request deduplication out of the box.
Installation
npm / yarn / pnpm
npm install @sirmekus/oku
# or
yarn add @sirmekus/oku
# or
pnpm add @sirmekus/okuScript tag (CDN, no bundler required)
Include the IIFE build from unpkg or jsDelivr. All methods are available on the global HttpClient variable.
<!-- unpkg (latest) -->
<script src="https://unpkg.com/@sirmekus/oku/dist/index.global.js"></script>
<!-- jsDelivr (latest) -->
<script src="https://cdn.jsdelivr.net/npm/@sirmekus/oku/dist/index.global.js"></script>
<!-- Pin to a specific version (recommended for production) -->
<script src="https://unpkg.com/@sirmekus/[email protected]/dist/index.global.js"></script>Once the script is loaded, use HttpClient directly - no import or bundler needed:
<script src="https://unpkg.com/@sirmekus/oku/dist/index.global.js"></script>
<script>
HttpClient.get({ url: '/api/users' })
.then(function (res) {
console.log(res.data);
})
.catch(function (err) {
console.error(err.statusCode, err.data);
});
// Or with async/await (modern browsers)
async function loadUsers() {
try {
const res = await HttpClient.get({ url: '/api/users' });
console.log(res.data);
} catch (err) {
console.error(err.statusCode, err.data);
}
}
</script>ESM alternative — if your page already uses
<script type="module">you can import the ESM build directly from the CDN instead:<script type="module"> import http from 'https://unpkg.com/@sirmekus/oku/dist/index.mjs'; const res = await http.get({ url: '/api/users' }); console.log(res.data); </script>
API Reference
get<T>(options: GetOptions): Promise<ResponseObject<T>>
Performs a GET request.
Resolves with a ResponseObject<T> on a 2xx response.
Rejects with a ResponseObject<T> on a non-2xx response or network failure.
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| url | string | Yes | — | The request URL. |
| headers | Record<string, string> | No | {} | Headers merged on top of { accept: "application/json" }. |
| onStart | () => void | No | — | Called immediately before the request is sent. |
| onComplete | () => void | No | — | Called after the request settles (success or failure). |
| returnEntireResponse | boolean | No | false | When true, data is the full parsed response body. When false, data is response.data ?? response. |
post<T>(options: PostOptions): Promise<ResponseObject<T>>
Performs a POST, PUT, PATCH, or DELETE request.
Resolves with a ResponseObject<T> on a 2xx response.
Rejects with a ResponseObject<T> on a non-2xx response or network failure.
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| url | string | Yes | — | The request URL. |
| headers | Record<string, string> | No | {} | Headers merged on top of defaults. Do not set Content-Type manually for file uploads — the browser sets it with the correct boundary. |
| onStart | () => void | No | — | Called immediately before the request is sent. |
| onComplete | () => void | No | — | Called after the request settles (success or failure). |
| data | Record<string, any> | No | {} | Request payload. Serialised to FormData if any value is a File or FileList, otherwise JSON.stringify'd. |
| method | "POST" \| "PUT" \| "PATCH" \| "DELETE" | No | "POST" | HTTP method. |
rawFetch(url, options?): Promise<Response>
A thin wrapper around native fetch. Returns the raw Response object with no parsing applied. Useful for streaming, blob downloads, or any case where you need full control over the response.
| Parameter | Type | Description |
|---|---|---|
| url | string | The request URL. |
| options | RequestInit & { headers?: Record<string, string> } | Standard fetch init options. headers are merged on top of { accept: "application/json" }. |
ResponseObject<T>
The shape returned (or rejected with) by get and post.
interface ResponseObject<T = any> {
status: "success" | "error";
statusCode: number; // HTTP status code, or 0 for network-level failures
data: T;
}Usage Examples
Basic GET
import http from '@sirmekus/oku';
const res = await http.get({ url: '/api/users' });
console.log(res.data); // the response payloadHandling errors
Errors reject the promise, so handle them with try/catch or .catch().
import http, { ResponseObject } from '@sirmekus/oku';
try {
const res = await http.get({ url: '/api/users/99' });
console.log(res.data);
} catch (err) {
const error = err as ResponseObject;
console.error(error.statusCode); // e.g. 404
console.error(error.data); // server error body
}Wiring up loading state
Pass onStart and onComplete to hook into any state management or UI you already have.
// useState
const [loading, setLoading] = useState(false);
const res = await http.get({
url: '/api/orders',
onStart: () => setLoading(true),
onComplete: () => setLoading(false),
});
// Zustand
import { useLoadingStore } from '@/stores/loadingStore';
const { setLoading } = useLoadingStore.getState();
await http.post({
url: '/api/orders',
data: { item: 'book' },
onStart: () => setLoading(true),
onComplete: () => setLoading(false),
});Global loading state via document events
If multiple parts of your app make requests independently, passing onStart/onComplete callbacks everywhere can be repetitive. An alternative is to dispatch CustomEvents on the document and listen for them in one central place — keeping requests fully decoupled from your loading UI.
Step 1 — register the listeners once (e.g. in your app bootstrap or a layout component):
document.addEventListener('http:start', () => {
document.getElementById('global-spinner').style.display = 'block';
});
document.addEventListener('http:complete', () => {
document.getElementById('global-spinner').style.display = 'none';
});Step 2 — dispatch the events per request:
const httpEvents = {
onStart: () => document.dispatchEvent(new CustomEvent('http:start')),
onComplete: () => document.dispatchEvent(new CustomEvent('http:complete')),
};
// Spread into any request — no extra code needed at the call site
const res = await http.get({ url: '/api/users', ...httpEvents });You can also pass arbitrary detail in the event payload:
document.addEventListener('http:complete', (e) => {
console.log('Request finished:', e.detail.url, e.detail.statusCode);
});
await http.get({
url: '/api/orders',
onStart: () =>
document.dispatchEvent(new CustomEvent('http:start', { detail: { url: '/api/orders' } })),
onComplete: () =>
document.dispatchEvent(new CustomEvent('http:complete', { detail: { url: '/api/orders', statusCode: 200 } })),
});This pattern works in any environment — vanilla JS, React, Vue, Svelte, or a plain HTML page loaded via
<script>tag.
Injecting auth headers
const token = getAuthToken(); // your own logic
const res = await http.get({
url: '/api/profile',
headers: {
Authorization: `Bearer ${token}`,
},
});POST with JSON
const res = await http.post({
url: '/api/login',
data: { email: '[email protected]', password: 'secret' },
});PUT / PATCH / DELETE
await http.post({
url: '/api/users/42',
method: 'PUT',
data: { name: 'Jane Doe' },
});
await http.post({
url: '/api/users/42',
method: 'DELETE',
});File upload
Any File or FileList value in data triggers automatic FormData serialisation. Do not set Content-Type manually — the browser must set it so the multipart boundary is included.
// Single file
await http.post({
url: '/api/avatar',
data: { avatar: fileInput.files[0] },
});
// Multiple files under the same key
await http.post({
url: '/api/attachments',
data: { files: fileInput.files }, // FileList
});
// Mixed payload
await http.post({
url: '/api/documents',
data: {
title: 'My Report',
category: 'finance',
file: fileInput.files[0],
},
});Raw fetch
Use rawFetch when you need the native Response object, e.g. for blob downloads or streaming.
import { rawFetch } from '@sirmekus/oku';
const res = await rawFetch('/api/export/csv', {
headers: { Authorization: `Bearer ${token}` },
});
const blob = await res.blob();Typing the response
Pass a type argument to get a typed data field.
interface User {
id: number;
name: string;
email: string;
}
const res = await http.get<User>({ url: '/api/users/1' });
// res.data is now typed as User
console.log(res.data.name);Notes
accept: application/jsonis always set. Override it by passing your ownacceptkey inheaders.Content-Typeis set toapplication/jsonautomatically for non-file POST payloads. ForFormDatapayloads it is intentionally omitted so the browser can include the multipart boundary.- All requests use
redirect: "manual". Redirects are not followed automatically. - No credentials, cookies, or XSRF tokens are handled. Inject them via
headersif needed. - A
statusCodeof0in a rejectedResponseObjectindicates a network-level failure (e.g. no internet, DNS failure) where no HTTP response was received.
