@zerotal/arch 1.7.3 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ ---
2
+ title: Icons
3
+ description: 2,060 icons bundled with Flow's component library — typed by name, rendered on the server, nothing to install.
4
+ ---
5
+
6
+ # Icons
7
+
8
+ `<Icon>` draws an icon by name. The set ships inside `@zerotal/flow-ui`, so this
9
+ works in a new app with nothing installed and nothing configured:
10
+
11
+ ```tsx
12
+ import { Icon } from "@zerotal/flow-ui";
13
+
14
+ <Icon name="inbox" />
15
+ <Icon name="chevron-right" />
16
+ <Icon name="trash-2" class="size-5 text-red-600" />
17
+ ```
18
+
19
+ The name is a union of every bundled icon, so a typo is a compile error rather
20
+ than a blank space nobody notices until it is in front of a user:
21
+
22
+ ```text
23
+ Type '"inbxo"' is not assignable to type 'IconName'. Did you mean '"inbox"'?
24
+ ```
25
+
26
+ That works on install — there is no generator to run first. The icons belong to
27
+ the framework, so the names are known before your app exists.
28
+
29
+ ## Props
30
+
31
+ `IconProps` — anything else you pass lands on the rendered `<svg>`.
32
+
33
+ | Prop | Type | Description |
34
+ | ------- | ---------- | ------------------------------------------------------------------------------ |
35
+ | `name` | `IconName` | Which icon. Checked at compile time against the bundled and registered names. |
36
+ | `label` | `string` | Accessible name. Omit for decoration — the icon is hidden from screen readers. |
37
+ | `class` | `string` | Merged with the defaults rather than replacing them. |
38
+
39
+ ## Sizing and colour
40
+
41
+ An icon is `1em` square and painted in `currentColor`, so by default it matches
42
+ the text it sits beside — size, weight of colour, and all. Override with classes
43
+ rather than attributes:
44
+
45
+ ```tsx
46
+ <p class="text-sm text-slate-600">
47
+ <Icon name="info" /> Saved a moment ago
48
+ </p>
49
+
50
+ <Icon name="triangle-alert" class="size-8 text-amber-500" />
51
+ ```
52
+
53
+ Sizing through CSS is what lets an icon line up with a label without either being
54
+ measured. `class="size-5"` sets both dimensions; `text-red-600` on the icon — or
55
+ on anything above it — colours it.
56
+
57
+ ## Labelling
58
+
59
+ An icon is decoration by default and hidden from screen readers, which is right
60
+ when it sits next to text that already says the same thing. Announcing it there
61
+ would read the meaning out twice.
62
+
63
+ An icon that is the **only** content of a control is not decoration. Without a
64
+ label, that button has no accessible name at all:
65
+
66
+ ```tsx
67
+ <button onClick={this.remove}>
68
+ <Icon name="trash-2" label="Delete order" />
69
+ </button>
70
+ ```
71
+
72
+ ## A name that isn't known until runtime
73
+
74
+ A name from a database column or a URL segment is not a literal, so it does not
75
+ satisfy the union. `isIconName()` narrows it:
76
+
77
+ ```tsx
78
+ import { Icon, isIconName } from "@zerotal/flow-ui";
79
+
80
+ override async render() {
81
+ const glyph = this.status.icon; // string, from a row
82
+ return isIconName(glyph) ? <Icon name={glyph} /> : <Icon name="circle-help" />;
83
+ }
84
+ ```
85
+
86
+ It is a shape check, not an existence check — it says the string could name an
87
+ icon, not that anything answers to it. An icon that resolves to nothing renders
88
+ nothing rather than throwing, because taking a page down over a missing glyph is
89
+ the worse failure.
90
+
91
+ ## Drawn for the gaps
92
+
93
+ Four names are drawn here rather than coming from the set, because the flows they
94
+ label are ones Zerotal ships and the set has no icon for as a concept:
95
+
96
+ | Name | For |
97
+ | ------------ | -------------------------------------------------------------- |
98
+ | `passkey` | WebAuthn sign-in — a fingerprint that ends in a key |
99
+ | `two-factor` | TOTP — a second device that has to agree |
100
+ | `otp` | An emailed one-time code — the separate slots it is typed into |
101
+ | `magic-link` | Passwordless sign-in by link |
102
+
103
+ The set has `key-round`, `fingerprint` and `shield-check` — the parts — and a login
104
+ page needs the whole. They are drawn on the same 24×24 stroke grid, so they sit
105
+ beside the other 2,060 without announcing themselves.
106
+
107
+ Nearly everything else that looked missing was there under a name that reads
108
+ differently: `git-branch` not `branch`, `file-json` not `json`, `paperclip` not
109
+ `attachment`, `venetian-mask` for impersonation. Search before you draw.
110
+
111
+ ## Brand marks
112
+
113
+ Three sign-in providers ship as brand marks, because `@zerotal/auth` has a code
114
+ path for each and a sign-in button wants the provider's actual logo:
115
+
116
+ ```tsx
117
+ <button><Icon name="brand-google" /> Continue with Google</button>
118
+ <button><Icon name="brand-github" /> Continue with GitHub</button>
119
+ <button><Icon name="brand-apple" /> Continue with Apple</button>
120
+ ```
121
+
122
+ They come from [Simple Icons](https://simpleicons.org) (**CC0-1.0**, public
123
+ domain), so the paths are the real ones rather than approximations — an
124
+ approximated logo reads as a forgery, not as an icon.
125
+
126
+ The `brand-` prefix is deliberate: the bundled set has its own stroke-style
127
+ `github` and `apple`, and prefixing means neither silently shadows the other, so a
128
+ page picks a style rather than inheriting one. There is no plain `google` — the
129
+ set never had one, which is what made this worth doing.
130
+
131
+ Unlike the rest, brand marks are **solid**: each body carries its own
132
+ `fill="currentColor"`, so it still takes its colour from the text around it.
133
+
134
+ > **CC0 covers copyright, not trademark.** The marks belong to their owners.
135
+ > Labelling a sign-in button with one is nominative use and what brand guidelines
136
+ > contemplate; using one as your own logo is not. For a provider not listed here,
137
+ > `registerIcons()` keeps that decision — and its licence — yours.
138
+
139
+ ## Your own icons
140
+
141
+ A wordmark, a product glyph, a shape nobody has drawn: register it once, from a
142
+ provider's `register()`, and it is available everywhere `<Icon>` is.
143
+
144
+ ```ts
145
+ import { registerIcons } from "@zerotal/flow-ui";
146
+
147
+ registerIcons({
148
+ "acme-wordmark": {
149
+ body: '<path fill="currentColor" d="M4 4h16v16H4z"/>',
150
+ },
151
+ });
152
+ ```
153
+
154
+ Each entry is an `IconBody` — the markup that goes **inside** the `<svg>`, plus an
155
+ optional `width`/`height` when it was drawn against a box other than 24×24. A name
156
+ you register shadows a bundled one, which is how you substitute your own drawing
157
+ without renaming every call site.
158
+
159
+ Registering supplies the body; the compiler needs telling separately. Declare the
160
+ names on `CustomIconRegistry` and they join the same union as the bundled ones —
161
+ `IconName` widens, and `CustomIconName` is the set you added:
162
+
163
+ ```ts
164
+ declare module "@zerotal/flow-ui" {
165
+ interface CustomIconRegistry {
166
+ "acme-wordmark": true;
167
+ }
168
+ }
169
+ ```
170
+
171
+ > **The body is inserted as markup, not text.** Register only SVG you control.
172
+ > A body built from user input is the same hole as any other unescaped HTML.
173
+
174
+ ### Matching the set
175
+
176
+ Icons drawn to a different grid look wrong beside ones that aren't. The bundled
177
+ set is 24×24 **stroke**: no fills, `stroke="currentColor"`, `stroke-width="2"`,
178
+ round caps and joins. Copy the shape of an existing icon rather than exporting
179
+ from a design tool, which will hand you absolute fills on a half-pixel grid.
180
+
181
+ ## What ships, and why it can
182
+
183
+ The bundled set is [Lucide](https://lucide.dev), which is ISC-licensed — the
184
+ reason it can be shipped inside the package at all. Redistributing it carries a
185
+ notice (`LICENSE-ICONS.md` in `@zerotal/flow-ui`) and asks nothing of your
186
+ application's UI.
187
+
188
+ Most sets are not so simple. Font Awesome Free is CC BY 4.0 — usable, and only
189
+ with attribution _you_ would have to display — and Font Awesome Pro may not be
190
+ redistributed at any price. Bundling either would relicense someone else's artwork
191
+ on behalf of every app that installed Flow. If you are entitled to a set we cannot
192
+ ship, `registerIcons()` is how you bring it: your artwork, your licence.
193
+
194
+ ## Cost
195
+
196
+ None on the client. Flow renders on the server, so an icon reaches the browser as
197
+ markup that is already in the page — no icon font, no sprite sheet, no request per
198
+ glyph, and nothing for a strict [Content Security Policy](/docs/flow/performance)
199
+ to block. The set is read once per process and never sent.
@@ -162,7 +162,7 @@ export default function Dashboard({ posts, auth }: Props) {
162
162
  <h1>Dashboard</h1>
163
163
  {auth.user && <p>Welcome back, {auth.user.name}</p>}
164
164
  {posts.map((post) => (
165
- <Link key={post.id} href={`/posts/${post.slug}`}>
165
+ <Link key={post.id} href={route("posts.show", { slug: post.slug })}>
166
166
  {post.title}
167
167
  </Link>
168
168
  ))}
@@ -176,6 +176,11 @@ Note `auth` is available without the controller passing it — see
176
176
  [`make:page`](/docs/inertia/build#generating-a-page) and bundle them with
177
177
  [`inertia:build`](/docs/inertia/build#building-assets).
178
178
 
179
+ `route("posts.show", { slug })` builds the URL from the route's **name** rather than
180
+ hard-coding the path, so renaming a route updates every link to it and a typo fails
181
+ the build. Prefer it over a literal `href` anywhere you link — see
182
+ [Building URLs](/docs/inertia/rendering#building-urls-with-route).
183
+
179
184
  ## Testing
180
185
 
181
186
  Set your suite up once as described in [Testing](/docs/testing). An Inertia route
@@ -339,7 +339,7 @@ export default function Page() {
339
339
  return (
340
340
  <>
341
341
  {flash.success && <div className="toast">{flash.success}</div>}
342
- {auth.user ? <span>{auth.user.name}</span> : <a href="/login">Sign in</a>}
342
+ {auth.user ? <span>{auth.user.name}</span> : <a href={route("login")}>Sign in</a>}
343
343
  </>
344
344
  );
345
345
  }
@@ -147,6 +147,85 @@ array** directly as a shorthand. To use both, pass props third and middleware fo
147
147
  Router.inertia("/admin", "Admin/Dashboard", { title: "Admin" }, [AuthMiddleware]);
148
148
  ```
149
149
 
150
+ ## Building URLs with route()
151
+
152
+ A hard-coded `href="/posts/hello"` is a string nothing checks. Rename the route and
153
+ every link to it keeps compiling and starts 404ing — a bug that surfaces when
154
+ someone clicks, not when someone builds.
155
+
156
+ Name the route instead, and let the URL be derived:
157
+
158
+ ```tsx
159
+ import { Link } from "@inertiajs/react";
160
+
161
+ <Link href={route("posts.show", { slug: post.slug })}>{post.title}</Link>
162
+ <Link href={route("posts.index", {}, { page: 2 })}>Next</Link>
163
+ ```
164
+
165
+ No import for `route` — `defineRoutes()` installs it globally, and the names are
166
+ checked against the same registry your controllers use, so `route("posts.shwo")`
167
+ fails the build. [Routing](/docs/routing#route-in-the-browser) owns the mechanics:
168
+ the generated table, wiring your entry point, typing, and `route.dynamic()` for a
169
+ name only known at runtime.
170
+
171
+ ### Forms submit to a name too
172
+
173
+ A form's action is the same kind of string as a link's `href`, and gets the same
174
+ treatment. `useForm()` and `router` both take a URL, so hand them one that was built
175
+ from the route name:
176
+
177
+ ```tsx
178
+ import { useForm, router } from "@inertiajs/react";
179
+
180
+ export default function Edit({ post }: Props) {
181
+ const form = useForm({ title: post.title, body: post.body });
182
+
183
+ const submit = (e: React.FormEvent) => {
184
+ e.preventDefault();
185
+ form.put(route("posts.update", { slug: post.slug }));
186
+ };
187
+
188
+ const destroy = () => {
189
+ router.delete(route("posts.destroy", { slug: post.slug }));
190
+ };
191
+
192
+ return (
193
+ <form onSubmit={submit}>
194
+ <input value={form.data.title} onChange={(e) => form.setData("title", e.target.value)} />
195
+ {form.errors.title && <span>{form.errors.title}</span>}
196
+ <button disabled={form.processing}>Save</button>
197
+ <button type="button" onClick={destroy}>
198
+ Delete
199
+ </button>
200
+ </form>
201
+ );
202
+ }
203
+ ```
204
+
205
+ The names follow the same convention the router generates: a `POST` is
206
+ `posts.store`, `PUT`/`PATCH` is `posts.update`, `DELETE` is `posts.destroy`. So the
207
+ name in the component and the route the controller is mounted on cannot drift apart
208
+ silently — change the URL and both ends move together.
209
+
210
+ This matters more for a form than for a link. A broken link 404s where someone can
211
+ see it; a form posting to a stale URL fails **after** the user has filled it in, and
212
+ the data goes with it.
213
+
214
+ Build the URL the same way for [Precognition](/docs/inertia/props#precognition), so
215
+ live validation and the real submit cannot end up aimed at different routes — the
216
+ failure there is a form that validates clean and then rejects on save.
217
+
218
+ ### One thing Inertia adds: define the routes in _both_ entries
219
+
220
+ An Inertia page renders twice — once in the SSR process, once in the browser — so a
221
+ component calling `route()` runs in both. A table defined in only one of them throws
222
+ in the other: miss the SSR entry and `POST /__ssr` answers `500` with
223
+ `[Inertia] SSR render failed` in the log, for a page the browser then renders
224
+ perfectly well.
225
+
226
+ Call `defineRoutes(ROUTES)` in your browser entry **and** in your
227
+ [SSR entry](/docs/inertia/ssr). Same static import, same table.
228
+
150
229
  ## Redirects
151
230
 
152
231
  After a non-GET action (a form POST/PUT/DELETE), redirect as usual — return a 302 and
@@ -49,11 +49,11 @@ course?", so this is it:
49
49
 
50
50
  ## Databases
51
51
 
52
- | Database | Status |
53
- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54
- | SQLite | Supported. The default; the full test suite runs against it on every merge. |
55
- | PostgreSQL | Supported. A smoke suite runs against a real PostgreSQL 16 on every merge — schema DDL, identity columns, CRUD, type round-trips, row locks and transaction rollback — and the job blocks a merge when it fails. The bulk of the ORM suite still runs on SQLite, so coverage of the Postgres path is narrower than of the default one. |
56
- | MySQL | Experimental. The ORM ships a MySQL dialect and the scaffolder can configure it, but no CI suite runs against a real MySQL server yet treat it as unverified until it joins the tested matrix. |
52
+ | Database | Status |
53
+ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54
+ | SQLite | Supported. The default; the full test suite runs against it on every merge. |
55
+ | PostgreSQL | Supported. A smoke suite runs against a real PostgreSQL 16 on every merge — schema DDL and `ALTER`, identity columns, CRUD, type round-trips, unique and NOT NULL enforcement, row locks and transaction rollback — and the job blocks a merge when it fails. The bulk of the ORM suite still runs on SQLite, so the Postgres path is covered more narrowly than the default one. |
56
+ | MySQL | Supported, hardening. The same smoke suite runs against a real MySQL 8 on every merge and blocks on failure. It is newer than the Postgres job and has found one defect already (`string()` was not indexable), so treat MySQL as verified in the paths the suite covers and less proven than PostgreSQL outside them. |
57
57
 
58
58
  Redis-backed drivers (cache, session, queue, broadcasting) build on
59
59
  `Bun.RedisClient` and are tested against the protocol surface it provides.
@@ -100,6 +100,21 @@ a contract, not a mood:
100
100
  - **experimental** — no compatibility promise. The API may change or the package
101
101
  may be absorbed into another in any release. Build on it with your eyes open.
102
102
 
103
+ ### A label below stable carries a review date
104
+
105
+ An honest "experimental" is useful once and corrosive indefinitely: a package that
106
+ has worn the label for a year is not being cautious, it is unowned. So each one
107
+ below `stable` names the release by which it is reviewed, and the review has three
108
+ outcomes — promote, keep with a new date and the reason, or withdraw.
109
+
110
+ | Package | Now | Reviewed by |
111
+ | --------------- | -------------- | ----------- |
112
+ | `@zerotal/ai` | `experimental` | **1.9.0** |
113
+ | `@zerotal/arch` | `beta` | **1.9.0** |
114
+
115
+ Neither is in the `zerotal` meta-package and nothing `stable` depends on either,
116
+ so the cost of the label falling due is ours and not yours.
117
+
103
118
  A package is never more mature than what it is built on: a stable package whose
104
119
  foundation can change under it is not stable, whatever its own label says. So
105
120
  `@zerotal/admin` and `@zerotal/monitor` cannot pass `@zerotal/flow`, and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/arch",
3
- "version": "1.7.3",
3
+ "version": "1.7.4",
4
4
  "license": "MIT",
5
5
  "maturity": "beta",
6
6
  "private": false,
@@ -35,11 +35,11 @@
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@zerotal/core": "1.7.3"
38
+ "@zerotal/core": "1.7.4"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.7.3"
42
+ "@zerotal/orm": "1.7.4"
43
43
  },
44
44
  "description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
45
45
  "keywords": [
@@ -1,113 +0,0 @@
1
- ---
2
- title: Client Authentication
3
- description: Bearer tokens, CSRF, and refreshing credentials on a 401.
4
- ---
5
-
6
- # Authentication & CSRF
7
-
8
- The client supports the two ways a browser app proves who it is, and the choice is
9
- usually made for you by where the API lives:
10
-
11
- - **Bearer tokens** suit APIs on another origin, mobile clients, and anything where
12
- the caller holds a credential it can attach itself.
13
- - **Session cookies** suit an API served from your own domain, where the browser
14
- already carries the session and CSRF protection is the concern instead.
15
-
16
- ## Bearer tokens
17
-
18
- Attach a bearer token (string or a resolver, sync or async) without writing an interceptor —
19
- update it at runtime with `setToken()`:
20
-
21
- ```ts
22
- // app/api/client.ts
23
- const api = createApiClient<Routes>({
24
- token: () => authStore.accessToken, // re-read on every request
25
- });
26
- api.setToken(freshToken); // or update imperatively
27
- ```
28
-
29
- Prefer the resolver form. A plain string is captured once at construction, so a
30
- token refreshed later never reaches the client; a function is consulted on every
31
- request and always sees the current value.
32
-
33
- Calling `setToken()` with no argument clears the token, which is what a logout
34
- should do — otherwise the next request still carries the credential of the user who
35
- just signed out.
36
-
37
- > **Note** — The `token` is only applied when no `Authorization` header is already
38
- > present on the request, so a per-request override always wins.
39
-
40
- ## Session cookies and CSRF
41
-
42
- For session/cookie (SPA) auth, set `withCredentials` to send cookies, which also turns on CSRF:
43
- the client reads the `XSRF-TOKEN` cookie and sends it as `X-XSRF-TOKEN` on mutating requests
44
- (matching the session/CSRF middleware). Customize the names with `csrf`:
45
-
46
- ```ts
47
- // app/api/client.ts
48
- createApiClient<Routes>({
49
- withCredentials: true, // credentials: 'include' + CSRF on
50
- csrf: { cookie: "XSRF-TOKEN", header: "X-XSRF-TOKEN" }, // defaults shown
51
- });
52
- ```
53
-
54
- Enabling `withCredentials` turns CSRF on by default, so the two travel together and
55
- neither needs configuring in the common case. Set `csrf: false` to opt out, or pass
56
- an object to rename the cookie and header to match a server that uses different
57
- ones.
58
-
59
- The token is attached only to mutating requests — `POST`, `PUT`, `PATCH`, `DELETE`.
60
- A `GET` is exempt because it should not change state, so it needs no protection
61
- from being triggered cross-site. If a `GET` in your API does change something, that
62
- is the thing to fix; adding a CSRF header to it would only hide the problem.
63
-
64
- The header is skipped when the request already carries one, so a caller that sets
65
- its own value keeps it.
66
-
67
- ## 401 / token refresh
68
-
69
- `onUnauthorized` is called when any request receives a 401 response. It receives
70
- the error and a `retry` function. Call `retry()` — optionally with header overrides
71
- — to re-execute the failed request. The retry is limited to **one attempt**.
72
-
73
- ```ts
74
- // app/api/client.ts
75
- const api = createApiClient<Routes>({
76
- baseUrl: "https://api.example.com",
77
-
78
- onUnauthorized: async (err, retry) => {
79
- const newToken = await authStore.refresh();
80
- return retry({ Authorization: `Bearer ${newToken}` });
81
- },
82
- });
83
- ```
84
-
85
- If `onUnauthorized` is not provided or does not call `retry`, the 401 error is
86
- thrown normally.
87
-
88
- The single-attempt limit is deliberate: a refresh that itself returns 401 would
89
- otherwise retry forever, turning an expired session into an endless loop of
90
- requests. When the retry also fails, the error is thrown and the app can send the
91
- user to the login screen.
92
-
93
- One case the hook does not solve on its own is a page that fires several requests
94
- at once. Each 401 calls `onUnauthorized` separately, so a naive handler triggers
95
- several concurrent refreshes and the losers of that race may invalidate the winner's
96
- token. Have the refresh itself de-duplicate — cache the in-flight promise in your
97
- auth store and hand the same one to every caller until it settles:
98
-
99
- ```ts
100
- // app/api/authStore.ts
101
- let inflight: Promise<string> | null = null;
102
-
103
- export function refresh(): Promise<string> {
104
- inflight ??= requestNewToken().finally(() => (inflight = null));
105
- return inflight;
106
- }
107
- ```
108
-
109
- ## Next steps
110
-
111
- - [Client overview](/docs/client) — the guide's front page and the rest of the sections.
112
- - [Error handling](/docs/client/errors) — the errors a rejected request throws.
113
- - [CSRF protection](/docs/csrf) — the server side of the cookie and header pair.
@@ -1,139 +0,0 @@
1
- ---
2
- title: Client Error Handling
3
- description: What a failed request throws, and how to tell the failure modes apart.
4
- ---
5
-
6
- # Error handling
7
-
8
- Non-2xx responses throw `ApiClientError`:
9
-
10
- ```ts
11
- // in any frontend module
12
- import { ApiClientError } from "@zerotal/client";
13
-
14
- try {
15
- await api.post("/api/users", { name: "", email: "bad" });
16
- } catch (err) {
17
- if (err instanceof ApiClientError) {
18
- console.log(err.status); // 422
19
- console.log(err.statusText); // 'Unprocessable Entity'
20
- console.log(err.body); // raw response text (the error message truncates it to 200 chars)
21
- }
22
- }
23
- ```
24
-
25
- ## Telling the failure modes apart
26
-
27
- Two very different things can go wrong, and only one of them produces an
28
- `ApiClientError`:
29
-
30
- | What happened | What is thrown |
31
- | -------------------------------- | ----------------------------------- |
32
- | The server answered with non-2xx | `ApiClientError` |
33
- | A 422 in the validator's shape | `ValidationError` |
34
- | The circuit breaker is open | `CircuitBreakerOpenError` |
35
- | No answer at all | The platform's own error, unwrapped |
36
-
37
- That last row is the one worth internalising. A DNS failure, a dropped connection,
38
- a CORS rejection, or an aborted request never reaches the point where a status
39
- exists, so `fetch` rejects with its own error and the client passes it through
40
- untouched. An `instanceof ApiClientError` check therefore does _not_ catch an
41
- offline user — and a `catch` block that assumes `err.status` exists throws a second
42
- error while handling the first.
43
-
44
- ```ts
45
- try {
46
- await api.get("/api/users");
47
- } catch (err) {
48
- if (err instanceof ValidationError) showFieldErrors(err.errors);
49
- else if (err instanceof ApiClientError) showStatus(err.status);
50
- else showOffline(); // no response: network, CORS, timeout, or abort
51
- }
52
- ```
53
-
54
- Timeouts and cancellations land in that final branch too, since both abort the
55
- request rather than producing a response.
56
-
57
- ## Reading response headers
58
-
59
- `ApiClientError` carries the response headers when there were any, which is where
60
- rate limiters and throttles put the information you need to react well:
61
-
62
- ```ts
63
- // in any frontend module
64
- if (err instanceof ApiClientError && err.status === 429) {
65
- const waitMs = err.retryAfterMs; // parsed Retry-After, or null
66
- if (waitMs !== null) scheduleRetry(waitMs);
67
- console.log(err.headers?.get("X-RateLimit-Remaining"));
68
- }
69
- ```
70
-
71
- `retryAfterMs` handles both forms the header takes — a delta in seconds and an
72
- HTTP-date — and returns milliseconds, or `null` when the header is absent or
73
- cannot be parsed.
74
-
75
- ## Global handlers
76
-
77
- The `onError` callback fires for every non-2xx response before the error is thrown.
78
- Use it for global side-effects (toasts, logging) without needing try/catch at every
79
- call site:
80
-
81
- ```ts
82
- // app/api/client.ts
83
- const api = createApiClient<Routes>({
84
- baseUrl: "https://api.example.com",
85
- onError: (err) => {
86
- toast.error(`${err.status}: ${err.statusText}`);
87
- logger.error("api_error", { status: err.status, body: err.body });
88
- },
89
- });
90
- ```
91
-
92
- > **Warning** — `onError` fires for every non-2xx error including 401, even when `onUnauthorized` is also configured. To suppress the global error callback for 401 during token refresh, guard by status inside `onError`.
93
-
94
- Because `onError` only ever sees responses, it does not report the network failures
95
- described above. Reporting that should also cover "the request never arrived"
96
- belongs in the caller, or in a wrapper around it.
97
-
98
- ### Typed validation errors
99
-
100
- A `422` response whose body matches the framework's validation shape (`{ message, errors }`,
101
- as produced by [`@zerotal/validator`](/docs/validator)) throws a `ValidationError` — an
102
- `ApiClientError` subclass with the field errors already parsed:
103
-
104
- ```ts
105
- // in any frontend module
106
- import { ValidationError } from "@zerotal/client";
107
-
108
- try {
109
- await api.post("/api/users", form);
110
- } catch (err) {
111
- if (err instanceof ValidationError) {
112
- setFieldErrors(err.errors); // { email: ["…"], password: ["…"] }
113
- err.has("email"); // boolean
114
- err.first("email"); // first message, or undefined
115
- err.fields(); // ["email", "password"]
116
- err.validationMessage; // "The given data was invalid."
117
- }
118
- }
119
- ```
120
-
121
- Check for `ValidationError` before `ApiClientError`. It is a subclass, so the
122
- broader check also matches it and would swallow the parsed field errors.
123
-
124
- A 422 whose body does not match that shape stays a plain `ApiClientError`, so an
125
- endpoint returning its own error format still surfaces as an ordinary failure
126
- rather than quietly producing an empty `errors` object.
127
-
128
- `onForbidden` is the 403 counterpart of `onUnauthorized`:
129
-
130
- ```ts
131
- // app/api/client.ts
132
- createApiClient<Routes>({ onForbidden: () => router.push("/403") });
133
- ```
134
-
135
- ## Next steps
136
-
137
- - [Client overview](/docs/client) — the guide's front page and the rest of the sections.
138
- - [Resilience](/docs/client/resilience) — retries, timeouts, and the circuit breaker.
139
- - [Authentication](/docs/client/auth) — the 401 refresh hook.