@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.
- package/api-surface.md +4 -4
- package/docs/changelog.md +60 -0
- package/docs/client/index.md +277 -70
- package/docs/contributing.md +18 -0
- package/docs/flow/icons.md +199 -0
- package/docs/inertia/index.md +6 -1
- package/docs/inertia/props.md +1 -1
- package/docs/inertia/rendering.md +79 -0
- package/docs/support-policy.md +20 -5
- package/package.json +3 -3
- package/docs/client/auth.md +0 -113
- package/docs/client/errors.md +0 -139
- package/docs/client/files.md +0 -118
- package/docs/client/references.md +0 -58
- package/docs/client/requests.md +0 -131
- package/docs/client/resilience.md +0 -141
- package/docs/client/testing.md +0 -146
|
@@ -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.
|
package/docs/inertia/index.md
CHANGED
|
@@ -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={
|
|
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
|
package/docs/inertia/props.md
CHANGED
|
@@ -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="
|
|
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
|
package/docs/support-policy.md
CHANGED
|
@@ -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
|
|
56
|
-
| MySQL |
|
|
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
|
+
"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.
|
|
38
|
+
"@zerotal/core": "1.7.4"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0",
|
|
42
|
-
"@zerotal/orm": "1.7.
|
|
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": [
|
package/docs/client/auth.md
DELETED
|
@@ -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.
|
package/docs/client/errors.md
DELETED
|
@@ -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.
|