@alxia/react-router 0.10.0 → 0.12.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.
- package/README.md +370 -20
- package/dist/chunks/index-5rt6hnhn.js +42 -0
- package/dist/chunks/index-5rt6hnhn.js.map +11 -0
- package/dist/chunks/{index-mppz0806.js → index-6jf7zpxz.js} +5 -5
- package/dist/chunks/{index-mppz0806.js.map → index-6jf7zpxz.js.map} +1 -1
- package/dist/chunks/index-y6vpnjr3.js +123 -0
- package/dist/chunks/index-y6vpnjr3.js.map +14 -0
- package/dist/context.d.ts +0 -7
- package/dist/context.d.ts.map +1 -1
- package/dist/document/index.d.ts +11 -0
- package/dist/document/index.d.ts.map +1 -0
- package/dist/document/index.js +19 -0
- package/dist/document/index.js.map +9 -0
- package/dist/document/inline-script.d.ts +27 -0
- package/dist/document/inline-script.d.ts.map +1 -0
- package/dist/document/language-store.d.ts +8 -0
- package/dist/document/language-store.d.ts.map +1 -0
- package/dist/document/nonce.d.ts +34 -0
- package/dist/document/nonce.d.ts.map +1 -0
- package/dist/document/page-language.d.ts +39 -0
- package/dist/document/page-language.d.ts.map +1 -0
- package/dist/document/text-direction.d.ts +10 -0
- package/dist/document/text-direction.d.ts.map +1 -0
- package/dist/entry/abort.d.ts +21 -0
- package/dist/entry/abort.d.ts.map +1 -0
- package/dist/entry/create-entry.d.ts +32 -0
- package/dist/entry/create-entry.d.ts.map +1 -0
- package/dist/entry/index.d.ts +8 -0
- package/dist/entry/index.d.ts.map +1 -0
- package/dist/entry/index.js +128 -0
- package/dist/entry/index.js.map +12 -0
- package/dist/entry/whole-page.d.ts +10 -0
- package/dist/entry/whole-page.d.ts.map +1 -0
- package/dist/errors/api-error.d.ts +86 -0
- package/dist/errors/api-error.d.ts.map +1 -0
- package/dist/errors/api-errors.d.ts +96 -0
- package/dist/errors/api-errors.d.ts.map +1 -0
- package/dist/errors/call-failure.d.ts +45 -0
- package/dist/errors/call-failure.d.ts.map +1 -0
- package/dist/errors/catalogue-check.d.ts +24 -0
- package/dist/errors/catalogue-check.d.ts.map +1 -0
- package/dist/errors/catalogue-read.d.ts +33 -0
- package/dist/errors/catalogue-read.d.ts.map +1 -0
- package/dist/errors/catalogue.d.ts +59 -0
- package/dist/errors/catalogue.d.ts.map +1 -0
- package/dist/errors/code-resolver.d.ts +44 -0
- package/dist/errors/code-resolver.d.ts.map +1 -0
- package/dist/errors/describe-refusal.d.ts +6 -0
- package/dist/errors/describe-refusal.d.ts.map +1 -0
- package/dist/errors/describe.d.ts +3 -0
- package/dist/errors/describe.d.ts.map +1 -0
- package/dist/errors/fetcher-result.d.ts +21 -0
- package/dist/errors/fetcher-result.d.ts.map +1 -0
- package/dist/errors/form-errors.d.ts +8 -0
- package/dist/errors/form-errors.d.ts.map +1 -0
- package/dist/errors/index.d.ts +15 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +602 -0
- package/dist/errors/index.js.map +29 -0
- package/dist/errors/json-actions.d.ts +38 -0
- package/dist/errors/json-actions.d.ts.map +1 -0
- package/dist/errors/log.d.ts +8 -0
- package/dist/errors/log.d.ts.map +1 -0
- package/dist/errors/message-values.d.ts +11 -0
- package/dist/errors/message-values.d.ts.map +1 -0
- package/dist/errors/messages.d.ts +63 -0
- package/dist/errors/messages.d.ts.map +1 -0
- package/dist/errors/page-path.d.ts +9 -0
- package/dist/errors/page-path.d.ts.map +1 -0
- package/dist/errors/provider.d.ts +20 -0
- package/dist/errors/provider.d.ts.map +1 -0
- package/dist/errors/request-id.d.ts +5 -0
- package/dist/errors/request-id.d.ts.map +1 -0
- package/dist/errors/route-error.d.ts +31 -0
- package/dist/errors/route-error.d.ts.map +1 -0
- package/dist/errors/standard-schema.d.ts +34 -0
- package/dist/errors/standard-schema.d.ts.map +1 -0
- package/dist/errors/submit-form.d.ts +16 -0
- package/dist/errors/submit-form.d.ts.map +1 -0
- package/dist/errors/use-json-fetcher.d.ts +18 -0
- package/dist/errors/use-json-fetcher.d.ts.map +1 -0
- package/dist/index.js +13 -34
- package/dist/index.js.map +3 -5
- package/dist/pwa/build/index.js +1 -1
- package/dist/pwa/index.js +6 -6
- package/dist/pwa/index.js.map +1 -1
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +29 -2
- package/dist/vite/index.js.map +5 -4
- package/dist/vite/inline.d.ts +18 -0
- package/dist/vite/inline.d.ts.map +1 -0
- package/docs/README.md +3 -1
- package/docs/guide/csp-nonce.md +35 -3
- package/docs/guide/document.md +310 -0
- package/docs/guide/errors.md +838 -0
- package/docs/roadmap.md +36 -1
- package/docs/troubleshooting.md +712 -1
- package/package.json +33 -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
|
+
```
|