@zerotal/arch 1.7.3 → 1.7.5

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.
Files changed (127) hide show
  1. package/CHANGELOG.md +34 -2
  2. package/README.md +4 -2
  3. package/api-surface.md +4 -4
  4. package/docs/about.md +16 -16
  5. package/docs/admin/actions.md +9 -9
  6. package/docs/admin/auth.md +2 -2
  7. package/docs/admin/dashboard.md +5 -5
  8. package/docs/admin/extending-ui.md +12 -12
  9. package/docs/admin/extending.md +16 -7
  10. package/docs/admin/forms.md +4 -4
  11. package/docs/admin/index.md +2 -2
  12. package/docs/admin/operations.md +9 -9
  13. package/docs/admin/resources.md +3 -3
  14. package/docs/admin/structure.md +6 -6
  15. package/docs/admin/tables.md +8 -8
  16. package/docs/admin/testing.md +4 -4
  17. package/docs/ai.md +118 -11
  18. package/docs/application.md +12 -12
  19. package/docs/arch.md +25 -3
  20. package/docs/assets.md +11 -11
  21. package/docs/audit.md +13 -13
  22. package/docs/authentication.md +39 -39
  23. package/docs/authorization.md +13 -13
  24. package/docs/broadcasting/channels.md +2 -2
  25. package/docs/broadcasting/client.md +2 -2
  26. package/docs/broadcasting/events.md +9 -10
  27. package/docs/broadcasting/index.md +1 -1
  28. package/docs/broadcasting/testing.md +5 -5
  29. package/docs/cache.md +15 -13
  30. package/docs/carbon.md +35 -35
  31. package/docs/changelog.md +165 -1
  32. package/docs/client/index.md +277 -70
  33. package/docs/commands.md +4 -4
  34. package/docs/components.md +116 -115
  35. package/docs/config-system.md +4 -4
  36. package/docs/container.md +27 -27
  37. package/docs/context.md +27 -27
  38. package/docs/contributing.md +46 -0
  39. package/docs/controllers.md +10 -10
  40. package/docs/conventions.md +23 -15
  41. package/docs/cookies.md +3 -3
  42. package/docs/csrf.md +6 -6
  43. package/docs/database.md +22 -17
  44. package/docs/deployment.md +2 -2
  45. package/docs/devtools.md +16 -16
  46. package/docs/email-verification.md +8 -8
  47. package/docs/encryption.md +7 -7
  48. package/docs/errors.md +6 -6
  49. package/docs/events.md +12 -11
  50. package/docs/flow/components.md +32 -32
  51. package/docs/flow/decorators.md +18 -18
  52. package/docs/flow/events.md +16 -16
  53. package/docs/flow/forms.md +17 -17
  54. package/docs/flow/icons.md +199 -0
  55. package/docs/flow/index.md +20 -20
  56. package/docs/flow/layouts.md +35 -35
  57. package/docs/flow/lifecycle.md +16 -16
  58. package/docs/flow/models.md +9 -9
  59. package/docs/flow/pagination.md +8 -8
  60. package/docs/flow/performance.md +7 -7
  61. package/docs/flow/references.md +3 -3
  62. package/docs/flow/routing.md +26 -26
  63. package/docs/flow/testing.md +20 -20
  64. package/docs/getting-started.md +18 -13
  65. package/docs/health.md +17 -8
  66. package/docs/helpers.md +17 -17
  67. package/docs/i18n.md +10 -10
  68. package/docs/inertia/devtools.md +4 -4
  69. package/docs/inertia/index.md +12 -7
  70. package/docs/inertia/props.md +22 -22
  71. package/docs/inertia/rendering.md +86 -7
  72. package/docs/inertia/ssr.md +2 -2
  73. package/docs/lifecycle.md +2 -2
  74. package/docs/lock.md +11 -11
  75. package/docs/logger.md +12 -12
  76. package/docs/media.md +22 -22
  77. package/docs/middleware.md +17 -17
  78. package/docs/migrations.md +13 -13
  79. package/docs/monitor.md +6 -6
  80. package/docs/notifications.md +24 -24
  81. package/docs/orm/casts.md +14 -14
  82. package/docs/orm/factories.md +8 -8
  83. package/docs/orm/index.md +22 -20
  84. package/docs/orm/lifecycle.md +13 -13
  85. package/docs/orm/queries.md +28 -28
  86. package/docs/orm/relationships.md +16 -16
  87. package/docs/orm/serialization.md +11 -11
  88. package/docs/package-development.md +10 -10
  89. package/docs/pagination.md +14 -14
  90. package/docs/password-reset.md +6 -6
  91. package/docs/providers.md +11 -11
  92. package/docs/query-builder.md +18 -18
  93. package/docs/queue.md +14 -12
  94. package/docs/rate-limiting.md +6 -6
  95. package/docs/responses.md +14 -14
  96. package/docs/roles-and-2fa.md +9 -9
  97. package/docs/routing.md +44 -44
  98. package/docs/scaffolding.md +1 -1
  99. package/docs/scheduler.md +10 -10
  100. package/docs/seeding.md +6 -6
  101. package/docs/session.md +16 -14
  102. package/docs/social.md +14 -14
  103. package/docs/storage.md +25 -13
  104. package/docs/structure.md +2 -2
  105. package/docs/support-policy.md +36 -8
  106. package/docs/telemetry.md +12 -12
  107. package/docs/tenancy.md +34 -40
  108. package/docs/testing/browser.md +6 -6
  109. package/docs/testing/console.md +3 -3
  110. package/docs/testing/database.md +10 -10
  111. package/docs/testing/flow-browser.md +6 -6
  112. package/docs/testing/http.md +12 -12
  113. package/docs/testing/index.md +3 -3
  114. package/docs/testing/mocking.md +8 -8
  115. package/docs/upgrade.md +1 -1
  116. package/docs/validator.md +19 -19
  117. package/docs/view.md +14 -14
  118. package/package.json +4 -4
  119. package/src/index.ts +25 -1
  120. package/src/tools/searchDocs.ts +7 -0
  121. package/docs/client/auth.md +0 -113
  122. package/docs/client/errors.md +0 -139
  123. package/docs/client/files.md +0 -118
  124. package/docs/client/references.md +0 -58
  125. package/docs/client/requests.md +0 -131
  126. package/docs/client/resilience.md +0 -141
  127. package/docs/client/testing.md +0 -146
package/docs/validator.md CHANGED
@@ -89,7 +89,7 @@ export class StorePostRequest extends FormRequest {
89
89
  Call `validate()` as a static method on the class. It reads `HttpContext` from
90
90
  `AsyncLocalStorage` — no arguments needed.
91
91
 
92
- ```typescript
92
+ ```typescript fragment
93
93
  // in a controller
94
94
  const data = await StorePostRequest.validate();
95
95
 
@@ -116,7 +116,7 @@ never need to catch them yourself.
116
116
  `authorize()` runs **before** validation. Returning `false` throws a
117
117
  `ForbiddenError` (403) without touching the request body.
118
118
 
119
- ```typescript
119
+ ```typescript fragment
120
120
  // in a FormRequest subclass
121
121
 
122
122
  // Check the current user's role:
@@ -141,7 +141,7 @@ async authorize(): Promise<boolean> {
141
141
  Because `rules()` is an instance method, you have full access to `this.context`
142
142
  for rules that depend on the current user, route params, or session state:
143
143
 
144
- ```typescript
144
+ ```typescript fragment
145
145
  // in a FormRequest subclass
146
146
  rules(r: RuleBuilder) {
147
147
  const userId = this.context.user?.id;
@@ -157,7 +157,7 @@ rules(r: RuleBuilder) {
157
157
 
158
158
  For simple one-off validation without a dedicated class:
159
159
 
160
- ```typescript
160
+ ```typescript fragment
161
161
  // in a controller
162
162
  import { validate } from "@zerotal/validator";
163
163
 
@@ -180,7 +180,7 @@ For CLI commands, services, or background jobs — where there is no request to
180
180
  redirect and no response to throw — use the `Validator` facade. It returns a
181
181
  `{ success, data, errors }` outcome and never throws.
182
182
 
183
- ```typescript
183
+ ```typescript fragment
184
184
  // in a command or service
185
185
  import { Validator } from "@zerotal/validator";
186
186
 
@@ -200,7 +200,7 @@ if (!result.success) {
200
200
 
201
201
  Start every rule chain with a type method on `RuleBuilder`:
202
202
 
203
- ```typescript
203
+ ```typescript fragment
204
204
  // in a rules() method or factory
205
205
  const r = new RuleBuilder(); // or the `r` param in rules()
206
206
 
@@ -247,7 +247,7 @@ These are available on **every** rule type:
247
247
 
248
248
  ### Custom validator
249
249
 
250
- ```typescript
250
+ ```typescript fragment
251
251
  // in a rules() method or factory
252
252
  username: r.string().custom(async (value) => {
253
253
  const taken = await User.findBy("username", value as string);
@@ -257,7 +257,7 @@ username: r.string().custom(async (value) => {
257
257
 
258
258
  ## String rules
259
259
 
260
- ```typescript
260
+ ```typescript fragment
261
261
  // in a rules() method or factory
262
262
  r.string()
263
263
  .min(3) // minimum character length
@@ -292,7 +292,7 @@ r.string()
292
292
 
293
293
  ## Number rules
294
294
 
295
- ```typescript
295
+ ```typescript fragment
296
296
  // in a rules() method or factory
297
297
  r.number()
298
298
  .min(0) // minimum value (inclusive)
@@ -305,7 +305,7 @@ r.number()
305
305
 
306
306
  ## Boolean rules
307
307
 
308
- ```typescript
308
+ ```typescript fragment
309
309
  // in a rules() method or factory
310
310
  r.boolean()
311
311
  .accepted() // truthy — for "agree to terms" checkboxes
@@ -314,7 +314,7 @@ r.boolean()
314
314
 
315
315
  ## Date rules
316
316
 
317
- ```typescript
317
+ ```typescript fragment
318
318
  // in a rules() method or factory
319
319
  r.date()
320
320
  .after("2026-01-01") // strictly after date
@@ -325,7 +325,7 @@ r.date()
325
325
 
326
326
  ## Array rules
327
327
 
328
- ```typescript
328
+ ```typescript fragment
329
329
  // in a rules() method or factory
330
330
  r.array(r.string()) // array of strings
331
331
  .min(1) // minimum item count
@@ -340,7 +340,7 @@ r.array(r.object({ name: r.string(), age: r.number() }));
340
340
 
341
341
  ## Object rules
342
342
 
343
- ```typescript
343
+ ```typescript fragment
344
344
  // in a rules() method or factory
345
345
  r.object({
346
346
  street: r.string(),
@@ -357,7 +357,7 @@ r.object({
357
357
 
358
358
  Validates `File` objects from `multipart/form-data` uploads:
359
359
 
360
- ```typescript
360
+ ```typescript fragment
361
361
  // in a rules() method or factory
362
362
  avatar: r.file()
363
363
  .mimes(["jpg", "jpeg", "png", "webp"]) // allowed extensions
@@ -368,7 +368,7 @@ avatar: r.file()
368
368
 
369
369
  ## Password rules
370
370
 
371
- ```typescript
371
+ ```typescript fragment
372
372
  // in a rules() method or factory
373
373
  r.password()
374
374
  .min(8) // minimum length (default: 8)
@@ -390,7 +390,7 @@ The third argument ignores a record on update — pass the current record's ID,
390
390
  a `UniqueOptions` object (`{ ignoreId }`) for clarity. A fourth argument
391
391
  overrides the error message.
392
392
 
393
- ```typescript
393
+ ```typescript fragment
394
394
  // in a rules() method or factory
395
395
  email: r.string().email().unique("users", "email");
396
396
 
@@ -403,7 +403,7 @@ email: r.string().email().unique("users", "email", { ignoreId: userId });
403
403
 
404
404
  ### exists — value must exist in the DB
405
405
 
406
- ```typescript
406
+ ```typescript fragment
407
407
  // in a rules() method or factory
408
408
  userId: r.number().exists("users", "id");
409
409
  roleSlug: r.string().exists("roles", "slug");
@@ -450,7 +450,7 @@ back to the previous page.
450
450
  Errors and old input are stored in the [session](/docs/session) under the keys
451
451
  `'errors'` and `'old'`. Read them on the next request:
452
452
 
453
- ```typescript
453
+ ```typescript fragment
454
454
  // in a controller
455
455
  const errors = ctx.flashed<Record<string, string[]>>("errors");
456
456
  const old = ctx.flashed<Record<string, unknown>>("old");
@@ -500,7 +500,7 @@ would not.
500
500
  **A `FormRequest` fails the HTTP request**, so test it through the route it
501
501
  guards. The status tells you which failure mode you got:
502
502
 
503
- ```typescript
503
+ ```typescript fragment
504
504
  // tests/http/register.test.ts
505
505
  const res = await app.post("/register", { email: "not-an-email" });
506
506
 
package/docs/view.md CHANGED
@@ -67,7 +67,7 @@ use the [`safe()` helper or `Raw` component](#embedding-raw-html).
67
67
 
68
68
  The runtime renders attributes from props with a few rules:
69
69
 
70
- ```tsx
70
+ ```tsx fragment
71
71
  // in a component
72
72
  <input type="text" value={name} disabled={isLocked} required={false} data-id={42} />
73
73
  // → <input type="text" value="…" disabled data-id="42">
@@ -97,7 +97,7 @@ Children are rendered by type, which is the engine's security boundary:
97
97
 
98
98
  Two consequences worth remembering:
99
99
 
100
- ```tsx
100
+ ```tsx fragment
101
101
  // in a component
102
102
  {
103
103
  user && <Welcome name={user.name} />;
@@ -123,7 +123,7 @@ Call `view()` from a controller action to set the response. It accepts any value
123
123
  whose `.toString()` returns HTML — a `SafeHtml` instance from JSX is the normal
124
124
  case:
125
125
 
126
- ```tsx
126
+ ```tsx fragment
127
127
  // app/controllers/PostController.tsx
128
128
  import { view } from "zerotal";
129
129
  import type { HttpContext } from "zerotal";
@@ -159,7 +159,7 @@ export default function Welcome(ctx: HttpContext, { title }: { title: string })
159
159
  }
160
160
  ```
161
161
 
162
- ```ts
162
+ ```ts fragment
163
163
  // routes/index.ts — the HttpContext is injected; you only pass the extra props
164
164
  import { Router } from "zerotal";
165
165
  import Welcome from "../resources/views/Welcome.tsx";
@@ -213,7 +213,7 @@ export function AppLayout({ title, children }: AppLayoutProps) {
213
213
  }
214
214
  ```
215
215
 
216
- ```tsx
216
+ ```tsx fragment
217
217
  // resources/views/About.tsx
218
218
  import { defineLayout } from "zerotal/view";
219
219
  import { AppLayout } from "./layouts/AppLayout.tsx";
@@ -229,7 +229,7 @@ export const AboutPage = wrap<{ heading: string }>(({ heading }) => (
229
229
  ));
230
230
  ```
231
231
 
232
- ```ts
232
+ ```ts fragment
233
233
  // routes/index.ts
234
234
  import { Router } from "zerotal";
235
235
  import { AboutPage } from "../resources/views/About.tsx";
@@ -246,7 +246,7 @@ directly with `Router.view()`. The third argument is the props — a **static
246
246
  object**, or a **per-request factory** that receives the `HttpContext` and may be
247
247
  async:
248
248
 
249
- ```ts
249
+ ```ts fragment
250
250
  // routes/index.ts
251
251
  // Static props — evaluated once at registration (marketing / info pages):
252
252
  Router.view("/about", AboutPage, { title: "About Us" });
@@ -263,7 +263,7 @@ Router.view("/privacy", PrivacyPage);
263
263
 
264
264
  The returned registration is chainable:
265
265
 
266
- ```ts
266
+ ```ts fragment
267
267
  // routes/index.ts
268
268
  Router.view("/terms", TermsPage)
269
269
  .name("terms") // name the route for url() generation
@@ -276,7 +276,7 @@ Under [file-based routing](/docs/routing), a page file's **default export**
276
276
  becomes a `GET` route automatically. The page is a function of `(http, params)`
277
277
  and may be async:
278
278
 
279
- ```tsx
279
+ ```tsx fragment
280
280
  // app/views/posts/[slug].tsx → GET /posts/:slug
281
281
  import type { HttpContext } from "zerotal";
282
282
  import { Post } from "../../models/Post.ts";
@@ -311,7 +311,7 @@ export default definePage((http, params) => `<h1>Hello ${params.name}</h1>`);
311
311
  When you have pre-rendered markup (e.g. from a Markdown renderer) use `safe()` or
312
312
  `<Raw>` to bypass escaping:
313
313
 
314
- ```tsx
314
+ ```tsx fragment
315
315
  // in a component
316
316
  import { safe, Raw } from "zerotal/view";
317
317
 
@@ -335,7 +335,7 @@ import { safe, Raw } from "zerotal/view";
335
335
  `esc()` escapes a value for use in raw string templates, where automatic JSX
336
336
  escaping isn't available:
337
337
 
338
- ```ts
338
+ ```ts fragment
339
339
  // in a helper
340
340
  import { esc } from "zerotal/view";
341
341
 
@@ -367,7 +367,7 @@ function returning a node that stringifies, so most of it tests without a server
367
367
  **Render the component and assert on the string.** No request, no router, no
368
368
  browser:
369
369
 
370
- ```typescript
370
+ ```typescript fragment
371
371
  // tests/views/PostCard.test.ts
372
372
  import { test, expect } from "bun:test";
373
373
  import { PostCard } from "../../resources/views/PostCard.tsx";
@@ -383,7 +383,7 @@ test("renders the title and author", () => {
383
383
  **Escaping is the test that matters.** Every view that renders user input has one
384
384
  job beyond looking right, and it is the job that becomes a security incident:
385
385
 
386
- ```typescript
386
+ ```typescript fragment
387
387
  // tests/views/PostCard.test.ts
388
388
  test("escapes markup in user-supplied text", () => {
389
389
  const html = String(PostCard({ post: { title: "<script>alert(1)</script>" } }));
@@ -400,7 +400,7 @@ fails if someone later routes user input into it.
400
400
  **Through a route, assert the rendered text** rather than the markup around it.
401
401
  `assertSee` survives a class rename; a full-HTML comparison does not:
402
402
 
403
- ```typescript
403
+ ```typescript fragment
404
404
  // tests/http/posts.test.ts
405
405
  const res = await app.get("/posts");
406
406
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zerotal/arch",
3
- "version": "1.7.3",
3
+ "version": "1.7.5",
4
4
  "license": "MIT",
5
- "maturity": "beta",
5
+ "maturity": "stable",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
@@ -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.5"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.7.3"
42
+ "@zerotal/orm": "1.7.5"
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/src/index.ts CHANGED
@@ -27,7 +27,15 @@ export * from "./errors.ts";
27
27
  // serve a subset, or add its own alongside them.
28
28
  export { archTools, vendoredDocsDir } from "./tools/index.ts";
29
29
  export type { ToolContext } from "./tools/index.ts";
30
+ // Subprocess plumbing, not API. `_probe.ts` carries the underscore this repo uses
31
+ // for a module nobody outside its package should reach for, nothing in the tree
32
+ // imports these from here, and the export block above says why the tools and the
33
+ // probe topics are public without ever saying why these are. Marked before the
34
+ // stable promise attaches, because withdrawing them afterwards is a breaking
35
+ // change made on behalf of a caller that does not exist.
36
+ /** @internal */
30
37
  export { findApp, spawnProbe } from "./tools/_probe.ts";
38
+ /** @internal */
31
39
  export type { ProbeResult, ProbeRunner, SpawnProbeOptions } from "./tools/_probe.ts";
32
40
 
33
41
  // The in-app reads, for anyone wanting them without the subprocess.
@@ -45,12 +53,28 @@ export type {
45
53
  SchemaReport,
46
54
  } from "./probe/topics.ts";
47
55
 
48
- // The install writers, so a project can generate the same files its own way.
56
+ // The writers behind `arch:install`. Exported for the command and its tests to
57
+ // reach, not as API.
58
+ //
59
+ // They were public on the reasoning that "a project can generate the same files
60
+ // its own way" — a use nobody has, and a promise about the shape of `.mcp.json`
61
+ // writing, agent detection and marker fencing that would hold for the rest of
62
+ // the 1.x line. `ArchInstallCommand` is the only caller in the tree. Under `beta`
63
+ // that was a cheap bet; the stable label is what makes it expensive, so they are
64
+ // marked now rather than withdrawn later on behalf of a caller who never arrived.
65
+ /** @internal */
49
66
  export { detectAgents } from "./install/detect.ts";
67
+ /** @internal */
50
68
  export type { Detected, McpTarget } from "./install/detect.ts";
69
+ /** @internal */
51
70
  export { applyMcpConfig, serverEntry, SERVER_ENTRY_PATH } from "./install/mcpConfig.ts";
71
+ /** @internal */
52
72
  export type { ConfigOutcome } from "./install/mcpConfig.ts";
73
+ /** @internal */
53
74
  export { applyBlock, fence, BLOCK_END, BLOCK_START } from "./install/markers.ts";
75
+ /** @internal */
54
76
  export type { BlockOutcome } from "./install/markers.ts";
77
+ /** @internal */
55
78
  export { agentsPreamble, buildGuidelines, claudeShim } from "./install/guidelines.ts";
79
+ /** @internal */
56
80
  export type { GuidelineOptions } from "./install/guidelines.ts";
@@ -181,6 +181,13 @@ export function parsePage(path: string, raw: string): DocPage {
181
181
  };
182
182
 
183
183
  for (const line of body.split("\n")) {
184
+ // A fence delimiter is markup, and its info string is metadata about the
185
+ // block rather than anything the page says. Indexing them put the language
186
+ // name into every page that shows code — a search for "typescript" matched
187
+ // most of the corpus on the strength of its fences — and, once documentation
188
+ // examples grew a `fragment` marker, that word too.
189
+ if (/^\s*`{3,}/.test(line)) continue;
190
+
184
191
  const match = /^(#{1,3})\s+(.*)$/.exec(line);
185
192
  if (match) {
186
193
  flush();
@@ -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.