@alxia/react-router 0.10.0 → 0.11.0

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 (54) hide show
  1. package/README.md +176 -1
  2. package/dist/errors/api-error.d.ts +86 -0
  3. package/dist/errors/api-error.d.ts.map +1 -0
  4. package/dist/errors/api-errors.d.ts +96 -0
  5. package/dist/errors/api-errors.d.ts.map +1 -0
  6. package/dist/errors/call-failure.d.ts +45 -0
  7. package/dist/errors/call-failure.d.ts.map +1 -0
  8. package/dist/errors/catalogue-check.d.ts +24 -0
  9. package/dist/errors/catalogue-check.d.ts.map +1 -0
  10. package/dist/errors/catalogue-read.d.ts +33 -0
  11. package/dist/errors/catalogue-read.d.ts.map +1 -0
  12. package/dist/errors/catalogue.d.ts +59 -0
  13. package/dist/errors/catalogue.d.ts.map +1 -0
  14. package/dist/errors/code-resolver.d.ts +44 -0
  15. package/dist/errors/code-resolver.d.ts.map +1 -0
  16. package/dist/errors/describe-refusal.d.ts +6 -0
  17. package/dist/errors/describe-refusal.d.ts.map +1 -0
  18. package/dist/errors/describe.d.ts +3 -0
  19. package/dist/errors/describe.d.ts.map +1 -0
  20. package/dist/errors/fetcher-result.d.ts +21 -0
  21. package/dist/errors/fetcher-result.d.ts.map +1 -0
  22. package/dist/errors/form-errors.d.ts +8 -0
  23. package/dist/errors/form-errors.d.ts.map +1 -0
  24. package/dist/errors/index.d.ts +15 -0
  25. package/dist/errors/index.d.ts.map +1 -0
  26. package/dist/errors/index.js +602 -0
  27. package/dist/errors/index.js.map +29 -0
  28. package/dist/errors/json-actions.d.ts +38 -0
  29. package/dist/errors/json-actions.d.ts.map +1 -0
  30. package/dist/errors/log.d.ts +8 -0
  31. package/dist/errors/log.d.ts.map +1 -0
  32. package/dist/errors/message-values.d.ts +11 -0
  33. package/dist/errors/message-values.d.ts.map +1 -0
  34. package/dist/errors/messages.d.ts +63 -0
  35. package/dist/errors/messages.d.ts.map +1 -0
  36. package/dist/errors/page-path.d.ts +9 -0
  37. package/dist/errors/page-path.d.ts.map +1 -0
  38. package/dist/errors/provider.d.ts +20 -0
  39. package/dist/errors/provider.d.ts.map +1 -0
  40. package/dist/errors/request-id.d.ts +5 -0
  41. package/dist/errors/request-id.d.ts.map +1 -0
  42. package/dist/errors/route-error.d.ts +31 -0
  43. package/dist/errors/route-error.d.ts.map +1 -0
  44. package/dist/errors/standard-schema.d.ts +34 -0
  45. package/dist/errors/standard-schema.d.ts.map +1 -0
  46. package/dist/errors/submit-form.d.ts +16 -0
  47. package/dist/errors/submit-form.d.ts.map +1 -0
  48. package/dist/errors/use-json-fetcher.d.ts +18 -0
  49. package/dist/errors/use-json-fetcher.d.ts.map +1 -0
  50. package/docs/README.md +2 -1
  51. package/docs/guide/errors.md +838 -0
  52. package/docs/roadmap.md +19 -0
  53. package/docs/troubleshooting.md +481 -0
  54. package/package.json +13 -3
@@ -0,0 +1,838 @@
1
+ # An API's refusals, worded for the page
2
+
3
+ This page covers how an app that calls a GraphQL API words that API's refusals
4
+ for its pages: the error's shape, the request id it carries, the page a
5
+ request is for, the catalogue store of the API's messages, the form checks in
6
+ the API's words, the provider that fills the catalogue, the wording of a
7
+ refusal, the loaders, the error page and the actions that answer with it, the JSON those actions read and the fetcher hook that posts it, calling the API from a loader, and keeping the API's catalogue fresh.
8
+ The code is in `@alxia/react-router/errors`, which has no UI. Its one runtime
9
+ import is `react-router`, never `@alxia/core`, so a page can import it
10
+ (`boundary.spec.ts` checks the built files).
11
+
12
+ - [What the subpath holds](#what-the-subpath-holds)
13
+ - [Recognising an API error](#recognising-an-api-error)
14
+ - [The request id](#the-request-id)
15
+ - [The page a request is for](#the-page-a-request-is-for)
16
+ - [The catalogue store](#the-catalogue-store)
17
+ - [Form checks in the API's words](#form-checks-in-the-apis-words)
18
+ - [Wording a refusal](#wording-a-refusal)
19
+ - [Loaders and the error page](#loaders-and-the-error-page)
20
+ - [Actions](#actions)
21
+ - [JSON actions and CSRF](#json-actions-and-csrf)
22
+ - [Calling the API from a loader](#calling-the-api-from-a-loader)
23
+ - [Keeping the catalogue fresh](#keeping-the-catalogue-fresh)
24
+
25
+ ## What the subpath holds
26
+
27
+ | export | what it is |
28
+ | --- | --- |
29
+ | `ApiErrorLike`, `isApiErrorLike(error)` | the shape of `@nxgt/graphql-client`'s `ApiError`, and the test for it |
30
+ | `ApiErrorEntryLike`, `ApiErrorExtensionsLike`, `ApiErrorParamsLike`, `ApiFieldErrorLike` | one error of the response, its `extensions`, their `params`, and one refused field |
31
+ | `waitMinutes(extensions)` | the minutes of a throttle's wait: `minutes` (at least one), else `retryAfter` rounded up to minutes |
32
+ | `REQUEST_ID_HEADER`, `requestIdOf(response)` | the header an API echoes on every response, and its value |
33
+ | `nextOf(url)` | the page a request is for, as `next` carries it back after sign-in |
34
+ | `FormErrors` | what a form action answers when the API refused the input |
35
+ | `routeError(error, options?)`, `errorRequestId(error)`, `RouteErrorData`, `RouteErrorOptions` | what a loader throws for the API's error, and the support reference its error page reads |
36
+ | `createApiMessages`, `ApiMessages`, `ApiMessagesOptions`, `CatalogueMessage(s)`, `ParamKind`, `MessageFormat`, `MessageValues` | the catalogue of the API's messages, held by the app |
37
+ | `apiCodeResolver`, `ApiCodeResolver`, `ApiCodeResolverOptions`, `CodeIssueLike` | the words of a form's checks, from that catalogue; `CodeIssueLike` is the issue a custom `codeOf` receives |
38
+ | `ApiMessagesProvider`, `ApiMessagesProviderProps` | fills the catalogue in the browser from the root's data |
39
+ | `createApiErrors`, `ApiErrors`, `ApiErrorsOptions` | what a page says of a refusal, from that catalogue: the message, the fields, the status |
40
+ | `ApiRefusal` | a refused call as a page says it: `{ message, status }` |
41
+ | `jsonActions({ origin? })`, `JsonActions`, `JsonActionsOptions` | `readJson`, `requireJsonPost` and `readInput` for the actions: JSON only, from this site only |
42
+ | `useJsonFetcher(key?, action?)`, `JsonBody` | a fetcher that posts JSON: `{ fetcher, post(body, to?), busy }` |
43
+ | `apiCallFailure(error, options)`, `CallFailureOptions`, `operationName(document)`, `DocumentLike` | what a failed call to the API becomes for a loader to throw, logged with the request id, and the operation's name for the log |
44
+ | `createErrorCatalogue`, `ErrorCatalogue`, `ErrorCatalogueOptions`, `CatalogueReply` | reads the API's catalogue into the store and keeps it fresh: `fresh`, `freshAll`, `warm`, `clientMessages`; `reset`, `settled`, `heldVersion` and the `now` parameters are for tests |
45
+ | `StandardSchemaV1` | the Standard Schema v1 type `readInput` takes, declared here (type only; `readInput`'s output is inferred from the schema's `~standard.types`) |
46
+
47
+ ## Recognising an API error
48
+
49
+ The subpath does not depend on `@nxgt/graphql-client`. `isApiErrorLike` tests
50
+ an `Error` named `ApiError` that has an `errors` array, like the client's
51
+ `isApiError`, plus a numeric `status`, which `ApiErrorLike` promises. It holds
52
+ when two copies of the client are installed.
53
+
54
+ ```ts
55
+ import { isApiErrorLike } from '@alxia/react-router/errors';
56
+
57
+ export function codeOf(error: unknown) {
58
+ return isApiErrorLike(error) ? error.code : undefined;
59
+ }
60
+ ```
61
+
62
+ An `ApiError` is assignable to `ApiErrorLike` as it is. The shape's code is
63
+ the API's own `<module>.errors.<slug>`, such as `todos.errors.title-taken`.
64
+
65
+ `waitMinutes` reads a throttle's wait the way the API words it: the
66
+ `minutes` of `extensions.params` or beside it, at least one, else the
67
+ `retryAfter` seconds rounded up.
68
+
69
+ ```ts
70
+ import { waitMinutes } from '@alxia/react-router/errors';
71
+
72
+ waitMinutes({ retryAfter: 90 }); // 2
73
+ waitMinutes({ params: { minutes: 4 } }); // 4
74
+ ```
75
+
76
+ ## The request id
77
+
78
+ An API that echoes `x-request-id` lets a page tell the user which request
79
+ failed, and the API's log find it.
80
+
81
+ ```ts
82
+ import { requestIdOf } from '@alxia/react-router/errors';
83
+
84
+ export function idOf(response: Response) {
85
+ return requestIdOf(response); // undefined when the API sent none
86
+ }
87
+ ```
88
+
89
+ ## The page a request is for
90
+
91
+ On a client navigation, `request.url` is `/todos/list.data?q=a&_routes=…`
92
+ (`/_.data` for `/`), React Router's single-fetch details. `nextOf` returns the
93
+ page itself, for the `next` a sign-in carries back. It never starts with two
94
+ slashes (`//evil.com` and `/\evil.com` both read as another site to a
95
+ redirect), but it is not a validation: an app must still check `next` before
96
+ redirecting to it after sign-in.
97
+
98
+
99
+ ```ts
100
+ import { nextOf } from '@alxia/react-router/errors';
101
+
102
+ nextOf('https://app.test/todos/list.data?q=a&_routes=root'); // '/todos/list?q=a'
103
+ ```
104
+
105
+ ## The catalogue store
106
+
107
+ An API that words its refusals by code (`todos.errors.title-taken`) can list
108
+ its messages: the uninterpolated ICU text, and the kind of each argument.
109
+ `createApiMessages` holds them for the languages the app speaks. It is an
110
+ instance, not module state, so a spec makes its own and two copies of the
111
+ package never split it. Make one in a module of its own:
112
+
113
+ ```ts
114
+ // app/api-messages.ts
115
+ import { formatMessage } from '@nxgt/material/i18n';
116
+ import { createApiMessages } from '@alxia/react-router/errors';
117
+
118
+ export const apiMessages = createApiMessages<'en' | 'fr'>({
119
+ format: formatMessage,
120
+ });
121
+ ```
122
+
123
+ `format` is the app's ICU formatter, the engine of its own translator, so the
124
+ API's messages and the app's read alike. It is called as
125
+ `format(message, language, values, { onError })` and reports a message it
126
+ could not format to `onError`; material's `formatMessage` fits as it is.
127
+
128
+ ```ts
129
+ apiMessages.set('en', {
130
+ 'todos.errors.items-outside-list': {
131
+ message: '{count, plural, one {One item is} other {# items are}} outside {name}.',
132
+ params: [
133
+ { name: 'count', kind: 'PLURAL' },
134
+ { name: 'name', kind: 'TEXT' },
135
+ ],
136
+ },
137
+ });
138
+
139
+ apiMessages.apiMessage('todos.errors.items-outside-list', { count: '3', name: 'Home' }, 'en');
140
+ // '3 items are outside Home.'
141
+ ```
142
+
143
+ The `kind` of an argument says what to supply without parsing the message: a
144
+ `DATE` given as `YYYY-MM-DD` becomes a local `Date` (by calendar parts, so
145
+ the day is the same in every time zone), and a `NUMBER` or `PLURAL` given as a
146
+ numeric string a number. `apiMessage` returns `undefined` for a code the store
147
+ lacks or a message that cannot be formatted with those values (a missing
148
+ argument, a day it cannot read), so the caller falls back to the message the
149
+ API already worded.
150
+
151
+ `set(language, messages)` replaces a language, which is what a server does
152
+ with the whole catalogue. `add` merges, which is what the browser does with
153
+ the few messages its forms word. `clear` forgets everything, for specs.
154
+
155
+ In the browser, `ApiMessagesProvider` adds the messages the root's data
156
+ carries (`value` is the root loader's data, `messages` the store to fill) before the children render, since the resolver reads them
157
+ synchronously. It is a plain function with no JSX, and it imports React for its
158
+ types only:
159
+
160
+ ```tsx
161
+ // app/root.tsx
162
+ import { ApiMessagesProvider } from '@alxia/react-router/errors';
163
+ import { apiMessages } from './api-messages';
164
+
165
+ export default function App({ loaderData }: Route.ComponentProps) {
166
+ return (
167
+ <ApiMessagesProvider messages={apiMessages} value={loaderData.apiMessages}>
168
+ <Outlet />
169
+ </ApiMessagesProvider>
170
+ );
171
+ }
172
+ ```
173
+
174
+ It writes in the browser only. A server render leaves the store as it is,
175
+ because the server holds the whole, fresher catalogue and a render's data may
176
+ be older than it.
177
+
178
+ ## Form checks in the API's words
179
+
180
+ A form validates before it posts, and a check that mirrors the API's rule
181
+ should read as the API words it. A refinement carries the API's code in
182
+ `params.code`, and `apiCodeResolver` turns that into the catalogue's text:
183
+
184
+ ```ts
185
+ // app/code-resolver.ts
186
+ import { apiCodeResolver } from '@alxia/react-router/errors';
187
+ import { apiMessages } from './api-messages';
188
+ import { getLanguage, translate } from './i18n';
189
+
190
+ export const CODE_RESOLVER = apiCodeResolver(apiMessages, {
191
+ translate,
192
+ language: getLanguage,
193
+ });
194
+ ```
195
+
196
+ ```tsx
197
+ import { useCodeResolver } from '@nxgt/material/components';
198
+
199
+ const schema = z.object({ title: z.string() }).refine(isFree, {
200
+ path: ['title'],
201
+ params: { code: 'todos.errors.title-taken' },
202
+ });
203
+
204
+ const resolver = useCodeResolver(schema, CODE_RESOLVER);
205
+ ```
206
+
207
+ The result is `{ translate, codeOf }`, assignable to material's
208
+ `CodeResolverOptions`. An API code (`<module>.errors.<slug>`) takes the
209
+ catalogue's message in the page's language; any other key takes the app's
210
+ `translate`; a code the catalogue lacks comes back as it is, so the form
211
+ library words the issue with its own locale and never shows the code. Make
212
+ it once, at module scope: a new function re-creates the resolver.
213
+
214
+ Two options adapt it. `codeOf(issue)` says which code an issue carries (the
215
+ refinement's `params.code` by default); an app that also words an empty field
216
+ as the API's `validation.errors.required` passes its own. `isApiCode(key)`
217
+ says which keys are API codes (`<module>.errors.<slug>` by default).
218
+
219
+ ```ts
220
+ import { apiCodeResolver } from '@alxia/react-router/errors';
221
+ import { apiMessages } from './api-messages';
222
+ import { getLanguage, translate } from './i18n';
223
+
224
+ export const CODE_RESOLVER = apiCodeResolver(apiMessages, {
225
+ translate,
226
+ language: getLanguage,
227
+ // An empty string field is the API's "required" refusal.
228
+ codeOf: (issue) =>
229
+ issue.code === 'too_small' && issue.origin === 'string'
230
+ ? 'validation.errors.required'
231
+ : (issue.params?.code as string | undefined),
232
+ // Only the API's own modules are worded by the catalogue.
233
+ isApiCode: (key) => /^(todos|validation)\.errors\./.test(key),
234
+ });
235
+ ```
236
+
237
+ The codes a browser can word are the ones the root's data brought, so the
238
+ app lists the few its forms use. A code missing from that list shows its
239
+ fallback ([troubleshooting](../troubleshooting.md#a-forms-own-check-shows-the-code-todoserrors-instead-of-words)).
240
+
241
+ ## Wording a refusal
242
+
243
+ An action or a loader that calls the API meets a refusal as an `ApiError`.
244
+ `createApiErrors` says it in the page's language, from the catalogue the app
245
+ keeps. Make one beside the catalogue:
246
+
247
+ ```ts
248
+ // app/api-errors.ts
249
+ import { createApiErrors } from '@alxia/react-router/errors';
250
+ import { apiMessages } from './api-messages';
251
+ import { translate } from './i18n';
252
+
253
+ export const apiErrors = createApiErrors<'en' | 'fr'>({
254
+ messages: apiMessages,
255
+ // The app's own word for an API it cannot reach: never an API code.
256
+ unavailable: (language) => translate('app.error.unavailable', language),
257
+ });
258
+ ```
259
+
260
+ - `apiErrorMessage(error, language)` is the catalogue's text of the error's
261
+ code with its `params` filled in (`extensions.params` over the extensions
262
+ themselves), else the message the API already worded.
263
+ - `fieldErrors(error, language)` is the message of each refused field by
264
+ input path, from `extensions.fields`; a refusal that names one field
265
+ (`extensions.field`) gives it the error's message. It is a `FormErrors`'
266
+ `fieldErrors`.
267
+ - `apiRefusal(error, language)` is what a failed call says to the user, as
268
+ `{ message, status }` (an `ApiRefusal`): the refusal's message with its
269
+ status, or `unavailable` with 503 for a 503 `Response` (an API that could
270
+ not be reached). It is `undefined` for anything else, which the caller
271
+ rethrows.
272
+ - `translateCode(code, fallback, language, values?)` words one code; use it
273
+ for a code the app words itself, such as a mail throttle's
274
+ `notes.errors.too-many-attempts` with `{ minutes: waitMinutes(extensions) }`.
275
+
276
+ ```ts
277
+ import { data } from 'react-router';
278
+ import { apiErrors } from './api-errors';
279
+ import type { Route } from './+types/todos';
280
+
281
+ // Your own call to the API, declared here as a stub.
282
+ declare function createTodo(input: unknown): Promise<void>;
283
+
284
+ export async function action({ request }: Route.ActionArgs) {
285
+ try {
286
+ await createTodo(await request.json());
287
+ return { ok: true };
288
+ } catch (error) {
289
+ const refusal = apiErrors.apiRefusal(error, 'en');
290
+ if (!refusal) throw error;
291
+ return data(
292
+ { formError: refusal.message },
293
+ { status: refusal.status },
294
+ );
295
+ }
296
+ }
297
+ ```
298
+
299
+ Two options are for an API that is stricter than the default. An error with
300
+ no API code (a raw `BAD_USER_INPUT`, from a constraint that failed before the
301
+ API's validation layer) says the API's own message unless the app gives
302
+ `refused`, its generic refusal, which then replaces it; the raw message is
303
+ never shown. `byCode` words given codes in the app's own way, for refusals
304
+ whose params differ and name no field, such as a query's depth or cost:
305
+
306
+ ```ts
307
+ export const apiErrors = createApiErrors<'en' | 'fr'>({
308
+ messages: apiMessages,
309
+ unavailable: (language) => translate('app.error.unavailable', language),
310
+ refused: (language) => translate('app.error.refused', language),
311
+ byCode: {
312
+ 'validation.errors.query-too-deep': (_error, language) =>
313
+ translate('app.error.too-heavy', language),
314
+ 'validation.errors.query-too-wide': (_error, language) =>
315
+ translate('app.error.too-heavy', language),
316
+ },
317
+ });
318
+ ```
319
+
320
+ The order is exact. `refused` applies only when the error has no API code,
321
+ or a code that `isApiCode` rejects; it outranks `byCode`. Then comes the
322
+ catalogue, then the API's own message. `fieldErrors` uses the same order for
323
+ a single `extensions.field`, while the entries of `extensions.fields` go
324
+ straight to the catalogue.
325
+
326
+ `isApiCode` changes what counts as an API code (`<module>.errors.<slug>` by
327
+ default). Here only the codes of the `todos` module are the catalogue's; any
328
+ other code, even one shaped like a catalogue code, says `refused`:
329
+
330
+ ```ts
331
+ export const apiErrors = createApiErrors<'en' | 'fr'>({
332
+ messages: apiMessages,
333
+ unavailable: (language) => translate('app.error.unavailable', language),
334
+ refused: (language) => translate('app.error.refused', language),
335
+ isApiCode: (code) => code.startsWith('todos.errors.'),
336
+ });
337
+ ```
338
+
339
+ `fieldErrors` gives a form its messages by input path:
340
+
341
+ ```ts
342
+ import { data } from 'react-router';
343
+ import { isApiErrorLike } from '@alxia/react-router/errors';
344
+ import { apiErrors } from './api-errors';
345
+ import type { Route } from './+types/todos';
346
+
347
+ declare function createTodo(input: unknown): Promise<void>;
348
+
349
+ export async function action({ request }: Route.ActionArgs) {
350
+ try {
351
+ await createTodo(await request.json());
352
+ return { ok: true };
353
+ } catch (error) {
354
+ const refusal = apiErrors.apiRefusal(error, 'en');
355
+ if (!refusal || !isApiErrorLike(error)) throw error;
356
+ return data(
357
+ { fieldErrors: apiErrors.fieldErrors(error, 'en') },
358
+ { status: refusal.status },
359
+ );
360
+ }
361
+ }
362
+ ```
363
+
364
+ ## Loaders and the error page
365
+
366
+ A loader that calls the API lets a refusal reach the error page. Throw
367
+ `routeError(error)`: an `ApiError` whose status is 401, 403, 404 or 503
368
+ becomes a route error response of that status, carrying the API's `code` and
369
+ `message` and the call's `requestId`; any other `ApiError` with a request id
370
+ becomes a 500 that carries only the id; anything else is returned as it is,
371
+ so `throw` it too.
372
+
373
+ ```ts
374
+ import { routeError } from '@alxia/react-router/errors';
375
+ import type { Route } from './+types/todo';
376
+
377
+ // Your own call to the API, declared here as a stub.
378
+ declare function readTodo(id: string): Promise<{ title: string }>;
379
+
380
+ export async function loader({ params }: Route.LoaderArgs) {
381
+ try {
382
+ return await readTodo(params.id);
383
+ } catch (error) {
384
+ throw routeError(error);
385
+ }
386
+ }
387
+ ```
388
+
389
+ The root's `ErrorBoundary` words the error and shows the support reference
390
+ the user can quote, `errorRequestId(error)` (`undefined` for an error that
391
+ carries none):
392
+
393
+ ```tsx
394
+ import { errorRequestId } from '@alxia/react-router/errors';
395
+ import { isRouteErrorResponse, useRouteError } from 'react-router';
396
+
397
+ export function ErrorBoundary() {
398
+ const error = useRouteError();
399
+ const requestId = errorRequestId(error);
400
+ return (
401
+ <main>
402
+ <h1>{isRouteErrorResponse(error) ? error.status : 'Oops'}</h1>
403
+ {requestId ? <p>Reference: {requestId}</p> : null}
404
+ </main>
405
+ );
406
+ }
407
+ ```
408
+
409
+ A thrown `Response` of one of those statuses with an `x-request-id` header
410
+ becomes a route error too (a bare `Response` hands the browser its body
411
+ only). `routeError(error, { statuses })` changes the routed statuses (a
412
+ `ReadonlySet<number>`). The id is read from `x-request-id` (`REQUEST_ID_HEADER`,
413
+ through `requestIdOf`), the one header name the subpath knows.
414
+
415
+ An `ApiError` has a `requestId` only if the code that called the API set it
416
+ (read `requestIdOf(response)` and keep it on the error); see
417
+ [the error page shows no request id](../troubleshooting.md#the-error-page-shows-no-request-id).
418
+
419
+ ## Actions
420
+
421
+ An action that calls the API answers a refusal as data, so the page stays
422
+ where it was. `apiErrors` (from [wording a refusal](#wording-a-refusal)) has
423
+ two helpers; each throws, through `routeError`, what the page cannot word (a
424
+ 401, which the session code redirects first, or any failure that is not an
425
+ API refusal), for the error page.
426
+
427
+ `fetcherResult(language, about, run)` is for a fetcher's action: `{ ok: true }`
428
+ with `about` and the value `run` resolved, or `{ ok: false, error }` with
429
+ `about` and the worded message, answered with the refusal's HTTP status. An
430
+ unreachable API (a 503 `Response`) is worded too.
431
+
432
+ ```ts
433
+ import { apiErrors } from './api-errors';
434
+ import type { Route } from './+types/todos';
435
+
436
+ // Your own call to the API, declared here as a stub.
437
+ declare function renameTodo(input: unknown): Promise<{ title: string }>;
438
+
439
+ export async function action({ request }: Route.ActionArgs) {
440
+ const input = await request.json();
441
+ return apiErrors.fetcherResult('en', { intent: 'rename' }, () =>
442
+ renameTodo(input),
443
+ );
444
+ }
445
+ ```
446
+
447
+ `submitForm(language, run, fieldOf)` is for a form: `{ ok: true, value }`, or
448
+ the form's `FormErrors` with the refusal's status. `fieldOf` names the form
449
+ field of an input path (`undefined` when the form does not show it): a
450
+ placed field's message goes under that field (`fieldErrors`), and any other
451
+ message, with the API's own for the code, joins the `formError`. A 404 that
452
+ names none of the form's fields (the record itself is gone) goes to the error
453
+ page.
454
+
455
+ ```ts
456
+ import { apiErrors } from './api-errors';
457
+ import type { Route } from './+types/todos';
458
+
459
+ // Your own call to the API, declared here as a stub.
460
+ declare function createTodo(input: unknown): Promise<{ id: string }>;
461
+
462
+ export async function action({ request }: Route.ActionArgs) {
463
+ const input = await request.json();
464
+ return apiErrors.submitForm(
465
+ 'en',
466
+ () => createTodo(input),
467
+ (path) => (path === 'todo.title' ? 'title' : undefined),
468
+ );
469
+ }
470
+ ```
471
+
472
+ `fetcherRefusal(error, language)` is the refusal both build on, for an action
473
+ of your own: it returns `apiRefusal`'s `{ message, status }`, or throws what
474
+ the page cannot word.
475
+
476
+ ```ts
477
+ import { apiErrors } from './api-errors';
478
+ import type { Route } from './+types/todos';
479
+
480
+ // Your own call to the API, declared here as a stub.
481
+ declare function archiveTodo(id: string): Promise<{ archivedAt: string }>;
482
+
483
+ export async function action({ request }: Route.ActionArgs) {
484
+ const { id } = await request.json();
485
+ try {
486
+ return { ok: true as const, ...(await archiveTodo(id)) };
487
+ } catch (error) {
488
+ // The API's refusal: { message, status }. A 401 or any other failure throws.
489
+ const { message, status } = apiErrors.fetcherRefusal(error, 'en');
490
+ return Response.json({ ok: false, error: message }, { status });
491
+ }
492
+ }
493
+ ```
494
+
495
+ The helpers route with `routeError`'s defaults (401, 403, 404 and 503). Each
496
+ takes an optional last argument, `route?: RouteErrorOptions`, passed to
497
+ `routeError` for what it throws: `apiErrors.fetcherRefusal(error, 'en', {
498
+ statuses })`, `fetcherResult(language, about, run, { statuses })` and
499
+ `submitForm(language, run, fieldOf, { statuses })`.
500
+
501
+ ## JSON actions and CSRF
502
+
503
+ Every action of the examples above reads JSON. That is a choice: a page of
504
+ another site can post a form, or `text/plain`, to your URL without a
505
+ preflight, and the browser sends the user's cookies with it. Taking
506
+ `application/json` alone, and only from this site, stops that before the
507
+ session cookie's `SameSite` is even consulted. `jsonActions` holds the three
508
+ helpers that do it.
509
+
510
+ ```ts
511
+ // app/json.ts
512
+ import { jsonActions } from '@alxia/react-router/errors';
513
+
514
+ export const { readJson, requireJsonPost, readInput } = jsonActions();
515
+ ```
516
+
517
+ `readJson(request)` answers the parsed body, or throws a response: 403
518
+ `Cross-site request refused` when the `Origin` is not this site's, or the
519
+ `Sec-Fetch-Site` is neither `same-origin` nor `none`; 415 `Expected a JSON body`
520
+ when the content type is not `application/json`; 400 `Malformed JSON body`
521
+ when the body does not parse.
522
+
523
+ ```ts
524
+ import { readJson } from './json';
525
+ import type { Route } from './+types/todos';
526
+
527
+ export async function action({ request }: Route.ActionArgs) {
528
+ const body = (await readJson(request)) as { title?: string };
529
+ // …
530
+ }
531
+ ```
532
+
533
+ The body is limited only by the server's `maxRequestBodySize` (Bun's default
534
+ is 128 MB), so an app should set it to what its largest form needs.
535
+
536
+ `readInput(request, schema)` reads the body's `input` and checks it against
537
+ a [Standard Schema](https://standardschema.dev) (Zod 4, Valibot, ArkType,
538
+ or one of your own), awaiting the check when it is async. It answers the
539
+ schema's output, typed, or throws a 400 `The form sent an input the API would
540
+ refuse`. It checks the origin the same way, since it reads through `readJson`.
541
+
542
+ ```ts
543
+ import { z } from 'zod';
544
+ import { apiErrors } from './api-errors';
545
+ import { readInput } from './json';
546
+ import type { Route } from './+types/todos';
547
+
548
+ const createTodo = z.object({ title: z.string().min(1) });
549
+
550
+ // Your own call to the API, declared here as a stub.
551
+ declare function create(input: { title: string }): Promise<{ id: string }>;
552
+
553
+ export async function action({ request }: Route.ActionArgs) {
554
+ const input = await readInput(request, createTodo);
555
+ return apiErrors.submitForm(
556
+ 'en',
557
+ () => create(input),
558
+ (path) => (path === 'title' ? 'title' : undefined),
559
+ );
560
+ }
561
+ ```
562
+
563
+ The Standard Schema types are declared in `/errors` itself (a few lines,
564
+ the specification's own interface), so the subpath never imports
565
+ `@alxia/core`, not even a type. `@alxia/core`'s `StandardSchemaV1`, and every
566
+ vendor's schema, fit them as they are.
567
+
568
+ `requireJsonPost(request)` is for an action that a native form also posts to:
569
+ before the page hydrates, a click posts the form natively, and `readJson`
570
+ would answer 415. `requireJsonPost` sends that post back to its page, its
571
+ search kept (`nextOf`), which then posts JSON once hydrated.
572
+
573
+ ```ts
574
+ export async function action({ request }: Route.ActionArgs) {
575
+ requireJsonPost(request);
576
+ const body = await readJson(request);
577
+ // …
578
+ }
579
+ ```
580
+
581
+ ### The origin behind a proxy
582
+
583
+ A request is "this site" when its `Origin` is the origin of `request.url`.
584
+ Behind a proxy that terminates TLS that origin is the internal one, so give
585
+ `jsonActions` the public one. A getter is read at each request, and when it
586
+ answers `undefined` the request's own origin is used:
587
+
588
+ ```ts
589
+ export const { readJson, requireJsonPost, readInput } = jsonActions({
590
+ origin: () => process.env.APP_ORIGIN,
591
+ });
592
+ ```
593
+
594
+ Without it, every post is refused: see
595
+ [Behind a TLS proxy every action answers 403](../troubleshooting.md#behind-a-tls-proxy-every-action-answers-403).
596
+
597
+ ### Posting from the page
598
+
599
+ `useJsonFetcher<Action, Body>(key?, action?)` is a React Router fetcher that
600
+ posts its body as JSON to the page's action (or to the `action` it is given):
601
+ `post(body, to?)` sends it, `busy` is true while it is in flight, and
602
+ `fetcher` is the fetcher, whose `data` is what the action answered. `key`
603
+ shares the fetcher between the component that posts and the one that reads
604
+ the answer. `post`'s second argument is the action of one call, for a list
605
+ whose rows each post to their own record.
606
+
607
+ ```tsx
608
+ import { useJsonFetcher } from '@alxia/react-router/errors';
609
+ import type { action } from './todos';
610
+
611
+ export function RenameTodo({ id }: { id: string }) {
612
+ const { fetcher, post, busy } = useJsonFetcher<typeof action>();
613
+ return (
614
+ <>
615
+ <button
616
+ type="button"
617
+ disabled={busy}
618
+ onClick={() => post({ input: { title: 'Done' } }, `/todos/${id}`)}
619
+ >
620
+ Rename
621
+ </button>
622
+ {fetcher.data && !fetcher.data.ok ? <p>{fetcher.data.error}</p> : null}
623
+ </>
624
+ );
625
+ }
626
+ ```
627
+
628
+ The action answers `{ ok, error }`, the shape of `fetcherResult` and `submitForm`, so `fetcher.data` is read as above.
629
+
630
+ `Body` types what `post` takes: `useJsonFetcher<typeof action, { input: { title: string } }>()`.
631
+ The hook imports `react-router` alone, so a page can import it from `/errors`.
632
+
633
+ ## Calling the API from a loader
634
+
635
+ The subpath does not send the call: the headers, the caller and the API's
636
+ address are the app's. It holds what happens once a call failed.
637
+ `apiCallFailure(error, options)` returns what the loader or action should
638
+ throw, and logs it:
639
+
640
+ | the call failed with | `apiCallFailure` returns | log |
641
+ | --- | --- | --- |
642
+ | an `ApiError` (the API refused) | the same error, with `requestId` set when you pass one | a warning for a 4xx, an error for a 5xx, with the status and the API's code |
643
+ | an `ApiStatusError` of 401, 403 or 404 (no GraphQL body) | a `Response` of that status and reason phrase, with an `x-request-id` header | a warning |
644
+ | an `ApiStatusError` of another status | a 503 `Response` with no body | an error |
645
+ | an `ApiUnavailableError` (`unreachable`, `timeout`, `invalid-response`) | a 503 `Response` with no body | an error with the reason |
646
+ | anything else, once `signal` is aborted | a 503 `Response` with no body | an error: `aborted` |
647
+ | anything else | the same error | none |
648
+
649
+ It recognises the client's errors by their `name`, never with `instanceof`, so
650
+ it imports nothing from `@nxgt/graphql-client` and holds across two copies of
651
+ it. Pass `requestId` from the response (`requestIdOf(response)`), and the id
652
+ rides on the `ApiError` and on the `Response`, where `routeError` reads it.
653
+ `passedThrough` replaces the statuses that keep their own (401, 403 and 404),
654
+ and `log` replaces `console`. A `passedThrough` status that cannot carry a
655
+ body (204 and the like) is answered with an empty body. The log lines start
656
+ with the `api` name you pass (`<api>: <operation> …`), not
657
+ `alxia-react-router:`, because they describe your app's call, not a package
658
+ diagnostic. Nothing logged holds a header, a variable or a token. A `log` that
659
+ throws never replaces the outcome: the error or `Response` is returned as if
660
+ the line had been written.
661
+
662
+ An app writes one `execute` that sends the call and ends in `apiCallFailure`.
663
+ The example uses `@nxgt/graphql-client`; `operationName` reads a document
664
+ structurally, so `graphql` is not imported by the subpath.
665
+
666
+ ```ts
667
+ import { createGraphQLClient } from '@nxgt/graphql-client';
668
+ import {
669
+ apiCallFailure,
670
+ operationName,
671
+ requestIdOf,
672
+ routeError,
673
+ } from '@alxia/react-router/errors';
674
+ import type { DocumentNode } from 'graphql';
675
+
676
+ const url = 'https://api.example.com/graphql';
677
+
678
+ export async function execute(
679
+ document: DocumentNode,
680
+ variables: Record<string, unknown>,
681
+ caller: { language: string; token?: string; signal?: AbortSignal },
682
+ ) {
683
+ let requestId: string | undefined;
684
+ const client = createGraphQLClient({
685
+ url,
686
+ dedupe: false,
687
+ fetch: async (request: Request) => {
688
+ const response = await fetch(request);
689
+ requestId = requestIdOf(response); // the client hands none back
690
+ return response;
691
+ },
692
+ });
693
+ try {
694
+ return await client.execute(document as never, variables as never, {
695
+ signal: caller.signal,
696
+ headers: { 'accept-language': caller.language },
697
+ timeout: 10_000,
698
+ });
699
+ } catch (error) {
700
+ throw apiCallFailure(error, {
701
+ api: `my-api ${url}`,
702
+ operation: operationName(document),
703
+ requestId,
704
+ signal: caller.signal,
705
+ });
706
+ }
707
+ }
708
+
709
+ // A loader: throw routeError(error) for what execute() threw.
710
+ export async function loader() {
711
+ try {
712
+ return await execute(todosQuery, {}, { language: 'en' });
713
+ } catch (error) {
714
+ throw routeError(error);
715
+ }
716
+ }
717
+
718
+ declare const todosQuery: DocumentNode;
719
+ ```
720
+
721
+ Each log line is in the troubleshooting page:
722
+ [answered 403](../troubleshooting.md#api-operation-request-id-answered-status-code),
723
+ [answered 502 without GraphQL](../troubleshooting.md#api-operation-request-id-answered-status-without-graphql),
724
+ [unreachable, timeout, invalid-response](../troubleshooting.md#api-operation-request-id-unreachable-timeout-or-invalid-response)
725
+ and [aborted](../troubleshooting.md#api-operation-request-id-aborted).
726
+
727
+ ## Keeping the catalogue fresh
728
+
729
+ The store of [the catalogue](#the-catalogue-store) is empty until something
730
+ fills it. On the server, `createErrorCatalogue` does: it reads each
731
+ language's messages from the API on first use, serves them from memory, and
732
+ checks the API's versions once per TTL, re-reading only the languages whose
733
+ version moved. It is an instance, made once in a module of the app's own, and
734
+ it imports nothing: the app's two calls to its API are options.
735
+
736
+ ```ts
737
+ // app/catalogue.server.ts
738
+ import { createErrorCatalogue } from '@alxia/react-router/errors';
739
+ import { apiMessages } from './api-messages';
740
+ import { api } from './api.server';
741
+
742
+ export const catalogue = createErrorCatalogue<'en' | 'fr'>({
743
+ messages: apiMessages,
744
+ languages: ['en', 'fr'],
745
+ // { version, messages: [{ code, message, params }] }
746
+ read: (language) => api.errorCatalogue(language),
747
+ // { en: 'v7', fr: 'v7' }: one call for every language
748
+ versions: () => api.errorCatalogueVersions(),
749
+ ttlMs: () => Number(process.env.CATALOGUE_TTL_MS ?? 60_000),
750
+ // The codes the browser words itself, such as a form's checks.
751
+ clientCodes: ['validation.errors.required', 'todos.errors.title-taken'],
752
+ });
753
+ ```
754
+
755
+ `ttlMs` has no default and must be given: a number, or a getter read at each
756
+ check, so a value set at startup or changed by a test is found. A getter that
757
+ throws, or a value that is not a finite number of at least 0, is logged and
758
+ `retryMs` is used in its place (see
759
+ [`ttlMs is not a duration`](../troubleshooting.md#alxia-react-router-ttlms-is-not-a-duration-using-retryms)). `retryMs` (5000 by default) is how soon a
760
+ failed read or check is tried again, and `firstReadWaitMs` (1500 by default)
761
+ is how long a request waits for a language's first read. `log` receives the
762
+ failures (`console` by default); its lines start with `alxia-react-router:`.
763
+ `languages` may not be empty: it throws a `TypeError` otherwise.
764
+
765
+ ### Awaiting it in the root's middleware
766
+
767
+ `freshAll()` makes every language ready before a loader or an action words a
768
+ refusal. It waits only for a language with no catalogue yet, and only up to
769
+ `firstReadWaitMs`; a held catalogue is served at once, and an old one is
770
+ checked in the background, so a request never waits for a check.
771
+
772
+ ```ts
773
+ // app/root.tsx
774
+ import { catalogue } from './catalogue.server';
775
+
776
+ export const middleware = [
777
+ async (_args: unknown, next: () => Promise<unknown>) => {
778
+ await catalogue.freshAll();
779
+ return next();
780
+ },
781
+ ];
782
+ ```
783
+
784
+ A language whose first read is slow or failed is not waited for twice: the
785
+ wait is counted from when the read started, one read is in flight however many
786
+ requests ask, and a failure is retried after `retryMs`. Meanwhile the refusals
787
+ of that language read in the API's words: see
788
+ [Right after start](../troubleshooting.md#right-after-start-refusals-read-in-the-apis-words).
789
+
790
+ ### Returning the browser's messages from the root's loader
791
+
792
+ The browser words a form's checks itself, so it needs the messages of the
793
+ `clientCodes`. `clientMessages()` returns them by language, leaving out a code
794
+ the catalogue does not hold; `ApiMessagesProvider` adds them to the browser's
795
+ store.
796
+
797
+ ```tsx
798
+ // app/root.tsx
799
+ import { catalogue } from './catalogue.server';
800
+
801
+ export function loader() {
802
+ return { catalogue: catalogue.clientMessages() };
803
+ }
804
+
805
+ // In the component:
806
+ // <ApiMessagesProvider messages={apiMessages} value={data.catalogue}>
807
+ ```
808
+
809
+ ### Warming it at startup
810
+
811
+ `warm()` reads every language without holding the boot back, so the first
812
+ request finds the messages held. It never throws: a failure is logged.
813
+
814
+ ```ts
815
+ // app/server.ts
816
+ import { createServer } from '@alxia/react-router';
817
+ import { catalogue } from './catalogue.server';
818
+
819
+ catalogue.warm();
820
+
821
+ export default createServer();
822
+ ```
823
+
824
+ ### In a spec
825
+
826
+ `fresh(language, now)` and `freshAll(now)` take the time as an argument, in
827
+ milliseconds, so a spec moves the clock instead of sleeping: a call at
828
+ `now = ttl` starts the check, `settled()` waits for it, and `heldVersion`
829
+ says which version is held. `reset()`, `settled()`, `heldVersion()`, the `now`
830
+ parameters and the store's `clear()` are for tests. `reset()` forgets what is held and empties the
831
+ store. It is for use between tests, with nothing in flight: a read or check
832
+ still running when it is called finishes and holds its result again.
833
+
834
+ ```ts
835
+ await catalogue.fresh('en', 0);
836
+ await catalogue.fresh('en', 60_000); // past the TTL: checks in the background
837
+ await catalogue.settled();
838
+ ```