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.1.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.1.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.1.0/src/index.js": "sha384-LIWx4UtyMnJj9TWQwzIJ6apo87l7n+1SaxZakf7SxeXoXr5cyiPH5IVXvlCqSOgH",
    "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()) return html`<p>Loading…</p>`;
      if (users.error()) return html`<p role="alert">${users.error().message}
        <button onclick=${() => users.refresh()}>Retry</button></p>`;
      return html`<ul>${each(() => users.data() ?? [], (u) => u.id, (u) => html`<li>${u.name}</li>`)}</ul>`;
    }}
  `;
}

mount(Users, "#app");

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.

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. The 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".
  • 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.

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, another origin, headers — use resource() and action() directly; they're what api() is made of.

Reference#

The package README documents every option: params, query, debounce, timeout, initial (server-rendered data), problemDetails, retry, retryDelay, invalidate, exclusive, idempotencyKey, FormData uploads, and the full ApiError shape.