@zoijs/api
v0.5.0
Published
Same-origin data access for Zoijs: api(url, { params, query }) reads as a resource, api.post/put/patch/delete write as an action — safe URL building, JSON bodies, invalidation, HTTP errors and a same-origin policy built in; createApi({ baseURL, auth }) fo
Downloads
830
Maintainers
Readme
@zoijs/api
Same-origin data access for Zoijs. Reads as a resource, writes as an action — safe URL building, reactive queries, JSON bodies, invalidation, HTTP errors and a same-origin policy built in — plus explicit, single-origin clients for external APIs.
Documentation · Core package · @zoijs/resource · @zoijs/action
@zoijs/api is an optional package. It is a thin layer over
@zoijs/resource for reads and @zoijs/action
for writes:
const tasks = api("/api/tasks"); // a resource
const addTask = api.post("/api/tasks", { invalidate: tasks }); // an action
await addTask.run({ title: "Learn Zoijs" }); // POST JSON, then tasks refresheswith no fetch, res.ok check, JSON.stringify, Content-Type header or refresh() call
of your own.
api() only talks to the page's own origin. For an external API you create an explicit client,
locked to that one origin — see External APIs:
const github = createApi({ baseURL: "https://api.github.com" });
const repos = github("/users/:user/repos", { params: { user: "zoijs" } });Install
npm install @zoijs/core @zoijs/resource @zoijs/action @zoijs/api@zoijs/core, @zoijs/resource and @zoijs/action are peer dependencies. With no install, from
a CDN: in an import map, point "@zoijs/core", "@zoijs/resource", "@zoijs/action" and "@zoijs/api"
at exact-version
jsDelivr file URLs with integrity hashes, then import by name (so every package shares one
core). See the CDN guide.
import { api } from "@zoijs/api";Quick start
Four steps, each building on the last. Steps 1 and 2 need nothing but your own server, and none
needs a build step. The main examples on this page are run by this package's test suite
(tests/readme.test.js), so they match the real API.
1. Read data
import { html, mount, each } from "@zoijs/core";
import { api } from "@zoijs/api";
function Tasks() {
const tasks = api("/api/tasks"); // GET, as a reactive resource
return html`
${() => {
if (tasks.loading() && tasks.data() === undefined) return html`<p role="status">Loading tasks…</p>`;
if (tasks.error())
return html`<div role="alert">
<p>Couldn't load tasks: ${tasks.error().message}</p>
<button onclick=${() => tasks.refresh()}>Try again</button>
</div>`;
if (tasks.data().length === 0) return html`<p>No tasks yet.</p>`;
return html`<ul>
${each(() => tasks.data(), (task) => task.id, (task) => html`<li>${task.title}</li>`)}
</ul>`;
}}
`;
}
mount(Tasks, "#app");That's the four states every view needs — loading, error (with a way out), empty and data — from
three reactive reads. No fetch, res.ok check, JSON parsing, abort controller or cleanup of
your own.
api(url) returns the same object resource() does, with the same behavior:
const users = api("/api/users");
users.data(); // the parsed response, or undefined before the first success (reactive)
users.loading(); // true while a request is in flight (reactive)
users.error(); // an ApiError, or null (reactive)
users.refresh(); // request again; keeps the current data until the new response settlesIt loads once immediately, only the latest request can update it, it stops when its component
unmounts, and failures are reported to configure({ onError }) as kind: "resource" — all of
that is resource(). A request superseded by refresh(), or still in flight when the component
unmounts, is also aborted.
2. Write data
import { html } from "@zoijs/core";
import { api } from "@zoijs/api";
function NewTask({ tasks }) {
const addTask = api.post("/api/tasks", { invalidate: tasks, exclusive: true });
async function submit(event) {
event.preventDefault();
const form = event.currentTarget; // read it now: it's null once the handler has awaited
await addTask.run({ title: new FormData(form).get("title") }); // never rejects
if (addTask.done()) form.reset(); // `tasks` reloads by itself (invalidate)
}
return html`
<form onsubmit=${submit}>
<label for="new-task">New task</label>
<input id="new-task" name="title" required />
<button disabled=${() => addTask.pending()}>${() => (addTask.pending() ? "Adding…" : "Add")}</button>
<p role="alert">${() => addTask.error()?.message ?? ""}</p>
</form>
`;
}A mutation sends nothing until run(), and run() never throws or rejects: the outcome is in
pending(), done(), result() and error(). exclusive makes a double submit send once;
invalidate refreshes the list after the server succeeds. More in
Mutations.
3. Call another origin
api() only talks to your page's own origin. An external API gets an explicit client, locked to
that one origin — requests use CORS, send no cookies and never follow redirects:
import { createApi } from "@zoijs/api";
const github = createApi({ baseURL: "https://api.github.com" });
const repos = github("/users/:user/repos", { params: { user: "zoijs" } }); // same resource APIThe API must allow your page's origin (CORS). See External APIs.
4. Add authentication
Give the client an auth provider that returns the current access token — Zoijs sends it as
Authorization: Bearer <token>, only to that origin:
const backend = createApi({
baseURL: "https://api.example.com",
auth: async ({ signal }) => getAccessToken({ signal }), // your session code; pass the signal on
authTimeout: 5000, // optional: give up on a slow provider after 5 s
});
const profile = backend("/profile");
const save = backend.post("/orders", { idempotencyKey: true, retry: 2 });The provider runs once per request (never per retry), may be async, and can return null for an
anonymous request. Zoijs never stores or refreshes tokens. See
Authenticated API clients and
Recovering from a 401.
Next: Handling errors · Request lifecycle · Options at a glance · Debugging · Testing · Example app
Dynamic URLs: params and query
api(url, options) accepts exactly three options: params, query and debounce. Any other
option (method, headers, body, …) throws a TypeError — api() is always a GET. You never
call encodeURIComponent, build a query string, or wire up refresh() timers yourself.
Path parameters
const task = api("/api/tasks/:id", { params: { id: taskId } });
// taskId = "abc/123" → GET /api/tasks/abc%2F123- A placeholder is a whole path segment:
/:name, wherenameis letters, digits and_(:id,:userId,:task_id). Anything else —:id?,:id*,:{id},:id.json— throws. A colon elsewhere (/time/12:30,host:8443, the query string) is ordinary text. - Each value becomes exactly one segment, encoded once:
/,\,?,#,%,:and@are encoded, so"../../admin"is the single segment..%2F..%2Fadmin, and"%2e%2e"is the literal text%2e%2e(sent as%252e%252e). Don't pre-encode values. "","."and".."throw — the URL standard treats./..(even encoded as%2e) as "this/parent directory", so they can't be one data segment.- Values may be a string, number (finite), bigint or boolean. Objects, arrays, functions,
symbols,
nullandundefinedthrow, so[object Object]can't end up in a URL. - Strict: a placeholder with no param throws, and so does a param the URL doesn't use —
params: { userId }for:idis caught immediately instead of requesting/users/:id. - A plain-object
paramsis read once, whenapi()is called. For a param that changes, pass a function — see Reactive params and queries.
Query parameters
const users = api("/api/users", { query: { active: true, page: 2 } });
// GET /api/users?active=true&page=2| Value | Sent as |
|---|---|
| "zoijs" | q=zoijs (encoded by URLSearchParams) |
| 2, 10n | page=2, big=10 (finite numbers only) |
| true / false | exact=true / exact=false |
| "" | empty= |
| null / undefined | left out |
| ["new", "featured"] | tag=new&tag=featured — repeated keys, each value encoded on its own |
| ["a", null, "b"] | tag=a&tag=b — null/undefined elements are skipped |
| [] | no tag at all (and removes any tag the URL had) |
| objects, nested arrays, functions, symbols, NaN (also as array elements) | throw a TypeError |
const products = api("/api/products", { query: { tag: ["new", "featured"] } });
// GET /api/products?tag=new&tag=featuredArrays are one level deep, never JSON-encoded or comma-joined. Caller arrays and objects are only read — frozen ones work.
The query is applied with the platform URLSearchParams after the URL is resolved. A query
already in the URL is kept, and a query key replaces every value the URL had for that key
(delete, then append each value — so it moves to the end):
api("/api/products?tag=old&page=1", { query: { tag: ["a", "b"] } }) requests
/api/products?page=1&tag=a&tag=b. A null/undefined value leaves the URL's own value alone.
Fragments (#…) are never sent.
Reactive params and queries
Pass a function as params or query to make it reactive. Read state inside it with
.get(); when that state changes, the URL is rebuilt and the request is sent again automatically:
import { html, createState, each } from "@zoijs/core";
import { api } from "@zoijs/api";
function Search() {
const q = createState("");
const results = api("/api/search", { query: () => ({ q: q.get() }) });
return html`
<input type="search" oninput=${(e) => q.set(e.target.value)} />
<ul>${each(() => results.data() ?? [], (r) => r.id, (r) => html`<li>${r.name}</li>`)}</ul>
`;
}const id = createState("1");
const user = api("/api/users/:id", { params: () => ({ id: id.get() }) });
// GET /api/users/1 — then id.set("2") → GET /api/users/2Both can be reactive at once, and every Phase 2 rule still applies to what the functions return:
const results = api("/api/search/:scope", {
params: () => ({ scope: scope.get() }),
query: () => ({ q: q.get(), tag: selectedTags.get() }),
debounce: 250,
});- The functions run inside one core
computed(); aneffect()callsrefresh()when the URL they build changes. It's the same tracking as everywhere else in Zoijs — no polling, no subscriptions of its own. A change that builds the same URL (e.g.q.get().trim(), or a new array with the same values) sends nothing. - Changes in the same synchronous block are batched:
id.set("43"); page.set(2)sends one request with both values. - Only the latest request can update the data; earlier ones are aborted.
refresh()always uses the current values, and isn't repeated by the automatic refetch.- A plain-object
paramsorqueryis static: nocomputed,effector timer is created for it. Passing state itself (query: { q }) throws — use the function form. - If a function throws, or returns something
api()can't send (a missing, unused or".."param, an object, a secret-looking key), no request is sent anderror()is anApiErrorof type"config"(a throw is kept as itscause). The message names the param or key, never the value. The next valid change recovers.
Values are read as ordinary property accesses: static params/query once, when api() is
called; a function's result each time it runs. Getters work and run once per read; nothing is
copied, merged or mutated.
Debounce
const q = createState("");
const results = api("/api/search", { query: () => ({ q: q.get() }), debounce: 250 });Typing a, ab, abc, abcd quickly sends one request, for abcd, 250 ms after the last
change — no timer code in your component.
debounceis milliseconds, a finite number from0to2147483647(the browser timer limit). Negative numbers,NaN,Infinity, strings and anything else throw aTypeError.0(the default) refetches as soon as the batched change runs, with no timer.- The first load is immediate; only reactive changes wait. Changes to
paramsandqueryshare one window: each change restarts it, and one request goes out with the latest values. Changing back to what was last requested cancels the pending refetch. refresh()is never debounced: it loads now with the current values and cancels a pending automatic refetch, so nothing is sent twice.- With no reactive
params/query,debouncedoes nothing and costs nothing.
Lifetime and dispose()
An api() called while a component renders belongs to that component. When it unmounts, its
request is aborted, its tracking and any pending debounce are dropped — you don't need to call
anything.
For an api() created outside a component (at module level, in a store, in a test), nothing
owns it, so its reactive tracking would last as long as the state it reads. Stop it yourself:
const events = api("/api/events", { query: () => ({ topic: topic.get() }) });
// later
events.dispose();dispose() aborts the in-flight request (the resource keeps the data it had and stops loading —
nothing is reported to onError), cancels a pending debounce, and removes the computed/effect
from the reactive graph, so later state changes send nothing. It is idempotent and safe inside a
component too (the unmount that follows is a no-op). After it — including after the owning
component unmounts — refresh() throws an ApiError of type "config" rather than quietly
starting again.
Secrets don't belong in URLs
URLs are stored in browser history, server and proxy logs, analytics and monitoring. api()
refuses query keys that name a credential — in query and in the URL itself — with a
TypeError (or a "config" ApiError from a reactive query). It never looks at values.
A key is refused when, lowercased with everything but letters and digits removed, it is exactly
one of: token, accesstoken, refreshtoken, idtoken, authtoken, sessiontoken,
bearer, jwt, password, passwd, pwd, secret, clientsecret, apisecret, apikey,
xapikey, authorization, auth, session, sessionid, credential, credentials,
privatekey. So token, TOKEN, access_token, accessToken, api-key and Authorization
are refused, while pageToken, next_page_token, tokenizer and author are not. Send
credentials in a header or a request body — and authorize on the server.
Mutations: api.post, api.put, api.patch, api.delete
import { html, each } from "@zoijs/core";
import { api } from "@zoijs/api";
function Tasks() {
const tasks = api("/api/tasks");
const addTask = api.post("/api/tasks", { invalidate: tasks, exclusive: true });
const removeTask = api.delete("/api/tasks/:id", { invalidate: tasks });
async function submit(e) {
e.preventDefault();
const form = e.currentTarget;
await addTask.run({ title: new FormData(form).get("title") });
if (addTask.done()) form.reset();
}
return html`
<form onsubmit=${submit}>
<label for="task-title">Title</label>
<input id="task-title" name="title" required />
<button disabled=${() => addTask.pending()}>${() => (addTask.pending() ? "Adding..." : "Add")}</button>
${() => (addTask.error() ? html`<p role="alert">${addTask.error().message}</p>` : null)}
</form>
<ul>
${each(() => tasks.data() ?? [], (task) => task.id, (task) => html`
<li>${task.title}
<button aria-label=${`Delete ${task.title}`} onclick=${() => removeTask.run({ params: { id: task.id } })}>Delete</button></li>`)}
</ul>
`;
}Each factory returns an @zoijs/action: run(), and reactive
pending(), error(), done(), result(), plus reset(). Nothing is sent until run().
run() never rejects — it resolves with the parsed response, or undefined on failure, and
the failure is in error() (an ApiError, reported to configure({ onError }) as
kind: "action"). No try/catch needed for normal error handling.
What run() takes
The URL decides — never the shape of the argument:
| Method | URL without /:name | URL with /:name placeholders |
|---|---|---|
| api.post / api.put / api.patch | run(body) — the argument is the JSON body; run() sends none | run({ params, body }) — body optional; any other key throws |
| api.delete | run() — any argument is refused | run({ params }) — a body is refused |
const updateTask = api.put("/api/tasks/:id");
await updateTask.run({ params: { id: task.id }, body: { title: "Updated" } });
const patchTask = api.patch("/api/tasks/:id");
await patchTask.run({ params: { id: task.id }, body: { completed: true } });
const removeTask = api.delete("/api/tasks/:id", { query: { hard: true } });
await removeTask.run({ params: { id: task.id } }); // DELETE /api/tasks/42?hard=trueSo with api.post("/api/raw"), run({ params: …, body: … }) sends that whole object as the
body; with /:name in the URL, params and body are always separate, so form data can't leak into
the path or vice versa. params follow every api() path rule (one encoded segment each, strict
missing/unused checks, "."/".." refused). Mistakes in run()'s input don't throw: no request
is sent and error() is a "config" ApiError.
JSON bodies
The body is sent with JSON.stringify and Content-Type: application/json (plus the same
Accept header as GETs). You can't set or override headers.
- Sent:
null, strings, finite numbers, booleans, arrays and plain objects (alsoObject.create(null)ones), nested freely. Objects withtoJSON(e.g.Date) send theirtoJSON(). Anundefinedproperty is left out, as in JSON. - Refused — no request is sent,
error()is a"config"ApiError:bigint(never coerced), functions and symbols (JSON would silently drop them),NaN/Infinity(JSON would sendnull),Map,Setand class instances (JSON would send{}), cycles, and getters ortoJSONthat throw. - Getters run once, as plain
JSON.stringifywould run them; the body is never cloned, merged or mutated.__proto__,constructorandprototypeare ordinary JSON keys. - Error messages never contain the body — not a value, not a key, not the platform's own serialization message.
- No body (
run()) means no body and noContent-Type— never the text"undefined". - A
FormDatabody is sent as multipart instead — see File uploads. Other body types (Blob,ArrayBuffer,URLSearchParams, streams) are refused.
Responses are parsed exactly like GETs (JSON, 204/205 → null, text as a string,
non-2xx → "http", invalid JSON → "parse"), and every URL gets the same same-origin checks.
Options
api.post/put/patch/delete(url, options) accepts only:
query— a static object, with the same rules and secret-key refusal asapi(). Per-call values go inparams.invalidate— anapi()resource, or an array of them, to refresh after success.exclusive—truemakes a double submit send once: while a run is pending,run()returns that run's promise instead of sending again. Defaultfalse, exactly as in@zoijs/action.timeoutandproblemDetails— as forapi(); see Timeouts and Problem details.idempotencyKey,retryandretryDelay— see Idempotency keys and retries. Mutation-only.
Anything else — headers, method, credentials, mode, redirect, cache, initial, … —
throws.
Invalidation
const tasks = api("/api/tasks");
const dashboard = api("/api/dashboard");
const save = api.post("/api/tasks", { invalidate: [tasks, dashboard] });- Only after the server succeeds: never after an HTTP, network, parse or config failure.
- After the mutation's success state is set.
run()doesn't wait for the refreshes — they continue on their own, so a slow list doesn't make the save slow. - A refresh that then fails is the resource's failure: it shows in that resource's
error()(andonErroraskind: "resource") and never marks the mutation as failed. - Each resource refreshes once per successful run (listed twice → once; a call that joined an
exclusiverun doesn't refresh again). It refreshes with its current params/query and skips a pending debounce. - A disposed or unmounted resource is skipped quietly — a stale view can't turn a successful
save into an error. If the mutation's own component unmounted while the request was in
flight, the action ignores the late result (as
@zoijs/actiondoes) but live resources are still refreshed: the server did change. - Only real
api()resources are accepted. An object with arefresh()method, a copy of a resource or a mutation throws aTypeError: resources are tracked privately, so they can't be spoofed. Nothing is invalidated by guessing from URLs.
No implicit retries, no cancellation, no optimism
- Mutations are never retried unless you opt in with
idempotencyKey: trueandretry— see Idempotency keys and retries. Repeating a POST can create a second order, payment or email. - Unmounting doesn't abort a mutation. An in-flight request runs to completion even if its
component unmounts. Only an explicit
timeoutaborts one — and that only stops the browser waiting: it never undoes the server operation. - Updates aren't optimistic: the UI changes when the server answers and the invalidated resources reload.
Cookies are sent same-origin, as for any fetch. Protect state-changing endpoints against
CSRF on the
server, and authorize and validate every request there — client-side checks are not access
control.
File uploads (FormData)
const upload = api.post("/api/users/:id/photo", { invalidate: profile, exclusive: true });
const form = new FormData();
form.append("file", file);
form.append("description", "Profile photo");
await upload.run({ params: { id }, body: form }); // or upload.run(form) for a URL without /:name- A
FormDatabody goes tofetchas-is: noJSON.stringifyand noContent-Type— the browser writesmultipart/form-datawith its own boundary. (JSON bodies getapplication/json; no body gets noContent-Type.) - It's recognized by the platform's own FormData brand check, so a FormData from another realm
(an iframe) works, while an object that only looks like one —
append()/entries(), orObject.create(FormData.prototype)— is refused. A FormData nested inside a JSON object, and a DELETE body, are refused. - Invalidation,
exclusive,timeout,problemDetailsand retries work as for JSON (retries replay the same FormData — see One logical run). The form is never cloned, read or enumerated, and its field names and values never appear in errors. - Nothing about the file is checked in the browser. The server must authenticate, authorize, and enforce file type and size, scan where appropriate, and control where files are stored.
Timeouts
const users = api("/api/users", { timeout: 10_000 });
const save = api.post("/api/orders", { timeout: 15_000 });- Milliseconds, a finite number from
0to2147483647; anything else throws aTypeError.0(the default) means no timeout. Not reactive. - The clock starts when the request starts — after any
debounce— and covers the whole response. When it runs out, the request is really aborted (nothing is left running) and the error is anApiErrorof type"timeout",statusnull, message"GET request timed out"(no duration, no platformAbortErrortext). refresh()uses the same timeout; a request replaced byrefresh(), a reactive change,dispose()or unmount is aborted quietly and is never reported as a timeout. Timers are cleared when the request ends, however it ends.- A mutation timeout doesn't mean the server didn't act. The order may have been placed; the
browser just stopped waiting. A timed-out mutation is only retried if you opted in with an
idempotency key and
retry; otherwise reload the data (or check with the server) before letting the user try again. - With
retry, each attempt gets its owntimeoutwindow; the backoff waits between attempts aren't counted.
Idempotency keys and retries
const createOrder = api.post("/api/orders", {
idempotencyKey: true,
retry: 2,
timeout: 10_000,
});
await createOrder.run({ sku: "ABC-123", quantity: 1 });The server contract. Sending an
Idempotency-Keyheader does not make an endpoint idempotent. Your server must store each key and, when it sees a key again, return the original outcome instead of performing the operation a second time — according to its own idempotency policy (how long keys are kept, what a key reused with a different body means). Zoijs can't prevent duplicates without that cooperation.
Retries are off by default — a mutation makes exactly one attempt. To retry, you opt in
twice: idempotencyKey: true and retry. retry without idempotencyKey: true throws, for
every method: PUT, PATCH and DELETE aren't assumed to be idempotent either. So: if Zoijs
retries a mutation, it always sends an idempotency key.
Idempotency keys
idempotencyKey: truesendsIdempotency-Key: <uuid>— a v4 UUID fromcrypto.randomUUID()(or built fromcrypto.getRandomValues()whererandomUUIDisn't available). NeverMath.random; with no cryptographic source the run fails with a"config"error and nothing is sent.- One key per logical
run(): every retry of that run reuses it, and the nextrun()gets a new one. A call that joins anexclusiverun shares that run's key and attempts. - The key goes only in that header — never in the URL, query, body,
error.message,error.url, problem details, the console or monitoring. Treat it like a credential. - It works without
retrytoo (a single attempt with a key).
Your own keys
When the operation already has an identity — a checkout session, a draft, a client-side operation id — pass a function that returns the key for the operation being run:
const save = api.post("/api/orders", {
idempotencyKey: () => currentOperationId(),
retry: 2,
});- The function is called once per logical
run()— not per retry, and only once forexclusivecalls that join a run. The next independentrun()calls it again. - It must return 1–255 visible ASCII characters (
!to~): no spaces, no control characters (so no CR, LF or NUL — a key can never inject a header), no non-ASCII. Nothing is trimmed or normalized: what's accepted is sent byte for byte. Anything else — or a function that throws — fails the run with a"config"error before any request; neither the value nor the thrown message is echoed. - A fixed string (
idempotencyKey: "checkout-abc") is refused with aTypeError: the same key would be sent by everyrun()of that mutation, so the server would treat a second, different order as a repeat of the first. - Each key must stand for exactly one logical server operation. Don't return a key that was already used for a different operation — the server will (correctly) treat it as a repeat.
- Your keys get the same privacy as generated ones: only in the header, never in URLs, bodies, errors or monitoring.
What's retried
retry: n means up to n extra attempts after the first — retry: 2 is at most 3 requests.
n is a whole number from 0 to 5.
| Failure | Retried? |
|---|---|
| network error (offline, connection reset, DNS) | yes |
| "timeout" (the timeout option ran out) | yes |
| HTTP 408, 425, 429, 500, 502, 503, 504 | yes |
| any other HTTP status — e.g. 400, 401, 403, 404, 405, 409, 410, 412, 422 | no |
| "parse", "config", "security" | never |
409 Conflict isn't retried: it isn't assumed to mean "idempotency key in use".
Backoff and Retry-After
- Retry n waits about
retryDelay × 2^(n-1), with ±20 % jitter (a factor between 0.8 and 1.2) so clients that failed at the same moment don't all retry at the same moment. With the defaultretryDelay: 250: 200–300 ms, 400–600 ms, 0.8–1.2 s, 1.6–2.4 s, 3.2–4.8 s. Every wait — jitter included — is capped at 30 s.retryDelayis milliseconds (validated liketimeout) and needsretry. - On 429 and 503, a
Retry-Afterheader — seconds (Retry-After: 5) or an HTTP-date — makes the wait at least that long, and is not jittered: never shorter than the server asked. If it asks for more than 30 s, the request stops retrying and fails with that response instead of retrying early — deliberately: respecting the server'sRetry-Aftermatters more than one more attempt. An unparseable value is ignored.
One logical run
pending()staystruethrough every attempt and wait;done()becomestrueonly when an attempt succeeds, andresult()is that response.- Failed attempts that are retried never show in
error()and are never reported toconfigure({ onError }): a mutation that recovers reports nothing. If every attempt fails,error()is the last attempt's error and it is reported once. invalidaterefreshes its resources once, after the run finally succeeds — never after a failed attempt, never after exhaustion.- The request is built once per run and replayed exactly: same method, URL, query, key and
body bytes. A JSON body is serialized once — getters and
toJSONrun once, not per attempt. - With
problemDetails: true, only the final error's problem body is read; retried responses are discarded unread. - When the component unmounts, an attempt already sent may finish (its result is ignored, as
@zoijs/actiondoes), but a scheduled retry is cancelled and no new attempt starts. The same goes forreset(), and for a run superseded by a newer (non-exclusive)run(): nobody is waiting for its result, so it isn't retried. - FormData bodies are retried by replaying the same FormData object — not cloned, not
re-serialized, no
Content-Typeset byapi(). The browser builds the multipart body again for each attempt, so the boundary may differ between attempts; the fields, file names, MIME types and bytes are the same (verified in Chromium, Firefox and WebKit). As for any mutation, a retry doesn't mean the first attempt failed to execute on the server — the idempotency key is what lets the server recognize the repeat, and only if it enforces it. - GET resources retry too — see GET retries.
Showing retries in the UI
${() => (save.retrying() ? html`<p>Retrying request…</p>` : null)}Two reactive readers sit next to the action's pending(), done(), error() and result():
| | idle | 1st attempt | waiting after a failure | 2nd attempt | succeeded | gave up |
|---|---|---|---|---|---|---|
| attempt() | 0 | 1 | 1 | 2 | 2 (kept) | last attempt (kept) |
| retrying() | false | false | true | true | false | false |
| pending() | false | true | true | true | false | false |
attempt()is the attempt in flight, or the last one finished; it's kept after the run ends until the nextrun()(which starts again at 1) orreset()(back to 0).- Without
retry,attempt()goes 0 → 1 andretrying()staysfalse; both methods exist on every mutation. exclusivejoiners share one run, so they see the same values. Only the newest run writes them.- They are ordinary state: changing them never reports anything to
onError, and they keep no timers, bodies or keys.
GET retries
const users = api("/api/users", { retry: 2 });- Off by default.
retry: n(0–5) allows up to n extra attempts —retry: 2is at most 3 requests — with the same table as mutations: network errors, timeouts and HTTP 408, 425, 429, 500, 502, 503, 504; never other statuses,"parse","config"or"security". - No idempotency key is needed: Zoijs treats GET as read-only. Don't attach side effects to GET endpoints — a retried GET runs again on the server.
- The same backoff, jitter,
retryDelay, 30 s cap andRetry-Afterrules apply.retryDelaywithoutretrythrows.idempotencyKey,exclusiveandinvalidateare mutation-only and refused here. - One logical load.
loading()staystruethrough every attempt and wait; retried failures never show inerror()and aren't reported toonError. A load that recovers ends witherror()nulland the newdata(); one that runs out reports its last error once (askind: "resource"). - Every attempt replays the same URL: params and query are read once per load. A reactive
change or
refresh()cancels a scheduled retry and starts a new load (with a fresh retry count) for the current URL — the old URL is never retried again.dispose()and unmount cancel it too. debounceruns once, before the first attempt; between attempts there's backoff, not debounce. Each attempt gets its owntimeout; backoff isn't counted.initialisn't an attempt: no request untilrefresh()or a reactive change, which may then retry.- With
problemDetails: true, only the final error's problem body is read. - GET resources don't have
attempt()/retrying(): a resource stays one plaindata/loading/errorshape. Useloading()to show progress.
Problem details
Error bodies are private by default: an ApiError never contains the response body. With
problemDetails: true (on api() or a mutation), an error response whose type is
application/problem+json (RFC 9457) is read and
normalized into error.problem:
const save = api.post("/api/users", { problemDetails: true });
await save.run({ email: "" });
save.error()?.message; // "POST request failed: 422 Unprocessable Content" — still generic
save.error()?.problem?.detail; // "email is required"- Only
type,title,detail,instance(strings) andstatus(an integer) are kept, and only with those types; extension members are ignored.error.problemis a frozen plain object, ornull— alwaysnullwithout the option, for other media types (evenapplication/json), and for 2xx responses (those are data, as always). - At most 64 KB is read: a body declared or found to be larger is dropped, and a body that
isn't a JSON object is ignored. Either way the error stays the ordinary
"http"error — a bad problem body never turns it into a"parse"error. - The values come from the server and are untrusted: they are never put in
error.message(so they don't flow into logs by default). Render them as text, as Zoijs bindings do; don't treatinstanceortypeas a link to follow.
Server rendering: initial
const users = api("/api/users", { initial: serverUsers });
users.data(); // serverUsers — the same value, not a copy
users.loading(); // false
users.error(); // nullinitialseeds the resource and skips the first request.refresh()fetches normally, and so do later reactiveparams/querychanges — the seed stands for the URL as it was when the resource was created. A change that builds the same URL sends nothing.- The key's presence counts:
{ initial: undefined }and{ initial: null }seed too; leaving the key out loads normally. - The value is kept exactly as given — never cloned, sanitized or merged — and renders with the usual escaping.
- On the server,
api(url, { initial })creates the resource without any request, so a component can render with server data.api()still has no server networking model: withoutinitial— or onrefresh()— a request on the server fails with a"security"error, as there is no page origin. Fetch on the server with your own code, pass the result asinitial, and serialize it for the client (see@zoijs/ssr'sserialize). - Mutations don't take
initial.
Responses
| Response | data() | error() |
|---|---|---|
| 2xx with a JSON type (application/json, application/json; charset=utf-8, application/problem+json, any …+json) | the parsed value | null |
| 204 / 205, or a JSON type with an empty body | null | null |
| 2xx with any other type (or none) | the body as a string | null |
| 2xx JSON type with an invalid body | unchanged | ApiError, type: "parse" |
| any status outside 200–299 (400, 401, 403, 404, 409, 500, 503, …) | unchanged | ApiError, type: "http" |
| the request couldn't be made (offline, DNS, refused, a cross-origin redirect) | unchanged | ApiError, type: "network" |
| the URL isn't allowed (see below) — no request is sent | unchanged | ApiError, type: "security" |
| reactive params/query produced something api() can't send — no request is sent | unchanged | ApiError, type: "config" |
| the timeout ran out — the request is aborted | unchanged | ApiError, type: "timeout" |
A string body is plain data. Zoijs renders it as text — it is never parsed as HTML or run as
script. To render trusted HTML you must opt in yourself (see
@zoijs/sanitize).
ApiError
Every failure — from api() and from mutations — is an ApiError (it extends Error):
import { api, ApiError } from "@zoijs/api";
const users = api("/api/users");
users.error()?.type; // "http" | "network" | "security" | "parse" | "config" | "timeout" | "auth"
users.error()?.status; // 404 — the HTTP status ("http"/"parse"), otherwise null| Field | Value |
|---|---|
| name | "ApiError" |
| type | "http", "network", "security", "parse", "config", "timeout" or "auth" (a client's auth provider failed — see Authenticated API clients) |
| status | the HTTP status, or null |
| statusText | the HTTP status text, or "" (HTTP/2 has none) |
| method | "GET", "POST", "PUT", "PATCH" or "DELETE" |
| url | origin + path, e.g. "https://app.example.com/api/users" — no query string or fragment; null for an unparseable URL |
| message | e.g. "GET request failed: 404 Not Found", "POST request not sent: …" — no URL path |
| problem | normalized problem details with problemDetails: true, otherwise null (see Problem details) |
The message is built to be safe to log: it never contains the URL path, query string or
fragment, URL credentials, request or response headers, cookies, the request body, or the
response body — paths
can carry identifiers or tokens (/reset/<token>). Only a blocked cross-origin request names
the target origin. If you need the endpoint, read error.url (origin + path, never the query
string or fragment) and decide yourself whether to log it — with params, the path includes
the param values.
Handling errors
Every failure is an ApiError in error(), never a thrown exception or a rejected
promise. Its type tells you what happened; status (for "http") and, with
problemDetails: true, problem add detail. One function that turns it into words for people
is usually all an app needs:
function describe(error) {
switch (error.type) {
case "http":
if (error.status === 404) return "We couldn't find that.";
if (error.status === 401) return "Your session has expired. Please sign in again.";
if (error.status === 403) return "You don't have access to this.";
if (error.status === 422) return error.problem?.detail ?? "Please check what you entered.";
if (error.status >= 500) return "The server had a problem. Try again in a moment.";
return `The request was refused (${error.status}).`;
case "timeout":
return "The server took too long to answer.";
case "network":
return "Can't reach the server. Check your connection and try again.";
case "auth":
return "We couldn't get your sign-in token. Please sign in again.";
default: // "security", "parse", "config": a bug or a misbehaving server — log it, show something generic
return "Something went wrong.";
}
}| type | What happened | Was anything sent? | Retried with retry? | What to do |
|---|---|---|---|---|
| "http" | The server answered outside 2xx | yes | 408, 425, 429, 500, 502–504 | depends on status — below |
| "network" | No answer: offline, DNS, CORS refusal, dropped connection | maybe | yes | offer a retry |
| "timeout" | Your timeout elapsed (per attempt) | yes — a mutation may have succeeded | yes | offer a retry; reload data before re-running a mutation |
| "auth" | The auth provider threw, rejected, returned an invalid token or exceeded authTimeout | no | no | sign in again, or retry |
| "security" | A refused URL or redirect, or no page (SSR) | no | no | a bug in the app — fix the URL |
| "parse" | A 2xx response that says JSON but isn't | yes | no | a server bug — log it |
| "config" | Your code passed something unsendable (a bad body, param or query) | no | no | a bug in the app — fix the call |
Not found (404). Show a not-found state rather than a generic error:
function UserPage({ id }) {
const user = api("/api/users/:id", { params: { id } });
return html`${() => {
const e = user.error();
if (e?.type === "http" && e.status === 404) return html`<h1>No such user</h1><a href="/users">All users</a>`;
if (e) return html`<p role="alert">${describe(e)}</p>`;
return user.data() ? html`<h1>${user.data().name}</h1>` : html`<p role="status">Loading…</p>`;
}}`;
}Validation (422). If your API answers with problem details, opt in with
problemDetails: true and show problem.detail (or title) — as text, it's server-supplied.
Only the standard members are kept, so a field-by-field error list in an extension member isn't
available: put the message people should read in detail, and validate in the browser too.
const signUp = api.post("/api/users", { problemDetails: true });
await signUp.run({ name: "Ada", email: "not-an-email" });
const e = signUp.error();
if (e?.type === "http" && e.status === 422) {
showFormError(e.problem?.detail ?? "Please check what you entered."); // e.g. "Enter a valid email address."
}Server errors (5xx) and network failures. Show a generic message and a way to try again —
refresh() for a resource, another run() for a mutation. For GETs, retry absorbs brief
outages first (see GET retries); mutations retry only
with an idempotency key.
const orders = api("/api/orders", { retry: 2 }); // up to 3 attempts for network errors, timeouts, 5xx
html`${() => orders.error() && html`<div role="alert">
<p>${describe(orders.error())}</p>
<button onclick=${() => orders.refresh()}>Try again</button>
</div>`}`;Authentication. "auth" means your provider failed — threw, rejected, timed out or returned
something that isn't a token — and nothing was sent. The error never contains the provider's
own error or the value it returned, so log what you need inside your provider. A 401 is
different: the request reached the server, which refused the token — recover explicitly and only
once, as shown in Recovering from a 401. Never refresh on a 403.
Timeouts. A network timeout (the timeout option) is type "timeout"; an auth-provider
timeout (authTimeout) is type "auth" — they can't be confused. An "auth" error that came
from authTimeout has the message "… the auth provider timed out", but the reaction is the same
as for any provider failure: let the person retry or sign in again.
Cancellation is not an error. A request superseded by refresh() or a reactive change, a
resource disposed or unmounted, and a mutation reset() while waiting for its token all end
silently: error() stays null, loading()/pending() end, and nothing reaches
configure({ onError }). Don't show a message for them. (What cancellation can and can't stop is
in Request lifecycle.)
Request lifecycle
A resource (api(url), client(url)) loads once when created — unless seeded with
initial — and again on refresh() or when a reactive
params/query function produces a different URL (after debounce). Each of those is one
logical load:
create / refresh() / params or query change
└─▶ build + check the URL ─▶ [auth provider] ─▶ attempt 1 ─▶ (retryable failure, retries left?) ─▶ wait ─▶ attempt 2 … ─▶ data() or error()
│ │ │
│ └ aborts at once when the load is superseded, disposed or unmounted —
│ or fails as "auth" after authTimeout; nothing is sent
└ a newer load supersedes this one: its provider and request are aborted, its result is ignored
dispose() / unmount ─▶ the in-flight load is aborted, loading() ends, data() keeps its last value, nothing is reportedA mutation sends nothing until run(). Each run() is one logical run:
run(input)
└─▶ build the URL + body (+ idempotency key) ─▶ [auth provider] ─▶ attempt 1 ─▶ wait ─▶ attempt 2 … ─▶ done() or error()
│ │
│ └ reset() / unmount: no NEW attempt starts —
│ an attempt already sent runs to completion
└ reset() / unmount: the provider is aborted, nothing is sent| Event | GET resource | Mutation |
|---|---|---|
| params/query change, refresh() | new load; the old one is aborted and ignored | — |
| dispose() / component unmount | in-flight load aborted, silently | (unmount — mutations have no dispose()) waiting provider and backoff cancelled; a sent attempt completes, its result ignored |
| reset() | — | state cleared; waiting provider and backoff cancelled; a sent attempt completes, its result ignored |
| a second run() while one is pending | — | exclusive: true: joins the pending run (no second request, no second token). Otherwise: a new run is sent; the older one keeps its first request but stops retrying, and only the newest updates the state |
| retryable failure | retried within retry, same URL and token | retried within retry only with an idempotency key — same URL, body, key and token |
| timeout elapses | that attempt fails ("timeout"), may be retried | the same — and the server may still have acted |
Cancelled is not the same as not received. Aborting stops the browser from waiting and guarantees Zoijs won't start anything new for that operation. It can't recall a request that has already left: the server may have received it, and may already have acted on it. That's why mutations aren't aborted on unmount, why a timed-out mutation is only retried with an idempotency key, and why the server must make repeated or abandoned requests safe.
Options at a glance
The canonical rules are in each linked section; this table is for finding them.
| Option | On | Default | Accepts | See |
|---|---|---|---|---|
| params | api(), run({ params }) | — | object or function of one; values string/number/bigint/boolean, one path segment each | Path parameters |
| query | api() (object or function), mutations (object) | — | scalars or one-level arrays; secret-looking keys refused | Query parameters |
| debounce | api() | 0 | ms, 0–2147483647; only with reactive params/query | Debounce |
| timeout | api(), mutations | 0 (none) | ms, 0–2147483647, per attempt | Timeouts |
| retry | api(), mutations | 0 | 0–5 extra attempts; mutations need idempotencyKey | GET retries, retries |
| retryDelay | api(), mutations | 250 | ms base backoff; needs retry | Backoff |
| problemDetails | api(), mutations | false | boolean | Problem details |
| initial | api() | — (absent) | any value; skips the first request | Server rendering |
| invalidate | mutations | — | an api() resource or an array of them | Invalidation |
| exclusive | mutations | false | boolean | Options |
| idempotencyKey | mutations | false | true (random UUID per run) or a function returning one | Idempotency keys |
| baseURL | createApi() | required | absolute https: URL (http: only for loopback), optional base path | External APIs |
| auth | createApi() | — | a function ({ signal }) => token \| null \| undefined (or a promise of one) | Authenticated API clients |
| authTimeout | createApi() | 0 (none) | ms, 0–2147483647; needs auth | Authenticated API clients |
There are no global settings, no headers option and no credentials option. An unknown or
misplaced option throws a TypeError naming the accepted ones — for example retry on a
mutation without idempotencyKey, or initial on a mutation. With TypeScript, a URL written as a
string literal also checks params names: api("/users/:id", { params: { userId } }) is a type
error.
Debugging
What you can inspect, all of it safe to show or log:
- State:
loading(),data(),error()on a resource;pending(),done(),result(),error(),attempt()(which attempt is in flight) andretrying()on a mutation. - The error:
type,status,statusText,method,url(origin + path, no query string) and, withproblemDetails,problem.messagenever contains the URL path, query, headers, bodies or tokens. Withparams,urlcontains their values — decide whether those are fine to log. - Monitoring:
configure({ onError })receives every final failure —kind: "resource"or"action"— once per logical request. Intermediate failures that a retry recovered from, and cancellations, are never reported.
import { configure } from "@zoijs/core";
import { ApiError } from "@zoijs/api";
configure({
onError(error, info) {
if (!(error instanceof ApiError)) return;
monitoring.capture({ kind: info.kind, type: error.type, status: error.status, method: error.method, message: error.message });
},
});- The network itself: your browser's developer tools show every request — including its
Authorizationheader, on your machine. Zoijs adds no request logger and never logs tokens; don't add one that does.
Testing
Components that use @zoijs/api are tested against stubbed responses — no real server, no
real credentials. Stub globalThis.fetch in unit tests, or route requests in a browser test.
Unit tests (node:test + jsdom, with @zoijs/testing). The DOM needs
a page URL so api() has an origin, e.g. new JSDOM("", { url: "https://app.example.com/" }).
import test from "node:test";
import assert from "node:assert/strict";
import { html } from "@zoijs/core";
import { api, createApi } from "@zoijs/api";
import { render, waitFor, cleanup } from "@zoijs/testing";
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
const realFetch = globalThis.fetch;
let requests = [];
function respond(handler) {
requests = [];
globalThis.fetch = async (url, init) => (requests.push({ url: String(url), method: init.method }), handler(new URL(url), init));
}
test.afterEach(() => {
cleanup();
globalThis.fetch = realFetch;
});
function Tasks() {
const tasks = api("/api/tasks");
return html`${() => (tasks.error() ? html`<p role="alert">Failed</p>` : html`<p>${tasks.data()?.length ?? "…"} tasks</p>`)}`;
}
test("renders the response", async () => {
respond(() => json([{ id: 1 }, { id: 2 }]));
const { getByText } = render(Tasks);
await waitFor(() => getByText("2 tasks"));
});
test("an HTTP error and a network failure both show the alert", async () => {
respond(() => json({}, 500));
const failed = render(Tasks);
await waitFor(() => failed.getByRole("alert"));
cleanup();
respond(() => Promise.reject(new TypeError("Failed to fetch"))); // what a browser does offline
const offline = render(Tasks);
await waitFor(() => offline.getByRole("alert"));
});
test("a delayed response: loading, then data", async () => {
let answer;
respond(() => new Promise((resolve) => (answer = () => resolve(json([{ id: 1 }])))));
const tasks = api("/api/tasks");
assert.equal(tasks.loading(), true);
await waitFor(() => assert.ok(answer));
answer();
await waitFor(() => assert.equal(tasks.loading(), false));
assert.deepEqual(tasks.data(), [{ id: 1 }]);
tasks.dispose();
});
test("retries are bounded and deterministic with retryDelay: 0", async () => {
respond(() => json({}, 503));
const save = api.post("/api/orders", { idempotencyKey: true, retry: 2, retryDelay: 0 });
await save.run({ total: 1 });
assert.equal(requests.length, 3);
assert.equal(save.error().status, 503);
});
test("a mutation's result, and invalidation", async () => {
respond((url, init) => (init.method === "POST" ? json({ id: 3 }, 201) : json([])));
const tasks = api("/api/tasks");
const add = api.post("/api/tasks", { invalidate: tasks });
await waitFor(() => assert.equal(tasks.loading(), false));
assert.deepEqual(await add.run({ title: "x" }), { id: 3 });
await waitFor(() => assert.equal(requests.filter((r) => r.method === "GET").length, 2)); // reloaded
tasks.dispose();
});
test("cancellation: a disposed resource sends nothing more and reports nothing", async () => {
let answer;
respond(() => new Promise((resolve) => (answer = resolve)));
const tasks = api("/api/tasks");
await waitFor(() => assert.ok(answer));
tasks.dispose();
answer(json([{ id: 1 }]));
await waitFor(() => assert.equal(tasks.loading(), false));
assert.equal(tasks.error(), null);
assert.equal(tasks.data(), undefined);
});
test("an auth provider failure: type auth, and nothing is sent", async () => {
respond(() => json([]));
const backend = createApi({ baseURL: "https://api.example.com", auth: () => { throw new Error("no session"); } });
const me = backend("/me");
await waitFor(() => assert.equal(me.error()?.type, "auth"));
assert.equal(requests.length, 0);
me.dispose();
});- A stubbed
fetchreceives exactly what Zoijs would send: checkinit.method,init.headers(includingAuthorizationfor a client) andinit.bodyin your handler. retryDelay: 0makes retries immediate, so tests don't wait. Use controlled promises, not timers, to hold a response back.- Create resources inside the rendered component, or
dispose()the ones you create in a test.
Browser tests (Playwright). Route the page's requests to canned answers:
await page.route("**/api/tasks", (route) => route.fulfill({ json: [{ id: 1, title: "Write tests" }] }));
await page.route("**/api/orders", (route) => route.fulfill({ status: 503, body: "" }));
await page.route("**/api/flaky", (route) => route.abort()); // a network errorFor a createApi() client the browser enforces CORS, so a routed answer for another origin needs
Access-Control-Allow-Origin (and an authenticated one, a preflight that allows
Authorization). A small local server is often simpler — the example app's
demo API is one.
Example app
examples/users is a complete, accessible user-management app built on the
public API only: an authenticated createApi() client, a reactive list with loading, empty and
error states, create/update/delete mutations with invalidation, 422 validation messages from
problem details, an idempotent retried POST, network and auth timeouts, simulated outages and the
bounded 401 recovery — against a deterministic demo API on a second origin, with no real
credentials. Run it from this repository:
npm --prefix api run devthen open http://127.0.0.1:3900/api/examples/users/. Its browser tests
(api/browser-tests/users-example.spec.js) run in Chromium, Firefox and WebKit.
Same-origin only
api() only requests URLs on the page's own origin. Relative URLs are the normal case:
api("/api/users"); // ✔ same origin
api("api/users"); // ✔ resolved against the document's base URL, like fetch()
api("https://app.example.com/api/users"); // ✔ when the page is on https://app.example.com
api("https://unknown.example/users"); // ✘ ApiError type "security" — no request is sentThe check runs on the final URL — after params and query are applied — so neither can
turn a same-origin URL into another one. The URL is resolved with the platform URL API and compared by origin (scheme + host + port),
so look-alikes are refused too: //evil.example, /\evil.example, http:// on an https://
page, another port, https://app.example.com.evil.example, and non-HTTP schemes (javascript:,
data:, blob:). URLs with credentials (https://user:pass@…) are refused. With no http(s)
page origin to compare against (no location, a file:// page, a sandboxed iframe), every
request is refused.
The request itself is sent with mode: "same-origin" and credentials: "same-origin", so the
browser also enforces it: a same-origin URL that redirects to another origin fails.
api() doesn't touch CORS, CSP or any other browser protection — it adds a check on top.
This is a client-side guard against requesting the wrong place, not access control. Your server must still authenticate and authorize every request. For cookies, credentials and CSP, see the production security checklist.
External APIs: createApi()
api() stays same-origin-only. To call an API on another origin, create a client for it —
an explicit, visible trust decision in your code:
import { createApi } from "@zoijs/api";
const github = createApi({ baseURL: "https://api.github.com" });
const repos = github("/users/:user/repos", { params: { user: "example" } });
const issue = github.post("/repos/:owner/:repo/issues", { idempotencyKey: true, retry: 2 });A client has exactly the same surface and behavior as api — client(url, options) for reads,
client.post/put/patch/delete(url, options) for writes, with the same options (params,
query, debounce, timeout, initial, problemDetails, retry, invalidate, exclusive,
idempotencyKey, …) and the same engine. Only the URLs it accepts and the way it sends them
differ. Nothing global changes: api() and every other client are unaffected, and no module can
make api() cross-origin-capable.
baseURL — required; with the optional auth, the only two
options (anything else, such as headers or credentials, is a TypeError):
- an absolute
https:URL — plainhttp:only for a loopback host (localhost,*.localhost,127.x.x.x,[::1]), for local development; - no credentials (
https://user:pass@…), query string, fragment, backslashes, whitespace or./..segments; relative (/api), protocol-relative (//host) and non-HTTP URLs (javascript:,data:,blob:,file:) are refused; - the host written in canonical form — what the URL parser would keep. Anything it would
silently rewrite is refused: percent-encoding (
api%2eexample.com), full-width or other non-ASCII characters (write an internationalized name in itsxn--form), and alternative IP spellings (127.1,0x7f.0.0.1,2130706433,[::ffff:127.0.0.1]). Letter case and the scheme's default port (:443) are the only differences allowed. For loopback, writelocalhost,127.0.0.1or[::1]; - an optional base path. It is always a directory:
https://api.example.com/v1andhttps://api.example.com/v1/are the same client (client.baseURLshows the normalized, read-only value).
Paths resolve under the base path. A client's URLs are paths starting with /, and the
leading / means "the client's base", not the origin's root:
const billing = createApi({ baseURL: "https://billing.example.com/v1/" });
billing("/customers"); // → https://billing.example.com/v1/customers
billing("/customers/:id", { params: { id: "a/b" } }); // → …/v1/customers/a%2Fb
billing("/search", { query: { q: "late fees" } }); // → …/v1/search?q=late+fees
billing("/"); // → https://billing.example.com/v1/One client = one origin, and requests stay under its base path. A full URL (even to the same
origin), //host, a relative path (users), a backslash, whitespace or a ./.. segment —
also percent-encoded, like %2e%2e — is refused with a TypeError when the resource or mutation
is created, before any request. Param values can't get around it: each fills exactly one encoded
segment. The final URL — after params and query — is checked again before every request: it must
have exactly the client's scheme, host and port (so https://api.example.com.evil.example,
another port or http:// are refused) and stay under the base path — checked by whole path
segments, so a sibling such as /v1-evil/ is never inside /v1/.
Encoded separators are left alone: %2F, %5C and %252e stay encoded in the URL that is
sent, exactly as with api(), so they can't move the request on the client side. How the
external server decodes and routes such a path is up to that server — the client can't enforce
it.
How requests are sent:
mode: "cors"— a client doesn't bypass CORS. The API must allow your page's origin (Access-Control-Allow-Origin), and for JSON bodies orIdempotency-Keyit must answer the preflight (Access-Control-Allow-Headers: Content-Type, Idempotency-Key). If it doesn't, the browser refuses the request and you get anApiErrorof type"network"— the browser hides the reason, and nothing is retried in a weaker mode (nono-cors, no proxy). To honorRetry-Afteracross origins, the API must also sendAccess-Control-Expose-Headers: Retry-After.credentials: "omit"— no cookies or HTTP authentication are sent to the external origin, even when the browser has some for it. (api()keeps sending same-origin cookies.)redirect: "manual"— redirects are never followed, not even within the same origin. A 3xx answer is anApiErrorof type"security"and isn't retried, so a redirect can't steer a request — or its body — to another origin. PointbaseURLat the final location.referrerPolicy: "strict-origin-when-cross-origin"— the external API learns at most your page's origin (which theOriginheader already tells it), never the page's path or query string, even if the page's own referrer policy is looser.- No credentials unless you add a Bearer token. Without
auththe request is anonymous; with it, see Authenticated API clients. There are no arbitrary headers, API-key or cookie options.
Everything else is unchanged for clients: secret-looking query keys are still refused, error
messages stay log-safe, problemDetails has the same media-type, 64 KB and field rules (an
external server's text is no more trusted than your own), and every retry replays the same
method, URL, query, body and idempotency key — the base URL is never re-read. error.url is the
external origin + path
