misina
Driver-based, zero-dependency TypeScript HTTP client.
Hooks lifecycle, retry with Retry-After, error taxonomy, redirect header policy. Pure TypeScript, works everywhere.
- Highlights
- Install
- Quick Start
- API
- Subpaths
- misina/test
- misina/auth
- misina/cookie
- misina/cache
- misina/dedupe
- misina/paginate
- misina/poll
- misina/stream
- misina/breaker
- misina/ratelimit
- misina/tracing
- misina/runtime/cloudflare
- misina/runtime/bun
- misina/runtime/deno
- misina/digest
- misina/transfer
- misina/auth/oauth
- misina/auth/sigv4
- misina/auth/signed
- misina/otel
- misina/sentry
- misina/beacon
- misina/graphql
- misina/hedge
- Recipes
- Benchmarks
- Idempotency-Key
- RFC 9457 problem+json
- Fetch Priority
- Progress Events
- meta — per-request user data
- state — session-scoped mutable state
- onComplete — terminal lifecycle hook
- trailingSlash + allowedProtocols
- defer — Late-Binding Config
- Type-Safe Path Generics
- OpenAPI
- Standard Schema Validation
- Security Defaults
- Credits
- Zero deps in the core. Optional peers only.
- ESM-only, tree-shakeable, sub-path exports for everything beyond the core.
- Driver pattern — swap the transport. Default driver wraps
globalThis.fetch; ship a mock or your own. - Hooks lifecycle —
init,beforeRequest,beforeRetry,beforeRedirect,afterResponse,beforeError. Default + per-request hooks concatenate. - Retry with
Retry-After/RateLimit-Resetparsing, jitter,backoffLimit, customshouldRetry.NetworkErrorretried independently fromHTTPError. - Redirect policy — RFC 9110 §15.4 compliant. Manual follow with cross-origin auth/cookie stripping by default.
https → httpdowngrade refused. validateResponse— sync or async predicate sees status + parsed body, lets200 { ok: false }count as failure.- Standard Schema support for runtime validation (zod, valibot, arktype).
- OpenAPI — type-only adapter from
openapi-typescriptoutput to misina's typed API. - Streaming — built-in SSE (WHATWG HTML §9.2 compliant) and NDJSON helpers.
- HTTP cache — RFC 9111 compliant:
Cache-Control: no-store/max-age, ETag / Last-Modified revalidation,Varyper-variant keying. - Cookie jar — RFC 6265 compliant: domain match check, Path matching, Secure flag, Max-Age / Expires.
- 820 tests across 115 files, exhaustively covering specs and edge cases.
- Subpath helpers:
auth,auth/oauth,auth/sigv4,auth/signed,beacon,breaker,cache,cookie,dedupe,digest,graphql,hedge,otel,paginate,poll,ratelimit,runtime/{bun,cloudflare,deno,next},sentry,stream,test,tracing,transfer. - Idempotency-Key on retry (RFC draft) —
idempotencyKey: 'auto'sends acrypto.randomUUID()for retried mutations. No competitor ships this. - RFC 9457 problem+json parsed onto
HTTPError.problemautomatically. - Circuit breaker (
misina/breaker) — Polly-shaped state machine, zero deps. - Polling helper (
misina/poll) —untilpredicate + interval + composed timeout/abort. safe()mode — Go-style{ ok, data, error, response }discriminated result, no throw.HTTPError<E>typed error body,meta+statefor per-instance context,onCompletefor unified observability.- HTTP
QUERYmethod (draft-ietf-httpbis-safe-method-w-body) shipped asmisina.query(). - Opt-in decompression (
decompress: true | string[]) — gzip / deflate / br / zstd viaDecompressionStream. bodyTimeout— independent cap on response-body read time for slow-streaming servers.maxResponseSize— byte cap withContent-Lengthfast-path + mid-stream counter; throwsResponseTooLargeError.requestIdonMisinaResponseandHTTPError— auto-scanned fromx-request-id/request-id/x-correlation-id. Surfaced in error message as[req: <id>].- LLM SDK retry parity —
retry-after-ms(sub-second precision) +x-should-retryserver hints honored by default. Server-Timingparser —MisinaResponse.serverTimingspopulated automatically.- W3C Trace Context (
misina/tracing) —withTracing()injectstraceparent+tracestate+ optional Baggage. - Rate-limit header parser (
misina/ratelimit) — handles OpenAI / Anthropic / IETF draft styles, normalizes reset values toDate. - HTTP cache extras — RFC 5861
stale-while-revalidate+stale-if-error, RFC 8246immutable, plus an RFC 9211parseCacheStatus()helper backed by the Structured Field Values parser. - SSE reconnect —
sseStreamReconnecting()honorsLast-Event-ID, the server'sretry:field, and exponential backoff across disconnects (HTML §9.2.4). - Request body compression — opt-in
compressRequestBody: 'gzip' | 'deflate'symmetrical with the response-sidedecompressknob. - Cookie jar across redirects —
Set-Cookieissued by intermediate 30x hops is persisted (login flows that set the session on the redirect). - Manual
composeSignals— fixes the Node #57736 listener-leak when long-lived AbortSignals are shared across many requests. - RFC 9530 digest (
misina/digest) —withDigest()addsContent-Digest/Repr-Digestautomatically;verifyDigest()validates incoming responses. - Resumable transfers (
misina/transfer) —downloadResumable()is Range-aware with per-chunk retries;uploadResumable()follows draft-ietf-httpbis-resumable-upload (POST + PATCH withUpload-Offset). - OAuth helpers (
misina/auth/oauth) —withJwtRefresh()peeksexpand refreshes preemptively (single-flight);createPkcePair()+exchangePkceCode()for PKCE flows. - AWS SigV4 signer (
misina/auth/sigv4) —withSigV4()addsAuthorization: AWS4-HMAC-SHA256 ...+x-amz-date+x-amz-content-sha256to every request via Web Crypto. No@aws-sdk/*peer dep. - RFC 9421 HTTP Message Signatures (
misina/auth/signed) —withMessageSignature()covers Ed25519 / ECDSA P-256 / RSA-PSS / HMAC-SHA256. Cloudflare Verified Bots / OpenAI Operator pattern. - OpenTelemetry spans (
misina/otel) —withOtel()emits HTTP client spans with semconv attributes; tracer is duck-typed so misina never imports@opentelemetry/*. - Undici driver (
misina/driver/undici) — Node-only optional driver that takes anyundici.Agent/Pool/Clientso callers can tune connection pool, keep-alive, pipelining, and HTTP/2 multiplexing. - node:http2 driver (
misina/driver/http2) — zero-dep alternative for environments that can't ship undici. Multiplexes streams over one session per origin; auto-reconnects onGOAWAY. - VCR-lite test helpers —
record()+recordToJSON()+replayFromJSON()round-trip cassettes;harToCassette()imports HAR;coverage()flags unused routes;randomStatus/randomNetworkErrorfor chaos;misinaCallSerializerredacts volatile headers in Vitest snapshots. - Typed runtime knobs —
misina/runtime/{bun,deno,cloudflare,next}augmentMisinaOptionswith runtime-specific fields (tls,client,cf,next).
From npm:
pnpm add misina
# or
npm install misina
# or
bun add misinaFrom JSR:
deno add jsr:@productdevbook/misina
# or
bunx jsr add @productdevbook/misina
# or
npx jsr add @productdevbook/misinaNote: the JSR build skips the four
misina/runtime/*subpaths (bun,cloudflare,deno,next). They use TypeScript ambient module augmentation (declare module) which JSR doesn't accept; npm callers get them as usual. JSR users who want runtime-specific typed knobs can paste theinterface MisinaRuntimeOptions { ... }declaration into their own project — it's the same shape published to npm.
Requires Node ≥ 22.11 / Bun ≥ 1.2 / Deno ≥ 2.0 / Baseline 2024 browsers (Safari 17.4+, Chrome 116+, Firefox 124+). Uses native AbortSignal.any, AbortSignal.timeout, and Headers.getSetCookie() — no polyfills.
import { createMisina } from "misina"
const api = createMisina({
baseURL: "https://api.github.com",
headers: { accept: "application/vnd.github+json" },
timeout: 10_000,
retry: 2,
})
// GET — typed
const user = await api.get<{ login: string }>("/users/octocat")
console.log(user.data.login, user.timings.total)
// POST with auto-JSON
await api.post("/repos/octocat/hello/issues", {
title: "hi",
body: "test",
})
// Error handling — classic try/catch
import { isHTTPError } from "misina"
try {
await api.get("/nope")
} catch (err) {
if (isHTTPError(err)) console.log(err.status, err.data)
}
// Error handling — Go-style, type-safe, no throw
const result = await api.safe.get<User, ApiError>("/users/42")
if (result.ok) {
result.data // User
} else {
result.error // HTTPError<ApiError> | NetworkError | TimeoutError
}import { createMisina } from "misina"
const api = createMisina({
// URL resolution
baseURL: "https://api.example.com",
allowAbsoluteUrls: true, // reject if false + absolute URL given
allowedProtocols: ["http", "https"], // add 'capacitor', 'tauri', etc.
trailingSlash: "preserve", // 'strip' | 'forbid'
// Headers + body + query
headers: {
/* ... */
}, // Headers / [k,v][] / Record<string, string|undefined>
arrayFormat: "repeat", // 'brackets' | 'comma' | 'indices'
paramsSerializer: undefined,
parseJson: JSON.parse,
stringifyJson: JSON.stringify,
// Lifecycle
timeout: 10_000, // per-attempt; false to disable
totalTimeout: false, // wall-clock cap incl. retries
signal: someAbortSignal,
retry: 2, // number | false | RetryOptions
responseType: undefined, // 'json' | 'text' | 'arrayBuffer' | 'blob' | 'stream'
// Hooks + drivers
hooks: {
/* init / beforeRequest / beforeRetry / beforeRedirect /
afterResponse / beforeError / onComplete */
},
driver: customDriver, // default: fetch driver
defer: [], // late-binding callbacks
// Errors
throwHttpErrors: true,
validateResponse: undefined,
// Redirect policy
redirect: "manual", // 'follow' | 'error'
redirectSafeHeaders: undefined, // headers to keep on cross-origin redirect
redirectMaxCount: 5,
redirectAllowDowngrade: false, // https → http allowed?
// Modern features
idempotencyKey: false, // 'auto' | string | (req) => string | false
priority: undefined, // 'high' | 'low' | 'auto'
meta: {
/* per-request typed user data */
},
state: {
/* session-scoped mutable state */
},
// Framework / runtime passthrough
cache: undefined, // RequestCache
credentials: undefined,
next: undefined, // Next.js { revalidate, tags }
// Progress
onUploadProgress: undefined,
onDownloadProgress: undefined,
progressIntervalMs: 0, // throttle ms between callbacks
})Returns Misina with: request, get, post, put, patch, delete,
head, options, query (HTTP QUERY method, draft-ietf-httpbis), extend,
plus safe for no-throw variants.
await api.get<User>("/users/42")
await api.post<User, ApiError>("/users", body) // 2nd generic = error body type
await api.delete("/users/42")
await api.query("/search", { filter: { ... } }) // safe + idempotent verb with bodyAll methods return a MisinaResponsePromise<T, E>.
const api = createMisina({
hooks: {
init: (options) => {
// sync, mutates a per-request clone — runs BEFORE Request construction
options.headers.authorization = `Bearer ${getToken()}`
},
beforeRequest: async (ctx) => {
// can return a Request to replace, or a Response to skip the driver
},
beforeRetry: async (ctx) => {
// ctx.error is set; refresh tokens, log, etc.
// Can return a Request (override) or Response (short-circuit retries).
},
beforeRedirect: ({ request, sameOrigin }) => {
// fired when redirect: 'manual' (default) follows a redirect
},
afterResponse: async (ctx) => {
// can return a new Response to replace
},
beforeError: async (error, ctx) => {
// must return an Error (transformed or original)
return error
},
onComplete: ({ request, response, error, durationMs, attempt }) => {
// terminal-state hook — fires once per call after retries
// single observation point for logging / metrics / tracing
},
},
})Hook errors are fatal — they don't trigger retry. Default and per-request hooks concatenate (defaults run first).
const api = createMisina({
retry: {
limit: 3,
methods: ["GET", "PUT", "HEAD", "DELETE", "OPTIONS"],
statusCodes: [408, 413, 429, 500, 502, 503, 504],
afterStatusCodes: [413, 429, 503], // honor Retry-After / RateLimit-Reset
maxRetryAfter: 60_000,
delay: (attempt) => 0.3 * 2 ** (attempt - 1) * 1000,
backoffLimit: 30_000,
jitter: true,
shouldRetry: ({ error }) => true, // ultimate escape hatch
retryOnTimeout: true,
},
})
// Shorthand: number → { limit }
createMisina({ retry: 5 })
// false → disabled
createMisina({ retry: false })POST is not retried by default (idempotency).
beforeRetry may return a Request to replace the URL on the next
attempt — useful for multi-region inference, alternative endpoints,
fallback DNS, etc. The attempt counter is 1-indexed for retries.
const REGIONS = [
"https://us-east.example.com",
"https://us-west.example.com",
"https://eu.example.com",
]
createMisina({
retry: { limit: 2, statusCodes: [502, 503, 504] },
hooks: {
beforeRetry: ({ request, attempt }) => {
const next = REGIONS[attempt % REGIONS.length]
const u = new URL(request.url)
u.host = new URL(next).host
return new Request(u.toString(), request)
},
},
})import {
HTTPError,
NetworkError,
TimeoutError,
isHTTPError,
isNetworkError,
isTimeoutError,
} from "misina"
try {
await api.get("/x")
} catch (err) {
if (isHTTPError(err)) {
console.log(err.status, err.data, err.response)
}
if (isNetworkError(err)) console.log(err.cause)
if (isTimeoutError(err)) console.log(err.timeout)
}const user = await api
.get<User>("/users/42")
.onError(404, () => null)
.onError([401, 403], () => redirect("/login"))
.onError("NetworkError", () => useCachedFallback())For UI code where a try/catch widens the catch to unknown, use the
no-throw companion. Every shorthand mirrors onto misina.safe:
const result = await api.safe.get<User, ApiError>("/users/42")
if (result.ok) {
result.data // User — type-safe
result.response // MisinaResponse<User>
} else {
result.error // HTTPError<ApiError> | NetworkError | TimeoutError
result.response?.status // available on HTTPError; undefined on network errors
}The discriminated { ok, data, error, response } union makes both branches
type-safe at the call site — no try/catch plumbing needed.
Treat 200 { ok: false } as failure:
const api = createMisina({
validateResponse: ({ data }) => (data as { ok: boolean }).ok === true,
})Return an Error to throw a custom error directly.
createMisina({
parseJson: (text) =>
JSON.parse(text, (k, v) =>
typeof v === "string" && /^\d{4}-\d{2}-\d{2}T/.test(v) ? new Date(v) : v,
),
stringifyJson: (value) =>
JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v)),
})import { replaceOption } from "misina"
const authed = api.extend({ headers: { authorization: "Bearer x" } })
// Replace defaults' hooks instead of concatenating
const standalone = api.extend({
hooks: replaceOption({ beforeRequest: [myHook] }),
})
// Function form sees parent defaults
const v2 = api.extend((parent) => ({
baseURL: parent.baseURL?.replace("/v1", "/v2"),
}))import { createMisina, defineDriver } from "misina"
import mockDriver from "misina/driver/mock"
const driver = defineDriver(() => ({
name: "custom",
request: async (req) => fetch(req),
}))()
createMisina({ driver })
// Mock for tests
const mock = mockDriver({ response: new Response(JSON.stringify({ ok: 1 })) })
const test = createMisina({ driver: mock })For high-throughput Node servers (cross-region inference, datawarehouse RPCs, internal mesh traffic) the built-in fetch hides every knob behind its default agent — five idle connections, no HTTP/2, no keep-alive tuning. Swap in the undici driver to take control:
import { Agent } from "undici"
import { undiciDriver } from "misina/driver/undici"
const api = createMisina({
driver: undiciDriver({
dispatcher: new Agent({
connections: 100,
keepAliveTimeout: 30_000,
pipelining: 1,
allowH2: true, // HTTP/2 multiplexing
}),
}),
baseURL: "https://inference.example.com",
})undici is declared as an optional peer dependency — install it
yourself (npm i undici) only if you use this driver. Misina lazy-
imports undici on first call, so the rest of the package keeps its
zero-dep footprint. Switch back to the default fetch driver any
time without changing the rest of your code.
For environments that can't ship undici (locked-down profiles, custom
dispatcher logic), the node:http2 driver multiplexes streams over a
single ClientHttp2Session per origin and auto-reconnects on
GOAWAY frames or session errors:
import { http2Driver } from "misina/driver/http2"
const api = createMisina({
driver: http2Driver({
sessionIdleTimeoutMs: 30_000, // close idle sessions after 30s
}),
baseURL: "https://h2.example.com",
})Uses Node's built-in node:http2 (dynamic-imported on first call), so
nothing extra to install. For most workloads misina/driver/undici is
a better default — this driver covers the slim slice where undici
isn't an option or callers need direct session access.
Each helper lives at misina/<name> so you only pay for what you import.
import { createTestMisina } from "misina/test"
const t = createTestMisina({
routes: {
"GET /users/:id": ({ params }) => ({ status: 200, body: { id: params.id } }),
"POST /users": ({ request }) => ({ status: 201, body: { ok: true } }),
"GET /flaky": () => ({ throw: "fetch failed" }), // simulate NetworkError
"* /slow": () => ({ delay: 200, status: 200 }),
},
})
await t.client.get("https://api.test/users/42")
expect(t.calls).toHaveLength(1)
expect(t.lastCall().method).toBe("GET")
// Coverage report — which routes were actually exercised?
const cov = t.coverage()
// ^? { matched: string[]; unused: string[]; unmatched: MockCall[] }
// Chaos handlers
import { randomStatus, randomNetworkError } from "misina/test"
createTestMisina({
routes: {
"GET /flaky": randomStatus([200, 200, 503]),
"GET /down": randomNetworkError("connection reset"),
},
})
// Record / replay (VCR-lite, no fs dep)
import { record, recordToJSON, replayFromJSON, harToCassette } from "misina/test"
// 1. Record against a real driver, save the cassette
const real = createMisina({ baseURL })
const { client, calls } = record(real)
await runTests(client)
const cassette = await recordToJSON(calls)
fs.writeFileSync("fixtures.json", JSON.stringify(cassette))
// 2. Replay forever (or import a HAR exported by Chrome / Playwright)
const handler = replayFromJSON(JSON.parse(fs.readFileSync("fixtures.json", "utf8")))
// or replayFromJSON(harToCassette(harJson))
const t2 = createTestMisina({ routes: { "GET /:p": handler } })
// Vitest snapshot serializer (redacts authorization, idempotency-key,
// traceparent, etc. so snapshots compare cleanly across runs).
import { misinaCallSerializer } from "misina/test"
expect.addSnapshotSerializer(misinaCallSerializer())import { withBearer, withBasic, withRefreshOn401, withCsrf } from "misina/auth"
const api = withBearer(createMisina({ baseURL }), () => store.token)
const refreshed = withRefreshOn401(api, {
refresh: async () => fetchNewToken(),
})
const django = withCsrf(api, { cookieName: "csrftoken", headerName: "X-CSRFToken" })withRefreshOn401 collapses concurrent 401s into a single in-flight refresh.
import { withCookieJar, MemoryCookieJar } from "misina/cookie"
const jar = new MemoryCookieJar()
const api = withCookieJar(createMisina({ baseURL }), jar)
await api.post("/login", { user, pass }) // Set-Cookie stored
await api.get("/profile") // Cookie sent automaticallyimport { withCache, memoryStore, parseCacheControl, parseCacheStatus } from "misina/cache"
const api = withCache(createMisina({ baseURL }), {
store: memoryStore({ max: 500 }),
ttl: 60_000,
revalidate: true, // ETag / Last-Modified → 304 → reuse
honorCacheControl: true, // max-age, s-w-r, s-i-e, immutable, no-store
})
// RFC 9111 + RFC 5861 + RFC 8246 directives are honored:
// - `Cache-Control: stale-while-revalidate=N` → serve stale + revalidate in background
// - `Cache-Control: stale-if-error=N` → serve stale on 5xx within window
// - `Cache-Control: immutable` → skip ETag/If-None-Match revalidation
// Standalone helpers (no Misina required):
const cc = parseCacheControl(res.headers.get("cache-control"))
// ^? { maxAge?: number; staleWhileRevalidate?: number; immutable?: boolean; ... }
const status = parseCacheStatus(res.headers.get("cache-status")) // RFC 9211
// ^? Array<{ cache: string; hit?: boolean; fwd?: string; ttl?: number; ... }>import { withDedupe } from "misina/dedupe"
const api = withDedupe(createMisina({ baseURL }))
// Concurrent identical GETs collapse onto one network request.import { paginate, paginateAll } from "misina/paginate"
// Default: follow Link rel=next
for await (const user of paginate<User>(api, "/users")) {
console.log(user.id)
}
// Cursor-based
const all = await paginateAll<Item>(api, "/items", {
transform: (res) => res.data.items,
next: (res) => (res.data.next ? { query: { cursor: res.data.next } } : false),
countLimit: 1000,
})Long-poll a URL until a predicate is satisfied. Composes external + timeout
signals via AbortSignal.any.
import { poll, PollExhaustedError } from "misina/poll"
const job = await poll<JobStatus>(misina, "/jobs/42", {
interval: 1000, // ms — or fn(attempt) => ms
timeout: 60_000, // total deadline (TimeoutError on exceed)
maxAttempts: 30, // throws PollExhaustedError when reached
signal: external, // composes with timeout
until: (job) => job.state === "done",
init: { headers: { ... } }, // forwarded to misina.get
})followAccepted covers the common 202 + Location async-job pattern:
import { followAccepted } from "misina/poll"
const result = await followAccepted<JobResult>(misina, {
trigger: () => misina.post("/jobs", body), // returns 202 + Location
interval: 2000,
timeout: 5 * 60_000,
until: (data) => data.status === "completed",
})import { sseStream, ndjsonStream, sseStreamReconnecting } from "misina/stream"
const res = await api.get("/events", { responseType: "stream" })
for await (const event of sseStream(res.raw)) {
console.log(event.event, event.data)
}
const res2 = await api.get("/feed.ndjson", { responseType: "stream" })
for await (const item of ndjsonStream<Item>(res2.raw)) {
console.log(item)
}
// Long-running SSE: reconnects across disconnects, sets Last-Event-ID,
// honors the server's `retry:` field, exponential backoff fallback
// (HTML §9.2.4 EventSource semantics).
for await (const event of sseStreamReconnecting(api, "/notifications", {
reconnectDelayMs: 1_000,
maxDelayMs: 60_000,
})) {
console.log(event.id, event.data)
}LLM tool-call accumulators ship from the same subpath:
import { accumulateAnthropicMessage, accumulateOpenAIToolCalls, collect } from "misina/stream"
// OpenAI: drains the SSE stream and merges delta.tool_calls[] indexed
// by `index`; stops at [DONE].
const calls = await accumulateOpenAIToolCalls(sseStream(res.raw))
// Anthropic: drains a Messages stream (named events) and assembles
// the final message with text + tool_use blocks.
const message = await accumulateAnthropicMessage(sseStream(res.raw))
// Generic Array.reduce for async iterables — building block.
const total = await collect(sseStream(res.raw), (n) => n + 1, 0)Streams (and paginate, poll) implement [Symbol.asyncDispose] so
TC39 explicit resource management works:
{
await using stream = sseStream(res.raw)
for await (const ev of stream) {
/* ... */
}
} // stream auto-aborted on scope exitPolly / cockatiel-shaped circuit breaker. State machine:
closed ──[N failures within windowMs]──▶ open
open ──[wait halfOpenAfter]──▶ half-open (one probe allowed)
half-open ──[probe ok]──▶ closed
half-open ──[probe fails]──▶ open (fresh timer)
import { withCircuitBreaker, CircuitOpenError } from "misina/breaker"
const api = withCircuitBreaker(misina, {
failureThreshold: 5, // trip after 5 failures
windowMs: 30_000, // sliding window
halfOpenAfter: 10_000, // ms before letting one probe through
// isFailure defaults to: any thrown error or 5xx HTTPError.
// 4xx is intentionally NOT counted (client mistake, not service degradation).
})
try {
await api.get("/users/42")
} catch (err) {
if (err instanceof CircuitOpenError) {
console.log("upstream cooked — retry in", err.retryAfter, "ms")
}
}
// Inspect / control the breaker:
api.breaker.state() // 'closed' | 'open' | 'half-open'
api.breaker.trip() // force open (e.g. external monitoring signal)
api.breaker.reset() // force closedNo major fetch client (ofetch, ky, axios, got, wretch) ships a built-in
breaker — users had to wrap with cockatiel/opossum. This subpath fits
naturally with misina's driver pattern and adds zero deps.
Parser for x-ratelimit-* headers + an in-process token bucket.
import { parseRateLimitHeaders, withRateLimit } from "misina/ratelimit"
// Read what the server says.
const info = parseRateLimitHeaders(response.headers)
console.log(info?.requests?.remaining, info?.tokens?.remaining)
// Or wire a client-side limiter that gates dispatch and learns from
// the response headers automatically:
const api = withRateLimit(createMisina({ baseURL }), {
rpm: 500,
tpm: 100_000,
estimateTokens: (req) => approximateInputTokens(req),
})withRateLimit acquires from both buckets in beforeRequest, snaps
the buckets to the server's x-ratelimit-remaining-* numbers in
onComplete, and parks both until resetAt on a 429. AbortSignal
cancels a queued acquire.
Reset values normalize to Date: ISO 8601, Unix seconds (absolute or
seconds-from-now via 100k threshold), and duration suffix ('500ms',
'30s', '1m30s', '2h15m').
W3C Trace Context propagator. Auto-injects traceparent and forwards
tracestate on every outgoing request. Optional W3C Baggage header.
import { withTracing } from "misina/tracing"
const api = withTracing(createMisina({ baseURL }))
// Each request gets a fresh `traceparent: 00-<32hex>-<16hex>-01`.
// Compose with OpenTelemetry by reading the active span:
import { trace } from "@opentelemetry/api"
const apiOtel = withTracing(createMisina({ baseURL }), {
getCurrentSpan: () => {
const span = trace.getActiveSpan()
if (!span) return null
const ctx = span.spanContext()
return { traceId: ctx.traceId, parentId: ctx.spanId, flags: ctx.traceFlags }
},
baggage: { tenant: "acme", env: "prod" },
})Caller-supplied traceparent / Baggage headers are preserved (no
overwrite). Each request gets a new parent-id; each Baggage callback is
evaluated per-request when supplied as a function.
Type-only augmentation for Cloudflare Workers. Importing the module
narrows MisinaOptions.cf to the documented cf property bag
(cacheTtl, cacheKey, cacheEverything, polish, image, etc.).
The value is forwarded opaquely to the underlying fetch so workerd
acts on it.
import "misina/runtime/cloudflare"
await api.get("/asset", { cf: { cacheTtl: 86_400, cacheEverything: true } })import "misina/runtime/bun"
await api.get("/upstream", {
tls: { rejectUnauthorized: false, serverName: "internal.test" },
proxy: "http://corp:3128",
unix: "/var/run/api.sock",
verbose: true,
})tls, proxy, unix, verbose are forwarded opaquely to Bun's
fetch.
import "misina/runtime/deno"
const client = Deno.createHttpClient({ caCerts: [pem] })
await api.get("/upstream", { client })client is forwarded as init.client so Deno's fetch uses your
custom Deno.HttpClient (custom CA bundles, mTLS, proxies, HTTP/2
pool tweaks).
withDigest(misina, opts?) automatically adds Content-Digest (RFC 9530) on outgoing requests with a body. verifyDigest(response)
validates an incoming response and throws DigestMismatchError on
failure.
import { withDigest, verifyDigest } from "misina/digest"
const api = withDigest(createMisina({ baseURL }), { algorithm: "sha-256" })
const res = await api.post("/upload", body) // Content-Digest: sha-256=:...:
await verifyDigest(res.raw) // throws DigestMismatchError on mismatchsha-256 and sha-512 supported via Web Crypto. Repr-Digest
available via field: "repr-digest".
Two helpers for moving large files:
import { downloadResumable, uploadResumable } from "misina/transfer"
// Range-aware download, resumes after network failure per chunk.
const { blob } = await downloadResumable(misina, "/files/big.bin", {
chunkSize: 4 * 1024 * 1024,
onProgress: ({ loaded, total }) => render(loaded, total),
})
// draft-ietf-httpbis-resumable-upload: POST opens, PATCH chunks,
// HEAD recovers the offset to resume.
const { uploadUrl } = await uploadResumable(misina, "/uploads", file, {
chunkSize: 4 * 1024 * 1024,
onProgress: ({ loaded, total }) => render(loaded, total),
})Range download falls back to a single streaming GET when the server
doesn't advertise Accept-Ranges: bytes. Resumable upload reissues
HEAD <uploadUrl> to recover the server-known offset when
uploadUrl is provided from a previous attempt.
import { createPkcePair, exchangePkceCode, withJwtRefresh } from "misina/auth/oauth"
// 1. PKCE flow
const pair = await createPkcePair() // { verifier, challenge, method: 'S256' }
window.location = `${authEndpoint}?response_type=code&code_challenge=${pair.challenge}&code_challenge_method=S256&...`
// callback:
const tokens = await exchangePkceCode(misina, {
tokenEndpoint,
clientId,
redirectUri,
code,
verifier: pair.verifier,
})
// 2. Proactive refresh (peeks JWT exp, single-flight under load)
const api = withJwtRefresh(misina, {
getToken: () => store.token,
refresh: async () => {
const t = await refreshTokens()
store.token = t.access_token
return t.access_token
},
expiryWindowMs: 30_000,
})import { withSigV4 } from "misina/auth/sigv4"
const api = withSigV4(
createMisina({
baseURL: "https://bedrock-runtime.us-east-1.amazonaws.com",
}),
{
service: "bedrock-runtime",
region: "us-east-1",
credentials: async () => fromEnvOrIam(), // any provider returning { accessKeyId, secretAccessKey, sessionToken? }
},
)
// Every request now carries Authorization: AWS4-HMAC-SHA256 ...,
// x-amz-date, x-amz-content-sha256, and (when present) x-amz-security-token.Streaming uploads: pass unsignedPayload: true to use
x-amz-content-sha256: UNSIGNED-PAYLOAD instead of buffering the body
to hash it. signRequest(request, opts) is exported separately for
one-off signing without wrapping the misina instance.
import { withMessageSignature } from "misina/auth/signed"
// 1. Asymmetric: Web Crypto Ed25519 keypair
const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"])
const api = withMessageSignature(createMisina({ baseURL }), {
keyId: "my-bot",
algorithm: "ed25519",
privateKey: pair.privateKey,
components: ["@method", "@target-uri", "@authority", "content-type", "content-digest"],
})
// 2. Shared secret: HMAC-SHA256 (raw Uint8Array works)
const api2 = withMessageSignature(createMisina({ baseURL }), {
keyId: "shared",
algorithm: "hmac-sha256",
privateKey: new TextEncoder().encode(process.env.SHARED_SECRET!),
components: ["@method", "@target-uri"],
})Implements RFC 9421 HTTP Message Signatures: builds the signature base
(derived components like @method, @target-uri, @authority,
@scheme, @path, @query, plus regular header values) per
RFC 9421 §2, signs via crypto.subtle, and emits Signature-Input
Signatureheaders. Supported algorithms:ed25519,ecdsa-p256-sha256,rsa-pss-sha512,hmac-sha256. Optionalcreated,expires,nonce,tagparameters supported.
Pairs naturally with misina/digest — sign over content-digest to
get end-to-end body integrity. The Signature and Signature-Input
headers are stripped on cross-origin redirects (RFC 9421 leak
prevention).
import { trace } from "@opentelemetry/api"
import { withOtel } from "misina/otel"
const api = withOtel(createMisina({ baseURL }), {
tracer: trace.getTracer("my-service"),
// optional:
spanName: (req) => `http.${req.method.toLowerCase()} ${new URL(req.url).pathname}`,
attributes: { "deployment.environment": process.env.NODE_ENV },
})Emits one OTel HTTP client span per request (SpanKind.CLIENT) with the
standard semconv attributes — http.request.method, url.full,
url.scheme, server.address, server.port, network.protocol.name
on start; http.response.status_code on completion. Errors call
span.recordException(err) and set status ERROR. traceparent is
auto-injected from the active span context; pass
injectTraceparent: false when withTracing is already in the chain
to avoid double injection.
Peer-dep duck-typed: anything matching the minimal { startSpan }
shape works — the real Tracer from @opentelemetry/api, an in-
memory exporter for tests, or your own wrapper. misina never imports
@opentelemetry/*.
import * as Sentry from "@sentry/browser"
import { withSentry } from "misina/sentry"
const api = withSentry(createMisina({ baseURL }), {
Sentry,
captureLevel: "error", // 'all' | 'error' (skip 4xx, default) | '5xx'
redactHeaders: ["authorization", "cookie", "x-api-key"],
successBreadcrumb: true, // add a breadcrumb on every 2xx
})Captures HTTPError, NetworkError, and TimeoutError to Sentry with
the originating request as context (request.method, request.url,
redacted headers, response status, requestId tag). No peer dependency —
pass anything that satisfies the minimal { captureException, addBreadcrumb? } shape (@sentry/browser, @sentry/node,
@sentry/core, or your own wrapper).
import { beacon } from "misina/beacon"
window.addEventListener("pagehide", () => {
beacon("/telemetry", { event: "pagehide", t: Date.now() })
})Fire-and-forget telemetry for page-unload moments. Tries fetchLater
first (Pending Beacon API, Chromium), falls back to fetch with
keepalive: true, and finally to navigator.sendBeacon. Returns
{ ok: true, via: 'fetchLater' | 'fetch-keepalive' | 'sendBeacon' }
on success or { ok: false, reason } so callers can record which path
actually ran.
import { withGraphql } from "misina/graphql"
const gql = withGraphql(createMisina({ baseURL }), {
endpoint: "/graphql",
persistedQueries: true, // Apollo APQ (SHA-256, GET fallback for short queries)
})
const data = await gql.query<{ user: { id: string } }>(
/* GraphQL */ `
query U($id: ID!) {
user(id: $id) {
id
}
}
`,
{ id: "42" },
)gql.query and gql.mutate send the standard { query, variables, operationName } envelope. With persistedQueries: true the client
sends only the hash on the first attempt and falls back to attaching
the full query when the server replies PersistedQueryNotFound
(Apollo APQ protocol). GraphQL errors[] collapse into a typed
GraphqlAggregateError so the success path always sees data.
import { hedge } from "misina/hedge"
// Race three replicas; the first to return wins, the others are aborted.
const data = await hedge<User>(misina, "/users/42", {
endpoints: [
"https://api-eu.example.com",
"https://api-us.example.com",
"https://api-ap.example.com",
],
delayMs: 75, // start replicas 1+ after this delay (Google "tail at scale")
max: 3, // cap simultaneous in-flight attempts
})Implements the Dean & Barroso CACM 2013 hedged-request pattern: the
helper dispatches against endpoints[0] immediately and stages the
remaining endpoints delayMs apart. The first response settles the
promise and aborts every loser via their own AbortController. Loser
errors surface as HedgeLoserError and are filtered out of the final
error report.
Every MisinaResponse carries a parsed serverTimings array (W3C
Server-Timing). Empty when the header is absent.
const r = await api.get("/")
for (const t of r.serverTimings) {
console.log(t.name, t.dur, t.desc)
}
// Or parse from any Headers manually:
import { parseServerTiming } from "misina"
const entries = parseServerTiming(headers.get("server-timing"))Misina composes with the modern web framework stack — every recipe below is end-to-end (no extra glue beyond what's shown).
import { QueryClient, useQuery } from "@tanstack/react-query"
import { createMisina, HTTPError } from "misina"
const api = createMisina({ baseURL: "/api", retry: 0 })
function useUser(id: string) {
return useQuery<User, HTTPError<{ message: string }>>({
queryKey: ["user", id],
queryFn: ({ signal }) => api.get<User>(`/users/${id}`, { signal }).then((r) => r.data),
})
}signal from TanStack cancels the request when the component unmounts
or the query is invalidated. Errors come back already typed as
HTTPError<E> so the error UI can branch on error.status /
error.problem / error.requestId.
import useSWR from "swr"
import { createMisina } from "misina"
const api = createMisina({ baseURL: "/api" })
const fetcher = <T>(url: string): Promise<T> => api.get<T>(url).then((r) => r.data)
function User({ id }: { id: string }) {
const { data, error } = useSWR<User>(`/users/${id}`, fetcher)
// ...
}For Suspense + ErrorBoundary mode, return the promise directly:
fetcher: (u) => api.get(u).then((r) => r.data). SWR's deduplication
pairs naturally with misina/dedupe when the same misina instance is
shared across hooks.
// app/lib/api.ts
import "misina/runtime/next" // type-only augmentation for { next: { revalidate, tags } }
import { createMisina } from "misina"
export const api = createMisina({
baseURL: process.env.API_URL,
})
// app/users/[id]/page.tsx
export default async function Page({ params }: { params: { id: string } }) {
const user = await api.get<User>(`/users/${params.id}`, {
next: { revalidate: 60, tags: [`user:${params.id}`] },
})
return <h1>{user.data.name}</h1>
}
// Mutation handler
"use server"
import { revalidateTag } from "next/cache"
import { onTagInvalidate } from "misina/runtime/next"
const apiWithInvalidate = onTagInvalidate(api, revalidateTag)
// Now any 2xx response with `{ next: { tags } }` automatically calls
// revalidateTag(...) on the matching tags after the mutation succeeds.misina/runtime/next augments MisinaOptions.next with the official
Next.js shape so TS catches typos in revalidate / tags. Pass the
revalidation cache straight through the fetch options — no wrapping.
// app/routes/users.$id.tsx
import type { LoaderFunctionArgs } from "@remix-run/node"
import { api } from "~/lib/api"
export async function loader({ params, request }: LoaderFunctionArgs) {
// request.signal aborts when the navigation is cancelled.
const res = await api.get<User>(`/users/${params.id}`, {
signal: request.signal,
})
return res.data
}The same pattern fits actions: read request.formData() first, then
hand request.signal to misina so the mutation cancels cleanly when
the user navigates away.
// src/lib/api.ts
import { createMisina } from "misina"
export function apiFor(event: { fetch: typeof fetch }) {
// SvelteKit's `event.fetch` forwards cookies + redirects + the
// request URL automatically — pass it to misina via a custom driver
// so server-side calls see the same auth state the browser sent.
return createMisina({
driver: { name: "sveltekit", request: (req) => event.fetch(req) },
})
}
// src/routes/+page.server.ts
export async function load(event) {
const api = apiFor(event)
const user = await api.get<User>("/api/me")
return { user: user.data }
}Same trick works for Cloudflare Workers (event.fetch from the request
binding) and Deno Fresh (ctx.fetch).
Two different jobs — pick by where you want the mock to sit.
| Need | Use |
|---|---|
| Mock the network in browser tests / Storybook | MSW service worker |
| Unit-test misina hooks / cache / retry behavior | createTestMisina |
| Hit a real upstream once, replay forever in CI | misina/test record() + replayFromJSON() |
| Import a captured Chrome / Playwright HAR file | misina/test harToCassette() |
| Inject latency / chaos status into specific routes | misina/test randomStatus / randomNetworkError |
MSW intercepts at the runtime fetch layer, so it's transparent — your
production misina instance keeps running unchanged. createTestMisina
swaps the driver, so it tests the misina pipeline (hooks, retry,
cache) in isolation without spinning up a service worker.
onComplete fires once per logical call after retries / redirects, so
it's the right place for structured-log emission. Three flavors:
// pino
import pino from "pino"
const log = pino()
const api = createMisina({
hooks: {
onComplete: ({ request, response, error, durationMs }) => {
log.info(
{
method: request.method,
url: request.url,
status: response?.status,
ms: durationMs,
err: error?.message,
},
"http",
)
},
},
})
// winston (mostly identical — winston.info(message, meta))
import winston from "winston"
const wlog = winston.createLogger({
/* ... */
})
const api2 = createMisina({
hooks: {
onComplete: (i) =>
wlog.info("http", {
method: i.request.method,
url: i.request.url,
status: i.response?.status,
ms: i.durationMs,
}),
},
})
// consola — colored TTY output
import { consola } from "consola"
const api3 = createMisina({
hooks: {
onComplete: ({ request, response, durationMs, error }) => {
const level = error
? "error"
: response?.status && response.status >= 400
? "warn"
: "success"
consola[level](
`${request.method} ${request.url} → ${response?.status ?? "ERR"} ${Math.round(durationMs)}ms`,
)
},
},
})durationMs already accounts for retries; error is populated only
on the failure branch. Pair with withTracing / withOtel when
distributed-tracing context belongs in the same log line.
Reproducible mitata suite under bench/ compares
misina against ofetch / ky / axios / native fetch over a local
node:http fixture:
pnpm benchSee bench/README.md for the full results tables, suite descriptions, and notes on what these numbers don't prove. tl;dr: in a steady-state GET on localhost (Node 24 / Apple Silicon) misina is ~79 µs vs ofetch's 64 µs / native fetch's 72 µs — within noise of the wrappers, well under any real-network RTT.
Auto-generate Idempotency-Key on retried mutations so the server can
deduplicate. Per draft-ietf-httpapi-idempotency-key-header.
const api = createMisina({
idempotencyKey: "auto", // crypto.randomUUID() per logical call
retry: { limit: 3, methods: ["POST"] },
})
await api.post("/charges", { amount: 100 })
// First attempt → Idempotency-Key: 9b1d…
// All retries → same key. Server safely deduplicates the side-effect.'auto' only fires for non-idempotent methods (POST/PATCH/DELETE) when
retry > 0. GET/HEAD/OPTIONS/PUT skip it (already idempotent by spec).
A user-supplied Idempotency-Key header always wins.
// String form — useful for an externally-supplied id (Stripe-style):
createMisina({ idempotencyKey: requestId })
// Function form — runs once per logical request, not per attempt:
createMisina({ idempotencyKey: (req) => `order-${orderId}` })
// Disabled (default):
createMisina({ idempotencyKey: false })No competing client ships this today.
Servers signal application errors with Content-Type: application/problem+json
(RFC 9457, formerly RFC 7807).
Misina lifts the structured shape onto HTTPError.problem automatically.
try {
await api.post("/charge", { amount: 100 })
} catch (err) {
if (isHTTPError(err) && err.problem) {
console.log(err.problem.type) // URI ref to the problem type
console.log(err.problem.title) // short summary
console.log(err.problem.status) // echoed status
console.log(err.problem.detail) // specific occurrence
console.log(err.problem.instance) // URI ref to this occurrence
console.log(err.problem.balance) // extension fields preserved
}
}The default error.message includes problem.detail (or title fallback)
so console output is immediately useful:
HTTPError: Request failed with status 402: Your account balance is $0.00.
Pass-through for RequestInit.priority
— hint to the browser/runtime about the urgency of a request.
await api.get("/critical", { priority: "high" })
await api.get("/prefetch", { priority: "low" })Honored by Chromium browsers, Firefox 132+, Safari 17.4+, and Cloudflare Workers — completes the Baseline 2024 set.
await api.post("/upload", file, {
onUploadProgress: ({ percent, bytesPerSecond }) => updateBar(percent),
})
await api.get("/download/big.bin", {
responseType: "blob",
onDownloadProgress: ({ loaded, total }) => updateBar(loaded / (total ?? 1)),
})Upload progress streams the body in 64 KB chunks via duplex: 'half' on
runtimes that support it (Node 22+, Bun, Deno, Chrome 105+). Safari and
Firefox don't support streaming request bodies yet — on those, the
callback is silently skipped and the body is sent in one go.
Throttle high-frequency callbacks via progressIntervalMs:
createMisina({
onUploadProgress: ({ percent }) => updateBar(percent),
progressIntervalMs: 100, // fire at most once per 100ms
})The final 100% event always fires regardless of throttle.
Per-request data that flows through every hook on ctx.options.meta. Type
via module augmentation (TanStack Query pattern):
declare module "misina" {
interface MisinaMeta {
tag?: string
tenant?: string
requestId?: string
}
}
const api = createMisina({
hooks: {
onComplete: ({ options, durationMs }) => {
tracer.send({ tag: options.meta?.tag, durationMs })
},
},
})
await api.get("/users/42", { meta: { tag: "search", tenant: "acme" } }).extend() shallow-merges meta (child keys win, parent keys preserved).
Same idea as meta, but for shared, mutable state across every call on
one instance. Hooks read AND write ctx.options.state:
declare module "misina" {
interface MisinaState {
token?: string
requestCount?: number
}
}
const session = createMisina({
state: { token: "v1", requestCount: 0 },
hooks: {
beforeRequest: (ctx) => {
ctx.options.state.requestCount! += 1
const headers = new Headers(ctx.request.headers)
if (ctx.options.state.token) headers.set("authorization", `Bearer ${ctx.options.state.token}`)
return new Request(ctx.request, { headers })
},
},
})
// Later, from anywhere — token rotation reaches subsequent calls:
// session.state.token = "v2" // (via a hook or external refresher)Same reference shared across calls on one instance. .extend() deliberately
gives the child a fresh state object so mutations don't leak across boundaries.
Fires exactly once per logical call after retries + redirects, with either
response or error populated. Single observation point for logging,
metrics, and distributed tracing:
createMisina({
hooks: {
onComplete: ({ request, response, error, durationMs, attempt, options }) => {
log({
url: request.url,
status: response?.status,
error: error?.name,
durationMs,
attempts: attempt + 1,
tag: options.meta?.tag,
})
},
},
})Covers success, HTTPError, NetworkError, TimeoutError paths uniformly —
no need to wire afterResponse and beforeError separately.
URL guardrails for backends that canonicalize paths or for embedded runtimes with custom schemes:
createMisina({
trailingSlash: "strip", // 'preserve' (default) | 'strip' | 'forbid'
allowedProtocols: ["http", "https", "capacitor"], // default ['http','https']
})
// 'strip' → /users/ becomes /users
// 'forbid' → throws a clear Error if path ends with /
// allowedProtocols rejects ftp://, file://, javascript:, etc by default.Both check the URL after baseURL resolution, before dispatch.
const api = createMisina({
defer: () => ({
headers: { authorization: `Bearer ${currentToken()}` },
next: { revalidate: 0 },
}),
})defer callbacks fire after init hooks, before beforeRequest hooks.
import { createMisinaTyped } from "misina"
type Api = {
"GET /users/:id": { params: { id: string }; response: User }
"POST /users": { body: NewUser; response: User }
"GET /users": { query: { page?: number }; response: User[] }
}
const api = createMisinaTyped<Api>({ baseURL: "https://api.example.com" })
const user = await api.get("/users/:id", { params: { id: "42" } })
// ^? MisinaResponsePromise<User>
const created = await api.post("/users", { body: { name: "x" } })
const list = await api.get("/users", { query: { page: 2 } })Path params are substituted at runtime: /users/:id → /users/42 (also {id} syntax).
For one-off URL building outside the typed client, use path():
import { path } from "misina"
path("/users/:id/posts/:postId", { id: "42", postId: "7" })
// → "/users/42/posts/7"path() (and createMisinaTyped) reject values that would escape the
template — .., /, \, NUL, CR/LF.
Build a File from any byte-bearing source (string, Uint8Array,
ArrayBuffer, Blob, ReadableStream, async iterable). Useful for
multipart uploads to LLM vision / audio / files endpoints.
import { toFile } from "misina"
const fd = new FormData()
fd.append("file", await toFile("image.jpg", readableStream, { type: "image/jpeg" }))
await api.post("/vision", fd)The body serializer also auto-wraps async iterables with
ReadableStream.from(...) — async generators or Node Readable
streams can be passed as body directly.
If you already run openapi-typescript on your spec, the type-only misina/openapi subpath turns its output into an EndpointsMap for free:
import { createMisinaTyped } from "misina"
import type { OpenApiEndpoints } from "misina/openapi"
import type { paths } from "./generated.d.ts"
const api = createMisinaTyped<OpenApiEndpoints<paths>>({ baseURL })
const user = await api.get("/users/{id}", { params: { id: "42" } })
// ^? whatever paths['/users/{id}']['get']['responses']['200'] resolves toFor each path × verb in your spec, the adapter produces a ${VERB} ${path} key with the right params, query, body, and response shapes pulled from parameters.path, parameters.query, requestBody.content['application/json'], and responses[200|201|204|default].content['application/json']. Operations that don't declare path/query/body simply omit those fields.
Zero runtime cost — the published misina/openapi/index.mjs is 11 bytes (re-exports only). All the work happens in .d.mts.
import { validated, validateSchema } from "misina"
import { z } from "zod"
const UserSchema = z.object({ id: z.string(), name: z.string() })
const user = await validated(api.get("/users/42"), UserSchema)
// ^? validated against zodThrows SchemaValidationError with .issues on mismatch.
- Redirect mode
'manual'by default — misina follows redirects itself. - Cross-origin redirects strip
Authorization,Cookie,Proxy-Authorization,WWW-Authenticate. Allowlist viaredirectSafeHeaders. https → httpredirects refused unlessredirectAllowDowngrade: true.- Header values containing CR/LF/NUL throw — request smuggling guard.
- URL composition (baseURL + path) rejects raw CR/LF/NUL.
- Path params in
createMisinaTypedreject..,/,\, NUL (traversal guard). - Cross-origin redirects strip
Authorization,Cookie,Proxy-Authorization,WWW-Authenticate,Signature,Signature-Input. Configurable viaredirectStripHeaders. https → httpredirects refused unlessredirectAllowDowngrade: true.maxResponseSizebyte cap with pre-stream Content-Length check + mid-stream byte counter.allowAbsoluteUrls: falsealso rejects scheme-relative URLs (//other.com/x).
misina stands on the shoulders of the modern fetch ecosystem. The design borrows liberally from prior art — credit where it's due:
- ofetch (unjs) — defer pattern, hook surface shape.
- ky (Sindre Sorhus) —
.extend()ergonomics,beforeRetryreturning aResponse, response timeout semantics,parseJson(text, ctx)(PR #849). - axios — request/response interceptor concept;
paramsSerializerand the option-bag API surface. - got — pagination iterator design, cookie-jar interface contract.
- wretch —
.onError(404, fn)status catcher ergonomics. - openapi-fetch / openapi-typescript (drwpow) — the
Pathsshape that themisina/openapiadapter targets. - cockatiel (connor4312) and Microsoft Polly — circuit-breaker state-machine shape used in
misina/breaker. - Standard Schema (zod / valibot / arktype authors) — the
~standard.validatecontract. - unstorage and unemail — the
defineDriver()pattern.
Specs and standards consulted along the way:
- WHATWG Fetch + AbortSignal + HTML §9.2 (EventStream)
- RFC 9110 (HTTP semantics, redirects)
- RFC 9111 (HTTP caching)
- RFC 8288 (Link header)
- RFC 6265 (Cookies)
- RFC 9457 (Problem details for HTTP APIs)
- draft-ietf-httpapi-idempotency-key-header
Built by productdevbook and Claude Code — 59+ audit passes, 481 regression tests, zero deps.
MIT © productdevbook