Same-origin HTTP — @zoijs/api

Use api() to read data and the mutation methods to change it. @zoijs/api is the high-level data layer for Zoijs: reads are a resource, writes are an action, and the request in between — URL building, JSON, HTTP errors and a same-origin security check — is handled for you.

import { api } from "@zoijs/api";

const users = api("/api/users");          // read: data(), loading(), error(), refresh()
const create = api.post("/api/users");    // write: run(), pending(), error(), done()

No fetch, no res.ok check, no JSON.stringify, no Content-Type header, no refresh() after a save.

Install#

npm install @zoijs/core @zoijs/resource @zoijs/action @zoijs/api

@zoijs/core, @zoijs/resource and @zoijs/action are peer dependencies (@zoijs/core 1.2.0+, @zoijs/resource 0.3.0+, @zoijs/action 0.2.0+).

Or with no install, from a CDN: the import map needs four entries — @zoijs/core, @zoijs/resource, @zoijs/action and @zoijs/api — each an exact-version jsDelivr file URL with an integrity hash for every module file. Generate it for the versions you use (in the Zoijs repo; it hashes the published npm tarballs):

node scripts/cdn-importmap.mjs @zoijs/core@1.9.0 @zoijs/resource@0.3.0 @zoijs/action@0.2.0 @zoijs/api@0.5.0 --prod

For the current releases that prints this map (tested in a browser against jsDelivr; the @zoijs/core entry uses the production build):

<script type=class="tok-string">"importmap">
{
  "imports": {
    "@zoijs/core": "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/prod.js",
    "@zoijs/resource": "https://cdn.jsdelivr.net/npm/@zoijs/resource@0.3.0/src/index.js",
    "@zoijs/action": "https://cdn.jsdelivr.net/npm/@zoijs/action@0.2.0/src/index.js",
    "@zoijs/api": "https://cdn.jsdelivr.net/npm/@zoijs/api@0.5.0/src/index.js"
  },
  "integrity": {
    "https://cdn.jsdelivr.net/npm/@zoijs/action@0.2.0/src/index.js": "sha384-xX/wRdVh4j+EErda1XJCYyK3q/eQnl+TE9mcl8yBknF5M5XKpgAoZkBBMrfQUt1Z",
    "https://cdn.jsdelivr.net/npm/@zoijs/api@0.5.0/src/index.js": "sha384-+zbGwCckJWMydKBHVXkdFx5/itzVp9zeCmAND9E2yJOjbLpTnTg+mFoCAz8u91q3",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/boundary.js": "sha384-sgP+7Uk+gh1Gk39OqMxZEo9Mox6uM6upgCbk9n4HKa8L3LywliSiXjQPQrjj/OG/",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/brand.js": "sha384-SQKAavcGif93XT58P5unWneM/VMR1rPsGM/wF63kNN4YaR2RdK2mu2IwULo9VYix",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/each.js": "sha384-ple5ogdJi5exkzQbgaQ8jnLLOymkmnG8dG/B8L36QkDbmIOiuRL+IdNVNNLYh+3N",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/html.js": "sha384-5sS5ABTdRpfXmhwZuIyhqcSO7fCY1VFVlYcolWRfLsRVzytc9JnUkjfX2Adfy8WI",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/mount.js": "sha384-eAm8c9K1SY4j66NnZ4pc4exzlek7iG67di9hU5U9S3PnaZnElkqRGpzOy2z4Uz2h",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/renderer.js": "sha384-H2K6FI3+kv2iPxyLb8V9e9Ux4X95PcPQGYu/h7Gq9rk4brE1nLHKFvI4iG5vt7Uz",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/index.js": "sha384-dDKK+RVA9Pj7X7aJvv0zgepWNZQi9W20zZ1JgACZ4EbIQ9LjvLsss8gsQGiyvcRb",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/prod.js": "sha384-2Wg5h5XW9gULwwZPet1azoni63tKm9iTs9FrAoLV0vGUa77h5WPgqsG/F3TFPuBy",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/computed.js": "sha384-nlAK86Jy1IR3yFSePE38l6cjZqcu5SdnYRVQxJh/+2YQL3WhTHdZ18yCKeKL4ffm",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/core.js": "sha384-dBKT0ohkuylmZHrv0HAjRtTxrMNyo6FZTk8Fqf6LH+aAi3moXikSLGdlPaeZi5IV",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/devtools.js": "sha384-KyEdZp4AcE9RdgocIKg0Q3NKTIyCUPnMi8LLPGrDDhRCJ55DA/JcqSZRFjoHHR5X",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/effect.js": "sha384-EMCI2Kpw6k0EUn5QPC6tJLoON+nUgQ7L25AXgIrWLmhMCLKJq/jP+C6Bk/P6gGDY",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/env.js": "sha384-41791FgWw6cq/pCRB6up+hZExngmUr1tAVxQG58SdvonImEcU6jK7g14jzZAPBFL",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/owner.js": "sha384-r8/NbofI3PXPfKvjxBuhWcRZGMJQn+Ldbne0ghFkjELu6SjvpaWIHnxF8FuScUAp",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/runtime.js": "sha384-5CQnBBrMFUfcq+ahlgE4DX/nG0ua+Bo0l70VtRkXVPWQyvGtXoSScup7FHsQqbA2",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/state.js": "sha384-w2HVkiWmSdb8h7qqr4OViqHJrDqlD/oInr+5Lal+lSvXWu3SPuZv35WWDPsEOgUq",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/utils/dom.js": "sha384-XsdCmmxzHfpBmOdz1648tCRrAMMHbWKIzvm4Aj2VIEpWJDIIaKmihqEARvxN6d+2",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/utils/security.js": "sha384-7UafFG7FtgE/ZSVl+gEOKHc7qewaF0V6hKLEqgI9yj9mGTxz62GTzkrUPfbOXOtT",
    "https://cdn.jsdelivr.net/npm/@zoijs/resource@0.3.0/src/index.js": "sha384-sPZZEuPS4hy0uXwAacAc2euMMziqcwBYhTQ2Z0wKWj3EAuwYJjvxSgtDyuhthMuk"
  }
}
</script>

Then import by name, as everywhere else — see the CDN guide.

Reading data#

api(url) returns a resource: it loads once immediately, and only the latest request can update it.

import { html, mount, each } from "@zoijs/core";
import { api } from "@zoijs/api";

function Users() {
  const users = api("/api/users");

  return html`
    ${() => {
      if (users.loading() && users.data() === undefined) return html`<p role="status">Loading users…</p>`;
      if (users.error())
        return html`<div role="alert">
          <p>Couldn't load users: ${users.error().message}</p>
          <button onclick=${() => users.refresh()}>Try again</button>
        </div>`;
      if (users.data().length === 0) return html`<p>No users yet.</p>`;
      return html`<ul>${each(() => users.data(), (u) => u.id, (u) => html`<li>${u.name}</li>`)}</ul>`;
    }}
  `;
}

mount(Users, "#app");

Those are the four states every view needs — loading, error with a way out, empty, and data — announced to assistive technology with role="status" and role="alert".

Path parameters — each value becomes exactly one encoded path segment, so "a/b" or "../admin" stay data:

const user = api("/api/users/:id", { params: { id } });

Reactive queries — pass a function and read state in it; the request is sent again when that state changes, here after typing pauses for 250 ms:

const q = createState("");
const results = api("/api/search", { query: () => ({ q: q.get() }), debounce: 250 });

params can be a function too. Query values may be strings, numbers, booleans or arrays (tag: ["a", "b"] → tag=a&tag=b); null/undefined are left out.

Timeouts and retries — opt-in:

const users = api("/api/users", { timeout: 10_000, retry: 2 });

A resource created inside a component is cleaned up when it unmounts; one created outside a component can be stopped with users.dispose().

Changing data#

api.post, api.put, api.patch and api.delete return an action. The body is sent as JSON (or as-is for a FormData); run() never rejects — failures land in error().

const tasks = api("/api/tasks");

const add = api.post("/api/tasks", {
  invalidate: tasks, // refresh the list after a successful add
  exclusive: true,   // a double-click sends one request
});

await add.run({ title: "Learn Zoijs" });

When the URL has /:name placeholders, run() takes { params, body } — URL values and body data are always kept separate:

const remove = api.delete("/api/tasks/:id", { invalidate: tasks });
await remove.run({ params: { id } });

const update = api.patch("/api/tasks/:id", { invalidate: tasks });
await update.run({ params: { id }, body: { done: true } });

Invalidation happens only after the server succeeds, and run() doesn't wait for the refresh.

In a form, label the input, show progress, and read event.currentTarget before awaiting — it's null once the handler has awaited:

function NewTask({ tasks }) {
  const addTask = api.post("/api/tasks", { invalidate: tasks, exclusive: true });

  async function submit(event) {
    event.preventDefault();
    const form = event.currentTarget;
    await addTask.run({ title: new FormData(form).get("title") });
    if (addTask.done()) form.reset();
  }

  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>
  `;
}

Retries#

Retries are off by default and only for transient failures: network errors, timeouts, and HTTP 408, 425, 429, 500, 502, 503, 504 — never 4xx client errors. They back off exponentially with jitter, honor Retry-After, and are capped at 5 extra attempts and 30 s per wait.

GET retries need nothing else:

const users = api("/api/users", { retry: 2 }); // at most 3 requests

Mutation retries also need an idempotency key:

const createOrder = api.post("/api/orders", { idempotencyKey: true, retry: 2 });
await createOrder.run({ sku: "ABC-123", quantity: 1 });

Why: a failed attempt may still have reached the server — the order might already exist. Every attempt of one run() sends the same Idempotency-Key header so the server can recognize the repeat. Sending the header doesn't make an endpoint safe to repeat: your server has to store the key and return the original result when it sees it again. Without that, a retry can create a second order. createOrder.retrying() and createOrder.attempt() let the UI show progress.

Security#

@zoijs/api makes the short code the safe code in the browser:

  • Same-origin by default. api()'s final URL must be http(s) on the page's own origin; cross-origin URLs, look-alike hosts and cross-origin redirects are refused, and no request is sent. Requests use mode and credentials "same-origin". External APIs need an explicit createApi() client, locked to one origin.
  • No credentials in URLs. https://user:pass@… is refused, and so are secret-looking query keys (token, access_token, password, api_key, authorization, session, …) — URLs end up in history, logs and analytics. Send credentials in headers or bodies.
  • Safe URL building. Params are encoded as single segments and queries go through URLSearchParams, so values can't change a URL's shape.
  • Log-safe errors. ApiError messages never contain URL paths, query strings, request bodies, headers, idempotency keys or response bodies.
  • Untrusted server text. With problemDetails: true, RFC 9457 problem details appear on error.problem — server-supplied text: render it as text, never as HTML or a link to follow.
  • No automatic mutation retries, and bounded retries when you enable them.
  • A client timeout is not a rollback. When a mutation times out, the server may already have completed it.

None of this is access control. Your server must still authenticate, authorize and validate every request, enforce idempotency keys, and check uploaded files (type, size, content). See the production security checklist.

External APIs#

api() only talks to the page's own origin. To call an API on another origin, create a client for it with createApi() — an explicit trust decision you can see in the code:

import { createApi } from "@zoijs/api";

const github = createApi({ baseURL: "https://api.github.com" });

const repos = github("/users/:user/repos", { params: { user: "example" } });

A client has the same surface and options as api — client(url) reads, client.post, put, patch and delete write — over the same engine, locked to one origin:

  • baseURL is the only option: an absolute https: URL (http: only for localhost-style loopback hosts), with no credentials, query string or fragment. headers, credentials and anything else are refused.
  • Paths resolve under the base path: with baseURL: "https://billing.example.com/v1/", billing("/customers") requests https://billing.example.com/v1/customers.
  • Requests can't leave the client: full URLs, //host, backslashes and .. segments are refused before any request, and redirects are never followed.
  • CORS still applies: the API must allow your page's origin. If it doesn't, the browser refuses the request and you get an ApiError of type "network".
  • No cookies are sent to the external origin; the only credential is an optional Bearer token (below) — authorization remains the external API's responsibility.
  • Browser only: on the server, requests are refused; use initial for server rendering.

Authenticated clients#

Add an auth provider and the client's requests carry a Bearer token:

const backend = createApi({
  baseURL: "https://api.example.com",
  auth: () => session.accessToken,   // sync or async; null/undefined → anonymous request
});

const profile = backend("/profile");
  • Return the token itself — Zoijs sends Authorization: Bearer <token>. Tokens must use the RFC 6750 characters (no spaces, line breaks or Bearer prefix, at most 8192 characters); anything else fails the request with an ApiError of type "auth" before it's sent.
  • Asked once per request — each load, refresh() or run(), never per retry: retries reuse the same token. Zoijs never stores or refreshes tokens; 401 and 403 aren't retried.
  • Only to the client's origin, with cookies still omitted and redirects never followed. The API's CORS preflight must allow Authorization.
  • Cancellable — the provider receives { signal }, which aborts when the request is no longer needed (superseded, disposed, unmounted, reset); pass it to your own token fetch. Add authTimeout: 10_000 to give up on a provider after 10 s (an "auth" error, nothing sent).
  • After a 401, refresh your session yourself and call refresh() or run() again — once until a request succeeds, so a server that keeps answering 401 can't cause a loop. Zoijs never retries a 401. A token the server echoes back exactly in an error is redacted (encoded or truncated copies aren't recognized — don't forward server error text to logs wholesale).
  • Keep tokens short-lived and out of URLs and logs, and avoid persisting them — the external API must still authorize every request. Cross-origin cookie sessions aren't supported.

invalidate works across clients and api(), by resource reference. Everything else — secret-key refusal, log-safe errors, problem details, timeouts and retries — behaves exactly as for api().

Handling errors#

Every failure is an ApiError in error() — never a thrown exception or a rejected promise. Its type says what happened; one function can turn it into words for people:

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
      return "Something went wrong.";
  }
}
  • 404 — show a not-found state, not a generic error.
  • 422 — with problemDetails: true, show error.problem.detail as text. Only the standard problem-details members are kept, so put the message people should read in detail.
  • 5xx and network errors — offer a retry: refresh() for a resource, another run() for a mutation. retry absorbs brief outages for GETs.
  • "auth" — your auth provider failed or timed out and nothing was sent. A 401 means the server refused the token: recover explicitly and once (see Authenticated clients). A network timeout is type "timeout", an authTimeout is type "auth" — they can't be confused.
  • Cancellation isn't an error — a superseded, disposed or reset request ends silently.

The README's cookbook has a runnable example for each case.

Request lifecycle#

WhenA resource (api(url))A mutation (run())
createdloads once (unless seeded with initial)sends nothing until run()
params/query change, refresh()a new request; the old one is aborted and ignored—
dispose() / unmountthe in-flight request is aborted, silentlya waiting provider or retry is cancelled; a request already sent completes
reset()—state cleared; nothing new is sent for that run
double submit—exclusive: true joins the pending run

Cancelled isn't "not received". Aborting stops the browser waiting and guarantees nothing new starts; it can't recall a request that already left — the server may have acted on it. That's why mutations aren't aborted on unmount and are retried only with an idempotency key.

Testing#

Test components against stubbed responses — no real server or credentials. In unit tests, replace fetch (jsdom needs a page URL, so api() has an origin):

import { render, waitFor } from "@zoijs/testing";

globalThis.fetch = async () =>
  new Response(JSON.stringify([{ id: 1, name: "Ada" }]), { headers: { "Content-Type": "application/json" } });

const { getByText } = render(Users);
await waitFor(() => getByText("Ada"));

Return new Response("", { status: 500 }) for an HTTP error, reject with a TypeError for a network failure, hold a promise for a slow response, and use retryDelay: 0 to make retries instant. In Playwright, page.route() does the same in a real browser. The testing guide shows each case.

TypeScript: a URL written as a string literal types its params — api("/users/:id", { params: { userId } }) is an error in your editor, as it is at runtime.

Example app#

api/examples/users is a complete, accessible user-management app on an authenticated createApi() client: a reactive list with loading, empty and error states, create/edit/delete with invalidation, 422 validation messages, an idempotent retried POST, timeouts, simulated outages and the bounded 401 recovery — against a local demo API, with no real credentials. It's browser-tested in Chromium, Firefox and WebKit.

How it fits with the other packages#

@zoijs/api is the one package built on other packages, on purpose — it's the convenience layer over the primitives:

@zoijs/api ──► @zoijs/resource ──► @zoijs/core
     └───────► @zoijs/action ───► @zoijs/core

Every other package depends only on @zoijs/core. If you need full control over a request — a custom fetch or headers — use resource() and action() directly; they're what api() is made of. (Another origin doesn't need that: use createApi().)

Reference#

The package README has a tested quick start, an options table, debugging and testing guides, and documents every option: params, query, debounce, timeout, initial (server-rendered data), problemDetails, retry, retryDelay, invalidate, exclusive, idempotencyKey, FormData uploads, and the full ApiError shape.