@zerotal/arch 1.7.2 → 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.
@@ -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.
@@ -1,118 +0,0 @@
1
- ---
2
- title: Client File Transfers
3
- description: Uploading and downloading binary payloads.
4
- ---
5
-
6
- # File uploads & downloads
7
-
8
- The client encodes plain objects as JSON, which is the right default until the
9
- payload is binary. Recognising the difference is the whole of file handling here:
10
- on the way out, a body the platform already knows how to encode is passed through
11
- untouched; on the way back, you name the shape you expect.
12
-
13
- ## Uploading
14
-
15
- A body that is `FormData`, `Blob`, `File`, `URLSearchParams`, `ArrayBuffer`, or a
16
- string is sent as-is with no JSON encoding, which lets `fetch` set the correct
17
- `Content-Type` — including the multipart boundary, a value that cannot be written
18
- by hand:
19
-
20
- ```ts
21
- // in any frontend module
22
- const form = new FormData();
23
- form.append("avatar", file);
24
- await api.post("/api/avatars", form);
25
- ```
26
-
27
- | Body | Sent as |
28
- | ----------------- | ------------------------------------------- |
29
- | Plain object | JSON, with `Content-Type: application/json` |
30
- | `FormData` | Multipart, boundary set by the platform |
31
- | `File` / `Blob` | Raw bytes |
32
- | `URLSearchParams` | Form-encoded |
33
- | `ArrayBuffer` | Raw bytes |
34
- | String | Sent verbatim |
35
-
36
- Do not set `Content-Type` yourself for a `FormData` upload. A hand-written header
37
- has no boundary parameter, and the server then fails to parse a body that is
38
- otherwise perfectly formed — a confusing failure worth avoiding by simply leaving
39
- the header alone.
40
-
41
- To send a file alongside ordinary fields, put everything in the `FormData`;
42
- mixing a JSON body and a file in one request is not possible:
43
-
44
- ```ts
45
- const form = new FormData();
46
- form.append("title", "Quarterly report");
47
- form.append("document", file);
48
- await api.post("/api/reports", form);
49
- ```
50
-
51
- ## Downloading
52
-
53
- Responses are parsed as JSON unless you say otherwise. `responseType` names the
54
- shape you want:
55
-
56
- | `responseType` | Returns | Reach for it when |
57
- | --------------- | -------------------- | ------------------------------------ |
58
- | `"auto"` | Parsed JSON, or text | The default — JSON with a safety net |
59
- | `"json"` | Parsed JSON, or text | The body is known to be JSON |
60
- | `"text"` | `string` | CSV, XML, plain text |
61
- | `"blob"` | `Blob` | Saving or displaying a file |
62
- | `"arrayBuffer"` | `ArrayBuffer` | Reading bytes directly |
63
-
64
- ```ts
65
- // in any frontend module
66
- const pdf = await api.get("/api/report", undefined, { responseType: "blob" });
67
- ```
68
-
69
- `"auto"` and `"json"` fall back to the raw text when the body will not parse, so a
70
- misconfigured endpoint returning an HTML error page surfaces that page rather than
71
- a parse exception.
72
-
73
- A `204 No Content` response — or any response with `Content-Length: 0` — resolves
74
- to `undefined` rather than throwing, so a `DELETE` needs no special handling:
75
-
76
- ```ts
77
- await api.delete("/api/avatars/1"); // → undefined
78
- ```
79
-
80
- Handing a downloaded blob to the browser takes one more step, since the client
81
- returns the data rather than saving it:
82
-
83
- ```ts
84
- const blob = await api.get("/api/report", undefined, { responseType: "blob" });
85
-
86
- const url = URL.createObjectURL(blob);
87
- const a = Object.assign(document.createElement("a"), { href: url, download: "report.pdf" });
88
- a.click();
89
- URL.revokeObjectURL(url);
90
- ```
91
-
92
- ## Reading response metadata
93
-
94
- Binary endpoints often carry the interesting information in headers. A per-request
95
- `meta` callback reads them without installing a global interceptor:
96
-
97
- ```ts
98
- // in any frontend module
99
- let total: string | null = null;
100
- const users = await api.get("/api/users", undefined, {
101
- meta: (m) => (total = m.headers.get("X-Total")),
102
- });
103
- ```
104
-
105
- This is the way to reach pagination totals, rate-limit counters, and `ETag` values
106
- while still receiving the parsed body as the return value.
107
-
108
- ## Query serialization
109
-
110
- Query objects serialize arrays and nested objects with bracket notation:
111
- `{ ids: [1, 2], filter: { status: "open" } }` becomes
112
- `?ids[]=1&ids[]=2&filter[status]=open`.
113
-
114
- ## Next steps
115
-
116
- - [Client overview](/docs/client) — the guide's front page and the rest of the sections.
117
- - [Making requests](/docs/client/requests) — the full request surface.
118
- - [Error handling](/docs/client/errors) — what a failed transfer throws.
@@ -1,58 +0,0 @@
1
- ---
2
- title: Client References
3
- description: Every ApiClient method, config key, and error type in one table.
4
- ---
5
-
6
- # References
7
-
8
- ## `createApiClient<Routes>(config)`
9
-
10
- Returns an `ApiClient<Routes>` bound to your route map. The `config` is an
11
- `ApiClientConfig` (see the [Configuration](/docs/client#configuration) table for the common
12
- fields, plus `onError`, `onResponse`, `onRequest`, `onUnauthorized`, `onForbidden`,
13
- and `circuitBreaker`).
14
-
15
- ## ApiClient methods
16
-
17
- | Method | Signature | Description |
18
- | ---------- | -------------------------------------------------------------------- | ---------------------------------- |
19
- | `get` | `get(path, params?, options?: GetOptions): Promise<Response>` | Typed GET; path params then query. |
20
- | `post` | `post(path, body?, options?: MutationOptions): Promise<Response>` | Typed POST with body. |
21
- | `put` | `put(path, body?, options?: MutationOptions): Promise<Response>` | Typed PUT with body. |
22
- | `patch` | `patch(path, body?, options?: MutationOptions): Promise<Response>` | Typed PATCH with body. |
23
- | `delete` | `delete(path, params?, options?: RequestOptions): Promise<Response>` | Typed DELETE; path params only. |
24
- | `setToken` | `setToken(token: TokenSource \| null): void` | Update or clear the bearer token. |
25
-
26
- ## ApiClientError
27
-
28
- | Member | Signature | Description |
29
- | -------------- | ----------------------- | -------------------------------------- |
30
- | `status` | `number` | HTTP status code. |
31
- | `statusText` | `string` | HTTP status text. |
32
- | `body` | `string` | Raw response text. |
33
- | `headers` | `Headers \| undefined` | Response headers, when available. |
34
- | `retryAfterMs` | `get(): number \| null` | Parsed `Retry-After` in ms, or `null`. |
35
-
36
- ## ValidationError extends ApiClientError
37
-
38
- | Member | Signature | Description |
39
- | ------------------- | ------------------------------------------- | ---------------------------------- |
40
- | `errors` | `Record<string, string[]>` | Field → messages map. |
41
- | `validationMessage` | `string` | Top-level `message` from the body. |
42
- | `has` | `has(field: string): boolean` | Whether a field has any error. |
43
- | `first` | `first(field: string): string \| undefined` | First message for a field. |
44
- | `all` | `all(): Record<string, string[]>` | The full field-error map. |
45
- | `fields` | `fields(): string[]` | Names of every failed field. |
46
-
47
- ## CircuitBreaker
48
-
49
- | Member | Signature | Description |
50
- | ---------- | ------------------------------------------- | --------------------------------------- |
51
- | `call` | `call<T>(fn: () => Promise<T>): Promise<T>` | Run `fn` under the breaker. |
52
- | `state` | `get(): CircuitState` | `'closed'`, `'open'`, or `'half-open'`. |
53
- | `failures` | `get(): number` | Current consecutive failure count. |
54
- | `reset` | `reset(): void` | Manually return to the closed state. |
55
-
56
- ## Next steps
57
-
58
- - [Client overview](/docs/client) — the guide's front page and the rest of the sections.
@@ -1,131 +0,0 @@
1
- ---
2
- title: Client Requests
3
- description: The typed route map, making requests, and shaping them with interceptors.
4
- ---
5
-
6
- # Route map
7
-
8
- Define your API surface once. Keys are `'METHOD /path'` strings — path params are
9
- `{braces}` style. All fields are optional.
10
-
11
- ```ts
12
- // app/api/api-types.ts
13
- export interface Routes {
14
- "GET /api/users": {
15
- query: { page?: number; perPage?: number; search?: string };
16
- response: { data: UserResource[]; total: number };
17
- };
18
- "GET /api/users/{id}": {
19
- params: { id: number };
20
- response: UserResource;
21
- };
22
- "POST /api/users": {
23
- body: { name: string; email: string; password: string };
24
- response: UserResource;
25
- };
26
- "PUT /api/users/{id}": {
27
- params: { id: number };
28
- body: { name?: string; email?: string };
29
- response: UserResource;
30
- };
31
- "DELETE /api/users/{id}": {
32
- params: { id: number };
33
- response: void;
34
- };
35
- }
36
- ```
37
-
38
- ## Making requests
39
-
40
- ```ts
41
- // in any frontend module
42
- // GET with path params
43
- const user = await api.get("/api/users/{id}", { id: 42 });
44
- // ^? UserResource — inferred from the route map
45
-
46
- // GET with query string
47
- const list = await api.get("/api/users", undefined, {
48
- query: { page: 2, perPage: 25, search: "alice" },
49
- });
50
- // ^? { data: UserResource[]; total: number }
51
-
52
- // POST with body
53
- const created = await api.post("/api/users", {
54
- name: "Alice",
55
- email: "alice@example.com",
56
- password: "hunter2",
57
- });
58
- // ^? UserResource
59
-
60
- // PUT
61
- await api.put("/api/users/{id}", { name: "Alice Smith" }, { params: { id: 42 } });
62
-
63
- // PATCH
64
- await api.patch("/api/users/{id}", { email: "new@example.com" }, { params: { id: 42 } });
65
-
66
- // DELETE
67
- await api.delete("/api/users/{id}", { id: 42 });
68
- ```
69
-
70
- All methods accept an optional `options` argument for per-request headers and
71
- extra `fetch` init fields (`signal`, `credentials`, etc.):
72
-
73
- ```ts
74
- // in any frontend module
75
- const ctrl = new AbortController();
76
-
77
- await api.get("/api/users", undefined, {
78
- headers: { "X-Trace-Id": requestId },
79
- init: { signal: ctrl.signal },
80
- });
81
- ```
82
-
83
- ## Request interceptors
84
-
85
- Run one or more async functions before every outgoing request. Each interceptor
86
- receives the current `RequestConfig` and must return it (or a new one). Useful for
87
- attaching authorization headers from a reactive store without coupling the store
88
- to the client's constructor.
89
-
90
- ```ts
91
- // app/api/client.ts
92
- const api = createApiClient<Routes>({
93
- baseUrl: "https://api.example.com",
94
-
95
- // Single interceptor
96
- onRequest: async (config) => ({
97
- ...config,
98
- headers: {
99
- ...config.headers,
100
- Authorization: `Bearer ${await tokenStore.get()}`,
101
- },
102
- }),
103
- });
104
- ```
105
-
106
- Multiple interceptors execute in declaration order:
107
-
108
- ```ts
109
- // app/api/client.ts
110
- const api = createApiClient<Routes>({
111
- baseUrl: "https://api.example.com",
112
- onRequest: [addAuthHeader, addRequestId, logOutgoing],
113
- });
114
- ```
115
-
116
- A symmetric `onResponse` runs after every successful (2xx) response — receiving a
117
- `ResponseContext` of `{ status, headers, data, request }` — to unwrap envelopes or log.
118
-
119
- ### RequestConfig shape
120
-
121
- | Field | Type | Description |
122
- | --------- | ------------------------ | ------------------------------------------------------------ |
123
- | `method` | `string` | HTTP verb — `'GET'`, `'POST'`, … |
124
- | `url` | `string` | Full resolved URL (base + path + query string) |
125
- | `headers` | `Record<string, string>` | Merged headers — add/override here |
126
- | `body` | `BodyInit \| undefined` | Serialised body (JSON string, `FormData`, …), or `undefined` |
127
-
128
- ## Next steps
129
-
130
- - [Client overview](/docs/client) — the guide's front page and the rest of the sections.
131
- - [Reference](/docs/client/references) — the full API surface in one table.