@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
package/docs/roadmap.md CHANGED
@@ -53,6 +53,25 @@ Nothing planned.
53
53
 
54
54
  ## Shipped
55
55
 
56
+ ### 0.11.0
57
+
58
+ - **`@alxia/react-router/errors`: an API's refusals, worded for the page.**
59
+ A server's GraphQL refusals (`<module>.errors.<slug>`, `params`, `fields`)
60
+ are worded from its error catalogue in the page's language, for forms,
61
+ fetchers, loaders and the error page, with no UI. You get the shape of a
62
+ refusal, the request id and `nextOf`, the catalogue store
63
+ (`createApiMessages`), the form checks' resolver (`apiCodeResolver`), the
64
+ provider (`ApiMessagesProvider`) and the wording of a refusal
65
+ (`createApiErrors`: message, fields and status), the route helpers
66
+ (`routeError`, `errorRequestId`) and the actions' helpers (`fetcherResult`,
67
+ `fetcherRefusal`, `submitForm`), the JSON actions (`jsonActions`:
68
+ `readJson` with its same-origin CSRF check, `requireJsonPost`, and
69
+ `readInput` over a Standard Schema) with their fetcher hook
70
+ (`useJsonFetcher`), the catalogue's refresh (`createErrorCatalogue`: read
71
+ on first use with a bounded wait, checked against the API's versions once
72
+ per TTL, `warm()` at startup, `clientMessages()` for the root), and the
73
+ failures of a call (`apiCallFailure`, `operationName`) ([guide](guide/errors.md)).
74
+
56
75
  ### 0.10.0
57
76
 
58
77
  - **`@alxia/react-router/pwa/worker`.** `startWorker({ contract })` is the
@@ -53,6 +53,20 @@ a loader, a message React Router or the browser prints, or an error from
53
53
  - [`buildWorker(): build/client/offline.html does not exist`](#buildworker-buildclientofflinehtml-does-not-exist)
54
54
  - [`buildWorker(): … is outside ….`](#buildworker--is-outside-)
55
55
  - [`buildWorker(): Bun.build failed:`](#buildworker-bunbuild-failed)
56
+ - [`Cross-site request refused`](#cross-site-request-refused)
57
+ - [`Expected a JSON body`](#expected-a-json-body)
58
+ - [`Malformed JSON body`](#malformed-json-body)
59
+ - [`The form sent an input the API would refuse`](#the-form-sent-an-input-the-api-would-refuse)
60
+ - [`alxia-react-router: the error catalogue (…) was not read`](#alxia-react-router-the-error-catalogue--was-not-read)
61
+ - [`alxia-react-router: the error catalogue was not checked, keeping what is held`](#alxia-react-router-the-error-catalogue-was-not-checked-keeping-what-is-held)
62
+ - [`alxia-react-router: the error catalogue was not warmed`](#alxia-react-router-the-error-catalogue-was-not-warmed)
63
+ - [`alxia-react-router: ttlMs threw, using retryMs`](#alxia-react-router-ttlms-threw-using-retryms)
64
+ - [`alxia-react-router: ttlMs is not a duration, using retryMs`](#alxia-react-router-ttlms-is-not-a-duration-using-retryms)
65
+ - [`TypeError: createErrorCatalogue(): needs at least one language.`](#typeerror-createerrorcatalogue-needs-at-least-one-language)
66
+ - [`<api>: <operation> (request <id>) answered <status> <code>`](#api-operation-request-id-answered-status-code)
67
+ - [`<api>: <operation> (request <id>) answered <status> without GraphQL`](#api-operation-request-id-answered-status-without-graphql)
68
+ - [`<api>: <operation> (request <id>) unreachable`, `timeout` or `invalid-response`](#api-operation-request-id-unreachable-timeout-or-invalid-response)
69
+ - [`<api>: <operation> (request <id>) aborted`](#api-operation-request-id-aborted)
56
70
 
57
71
  **Types**
58
72
 
@@ -87,6 +101,14 @@ a loader, a message React Router or the browser prints, or an error from
87
101
  - [An update waits an hour](#an-update-waits-an-hour)
88
102
  - [The worker cannot fetch another origin](#the-worker-cannot-fetch-another-origin)
89
103
  - [The manifest is in the wrong language](#the-manifest-is-in-the-wrong-language)
104
+ - [A form's own check shows the code (`todos.errors.…`) instead of words](#a-forms-own-check-shows-the-code-todoserrors-instead-of-words)
105
+ - [A message shows its `{braces}`](#a-message-shows-its-braces)
106
+ - [A refusal shows the API's raw message](#a-refusal-shows-the-apis-raw-message)
107
+ - [`apiRefusal` returns undefined](#apirefusal-returns-undefined)
108
+ - [The error page shows no request id](#the-error-page-shows-no-request-id)
109
+ - [An action shows the error page](#an-action-shows-the-error-page)
110
+ - [Behind a TLS proxy every action answers 403](#behind-a-tls-proxy-every-action-answers-403)
111
+ - [Right after start, refusals read in the API's words](#right-after-start-refusals-read-in-the-apis-words)
90
112
 
91
113
  ## Thrown or printed
92
114
 
@@ -1032,6 +1054,265 @@ peers, installed as
1032
1054
  [`Could not resolve: "workbox-core"`](#error-could-not-resolve-workbox-core-maybe-you-need-to-bun-install)
1033
1055
  shows.
1034
1056
 
1057
+ ### `Cross-site request refused`
1058
+
1059
+ **When:** an action reads its body with `readJson` or `readInput` (from
1060
+ `jsonActions()`), and the page's post answers 403 with this text.
1061
+
1062
+ **Why:** the request carried an `Origin` other than the app's own, or a
1063
+ `Sec-Fetch-Site` that is neither `same-origin` nor `none`. That is what a
1064
+ post from another site looks like, so it is refused before the body is read.
1065
+ The usual honest cause is that the app's origin is not the one the request
1066
+ reaches: see [Behind a TLS proxy every action answers 403](#behind-a-tls-proxy-every-action-answers-403).
1067
+
1068
+ **Fix:** if the post comes from another site, nothing: the refusal is the
1069
+ point. If it comes from your own page, give `jsonActions` the origin your
1070
+ users see:
1071
+
1072
+ ```ts
1073
+ export const { readJson } = jsonActions({
1074
+ origin: 'https://app.example.com',
1075
+ });
1076
+ ```
1077
+
1078
+ ### `Expected a JSON body`
1079
+
1080
+ **When:** an action answers 415 with this text.
1081
+
1082
+ **Why:** `readJson` takes only `application/json`. A native form post
1083
+ (`application/x-www-form-urlencoded` or `multipart/form-data`), a
1084
+ `text/plain` body and a request with no content type are refused, since only
1085
+ JSON needs a preflight from another site.
1086
+
1087
+ **Fix:** post from `useJsonFetcher`, which sets the content type
1088
+ (`encType: 'application/json'`), or set `Content-Type: application/json` on
1089
+ your own `fetch`. For an action that a native form also posts to before the
1090
+ page hydrates, call `requireJsonPost(request)` first: it sends that post back
1091
+ to its page, which then posts JSON.
1092
+
1093
+ ```ts
1094
+ const { requireJsonPost, readJson } = jsonActions();
1095
+
1096
+ export async function action({ request }: Route.ActionArgs) {
1097
+ requireJsonPost(request);
1098
+ const body = await readJson(request);
1099
+ // …
1100
+ }
1101
+ ```
1102
+
1103
+ ### `Malformed JSON body`
1104
+
1105
+ **When:** an action answers 400 with this text.
1106
+
1107
+ **Why:** the request declared `application/json`, but `request.json()` could
1108
+ not parse the body: it is empty, truncated, or not JSON at all (a form's
1109
+ `FormData` posted with the JSON content type, for example).
1110
+
1111
+ **Fix:** post an object through `useJsonFetcher`'s `post(body)`, or
1112
+ `JSON.stringify` the body of your own `fetch`. Do not set the content type by
1113
+ hand on a `FormData` body.
1114
+
1115
+ ### `The form sent an input the API would refuse`
1116
+
1117
+ **When:** an action that reads its body with `readInput(request, schema)`
1118
+ answers 400 with this text.
1119
+
1120
+ **Why:** the body was JSON, but its `input` field did not pass the schema: it
1121
+ is missing, or one of the checks failed. `readInput` reads `{ input }`, not
1122
+ the whole body, and it answers a bare 400 on purpose: the schema is the API's
1123
+ own input, so a form that passed its own checks never gets here.
1124
+
1125
+ **Fix:** post `{ input: … }`, and make the form's checks the schema's. To see
1126
+ what the schema refused while you debug, call it yourself:
1127
+
1128
+ ```ts
1129
+ const body = (await readJson(request)) as { input?: unknown };
1130
+ const result = await schema['~standard'].validate(body.input);
1131
+ console.log(result.issues);
1132
+ ```
1133
+
1134
+ ### `alxia-react-router: the error catalogue (…) was not read`
1135
+
1136
+ **When:** the server logs this line (with the error after it, at the level of
1137
+ `log.error`) while a language's catalogue is read for the first time, and the
1138
+ refusals in that language read in the API's own words.
1139
+
1140
+ **Why:** the app's `read(language)` threw: the API was down, slow past its own
1141
+ timeout, or answered an error. The catalogue keeps no partial result. It tries
1142
+ again once `retryMs` (5 seconds by default) has passed since the failure, on
1143
+ the next request that asks for that language; one read is in flight at a time.
1144
+
1145
+ **Fix:** read the error that follows the line. Once the API answers, the next
1146
+ request after the delay reads the catalogue and the refusals are worded; no
1147
+ restart is needed. To see the calls, give `createErrorCatalogue` a `log`
1148
+ that records them.
1149
+
1150
+ ### `alxia-react-router: the error catalogue was not checked, keeping what is held`
1151
+
1152
+ **When:** the server logs this line (with the error after it) once a held
1153
+ catalogue is older than the TTL and the check of the versions failed.
1154
+
1155
+ **Why:** `versions()` threw. The catalogue keeps serving the messages it
1156
+ holds, which may be stale, and asks again after `retryMs` instead of after a
1157
+ whole TTL.
1158
+
1159
+ **Fix:** read the error that follows. Nothing to restart: when the API
1160
+ answers, the check goes on and re-reads the languages whose version moved.
1161
+ If it repeats while the API is up, check that `versions()` returns a version
1162
+ for every language in `languages`: a language missing from its answer is read
1163
+ again at each check.
1164
+
1165
+ ### `alxia-react-router: the error catalogue was not warmed`
1166
+
1167
+ **When:** the server logs this line at startup, after `warm()`.
1168
+
1169
+ **Why:** `warm()` reads every language without holding the boot back, and
1170
+ the promise it started rejected. The reads themselves report their failures
1171
+ (`the error catalogue (…) was not read`), so this line means something else
1172
+ threw.
1173
+
1174
+ **Fix:** read the error that follows. The first request that asks for a
1175
+ language reads it anyway, so the server is not left without a catalogue.
1176
+
1177
+ ### `alxia-react-router: ttlMs threw, using retryMs`
1178
+
1179
+ **When:** the server logs this line (with the error after it) each time the
1180
+ catalogue reads its TTL and the `ttlMs` getter throws.
1181
+
1182
+ **Why:** `ttlMs` is a getter of the app's, such as one that reads a setting,
1183
+ and it threw. The catalogue does not let it break a request: it uses
1184
+ `retryMs` as the TTL for that read, so the held messages are still served
1185
+ and the versions are checked again soon.
1186
+
1187
+ **Fix:** read the error that follows and fix the getter, or pass a number.
1188
+
1189
+ ```ts
1190
+ const catalogue = createErrorCatalogue({
1191
+ // …
1192
+ ttlMs: () => settings.catalogueTtlMs ?? 60_000,
1193
+ });
1194
+ ```
1195
+
1196
+ ### `alxia-react-router: ttlMs is not a duration, using retryMs`
1197
+
1198
+ **When:** the server logs this line each time the catalogue reads its TTL and
1199
+ `ttlMs` is not a finite number of at least 0: `NaN`, `Infinity`, a negative
1200
+ number, or a value that is not a number, such as the string a setting
1201
+ returned.
1202
+
1203
+ **Why:** a TTL that is not a duration would never expire a held catalogue
1204
+ (`Infinity`) or make every comparison false (`NaN`). The catalogue treats it
1205
+ like a getter that threw: it uses `retryMs` for that read.
1206
+
1207
+ **Fix:** pass a number of milliseconds, converted where the setting is read:
1208
+
1209
+ ```ts
1210
+ const catalogue = createErrorCatalogue({
1211
+ // …
1212
+ ttlMs: () => Number(process.env.CATALOGUE_TTL_MS ?? 60_000),
1213
+ });
1214
+ ```
1215
+
1216
+ ### `TypeError: createErrorCatalogue(): needs at least one language.`
1217
+
1218
+ **When:** the server starts, or the module that calls `createErrorCatalogue`
1219
+ is imported.
1220
+
1221
+ **Why:** `languages` is empty, usually a list built from an environment
1222
+ variable or a config that was not set.
1223
+
1224
+ **Fix:** pass the languages the app serves, the same list the page's
1225
+ translator knows:
1226
+
1227
+ ```ts
1228
+ const catalogue = createErrorCatalogue({
1229
+ messages: apiMessages,
1230
+ languages: ['en', 'fr'],
1231
+ // …
1232
+ });
1233
+ ```
1234
+
1235
+ ### `<api>: <operation> (request <id>) answered <status> <code>`
1236
+
1237
+ Logged by `apiCallFailure` (a warning for a 4xx, an error for a 5xx), after the
1238
+ `api` the app gave it, when the
1239
+ API refused a call with a GraphQL body, or answered a status that is passed
1240
+ through, such as a 404, which has no `<code>`. For example
1241
+ `my-api https://api.example.com/graphql: Todos (request r-1) answered 403 todos.errors.forbidden`.
1242
+
1243
+ **Why:** the API answered, and refused: `<status>` and `<code>` are the
1244
+ `ApiError`'s. A 4xx is expected (a forbidden page, a missing record), which is
1245
+ why it is a warning. The `(request …)` part is there only if the API sent an
1246
+ `x-request-id` and your code passed it as `requestId`; without it the line
1247
+ carries no id and an error page can show none.
1248
+
1249
+ **Fix:** nothing to fix in the package. To find the API's own log line, search
1250
+ for the id. To get the id in the line, pass `requestId: requestIdOf(response)`
1251
+ from the `fetch` the client calls:
1252
+
1253
+ ```ts
1254
+ import { apiCallFailure, requestIdOf } from '@alxia/react-router/errors';
1255
+
1256
+ let requestId: string | undefined;
1257
+ const send = async (request: Request) => {
1258
+ const response = await fetch(request);
1259
+ requestId = requestIdOf(response);
1260
+ return response;
1261
+ };
1262
+
1263
+ export const failed = (error: unknown) =>
1264
+ apiCallFailure(error, { api: 'my-api', requestId });
1265
+ ```
1266
+
1267
+ ### `<api>: <operation> (request <id>) answered <status> without GraphQL`
1268
+
1269
+ Logged as an error by `apiCallFailure`, and the loader gets a 503 `Response`
1270
+ with no body (the reason is in the log).
1271
+
1272
+ **Why:** the API, or a gateway in front of it, answered a status that is not
1273
+ 401, 403 or 404 with a body that is not GraphQL (an HTML 502 page, a 429
1274
+ from a rate limiter). The log line's second argument is that body.
1275
+
1276
+ **Fix:** read the body in the log and fix the gateway. If a status such as 429
1277
+ should reach the page as its own, name it:
1278
+
1279
+ ```ts
1280
+ import { apiCallFailure } from '@alxia/react-router/errors';
1281
+
1282
+ export const failed = (error: unknown) =>
1283
+ apiCallFailure(error, {
1284
+ api: 'my-api',
1285
+ passedThrough: new Set([401, 403, 404, 429]),
1286
+ });
1287
+ ```
1288
+
1289
+ ### `<api>: <operation> (request <id>) unreachable`, `timeout` or `invalid-response`
1290
+
1291
+ Logged as an error by `apiCallFailure`, and the loader gets a 503 `Response`
1292
+ with no body (the reason is in the log line).
1293
+
1294
+ **Why:** `@nxgt/graphql-client` could not get an answer: `unreachable` is a
1295
+ refused connection or a DNS failure, `timeout` is a call past the deadline the
1296
+ app gave it, and `invalid-response` is an answer that is no GraphQL response
1297
+ (a 2xx with no `data`, garbled JSON). The log line's second argument is the
1298
+ cause.
1299
+
1300
+ **Fix:** check the API's address and that it is up. For a `timeout`, the call's
1301
+ `timeout` option is the app's, not the package's. The 503 reaches the error
1302
+ page through `routeError`, which words it with the request id when there is
1303
+ one.
1304
+
1305
+ ### `<api>: <operation> (request <id>) aborted`
1306
+
1307
+ Logged as an error by `apiCallFailure`, and the loader gets a 503 `Response`.
1308
+
1309
+ **Why:** the `signal` you passed was aborted, so the visitor closed the tab or
1310
+ navigated away, and the call failed with whatever error that produced. Nobody
1311
+ sees the 503.
1312
+
1313
+ **Fix:** none. To keep it out of the logs, test `request.signal.aborted` in
1314
+ your own `catch` before calling `apiCallFailure`.
1315
+
1035
1316
  ## Types
1036
1317
 
1037
1318
  ### `Property 'alxia' does not exist on type 'Readonly<RouterContextProvider>'`
@@ -1579,3 +1860,203 @@ cookie never reaches the server. Only the link's query does.
1579
1860
  read it with `manifestLanguage(url, supported, fallback)`; the fallback is a
1580
1861
  supported language the app resolves from the request's `Accept-Language`, not
1581
1862
  from the cookie ([the manifest route](guide/pwa.md#the-web-app-manifest-and-why-the-language-travels-in-lang)).
1863
+
1864
+ ### A form's own check shows the code (`todos.errors.…`) instead of words
1865
+
1866
+ A refinement with `params: { code: 'todos.errors.title-taken' }` shows the
1867
+ code itself, or the form library's generic text, where the API's words should
1868
+ be.
1869
+
1870
+ **Why:** `apiCodeResolver` returns the code unchanged when its `messages`
1871
+ store has no text for it in the page's language, and the form library then
1872
+ words the issue itself. In the browser the store holds only what the root's
1873
+ data brought: the code is not in that list (the app's `clientCodes`), or
1874
+ `ApiMessagesProvider` is not around the routes, or it was given another store
1875
+ than the resolver's.
1876
+
1877
+ **Fix:** add the code to the few the root sends, and wrap the routes with the
1878
+ provider on the same instance the resolver reads:
1879
+
1880
+ ```tsx
1881
+ <ApiMessagesProvider messages={apiMessages} value={loaderData.apiMessages}>
1882
+ <Outlet />
1883
+ </ApiMessagesProvider>
1884
+ ```
1885
+
1886
+ Check it with `apiMessages.messageOf('fr', code)` in the browser's console
1887
+ or a spec ([the catalogue store](guide/errors.md#the-catalogue-store)).
1888
+
1889
+ ### A message shows its `{braces}`
1890
+
1891
+ A refusal reads `{count, plural, one {One item} other {# items}} outside {name}.`
1892
+ or `Hello {name}`, braces and all.
1893
+
1894
+ **Why:** the `format` given to `createApiMessages` is not an ICU formatter
1895
+ (an identity function, a plain `replace`), or a value the message names is
1896
+ missing and the formatter handed back the raw message. `apiMessage` returns
1897
+ `undefined` for a message the formatter reported to `onError`, but a
1898
+ formatter that never calls it returns the text as it found it.
1899
+
1900
+ **Fix:** pass the app's ICU formatter, such as `@nxgt/material`'s
1901
+ `formatMessage`, and make it call `onError` for a message it cannot format:
1902
+
1903
+ ```ts
1904
+ const apiMessages = createApiMessages<'en' | 'fr'>({ format: formatMessage });
1905
+ ```
1906
+
1907
+ A value is missing when the API's `extensions.params` lacks an argument the
1908
+ message names; the same call in a spec shows which.
1909
+
1910
+ ### A refusal shows the API's raw message
1911
+
1912
+ A page shows `Variable "$email" got invalid value …` or another message the
1913
+ API worded for developers, instead of the app's words.
1914
+
1915
+ **Why:** the error has no API code (a raw `BAD_USER_INPUT`, from a check that
1916
+ failed before the API's validation layer), or a code that `isApiCode`
1917
+ rejects. Without `refused`, `createApiErrors` says the API's own message for
1918
+ such an error.
1919
+
1920
+ **Fix:** pass `refused`, the app's generic refusal; it replaces the raw
1921
+ message for every error that is not an API code
1922
+ ([wording a refusal](guide/errors.md#wording-a-refusal)):
1923
+
1924
+ ```ts
1925
+ export const apiErrors = createApiErrors<'en' | 'fr'>({
1926
+ messages: apiMessages,
1927
+ unavailable: (language) => translate('app.error.unavailable', language),
1928
+ refused: (language) => translate('app.error.refused', language),
1929
+ });
1930
+ ```
1931
+
1932
+ ### `apiRefusal` returns undefined
1933
+
1934
+ `apiErrors.apiRefusal(error, language)` gives `undefined` for a call that
1935
+ failed, and the action returns nothing useful.
1936
+
1937
+ **Why:** the error is neither an API error (`isApiErrorLike`) nor a 503
1938
+ `Response`: a network failure, a `TypeError` of the app's own code, a 404.
1939
+ `apiRefusal` words only what the API refused or could not be reached for.
1940
+
1941
+ **Fix:** rethrow what it does not word, so the route's error boundary shows
1942
+ it:
1943
+
1944
+ ```ts
1945
+ const refusal = apiErrors.apiRefusal(error, 'en');
1946
+ if (!refusal) throw error;
1947
+ ```
1948
+
1949
+ ### The error page shows no request id
1950
+
1951
+ The root's `ErrorBoundary` calls `errorRequestId(error)` and gets `undefined`,
1952
+ though the API logged the request.
1953
+
1954
+ **Why:** `routeError` carries the `requestId` the error already has; it never
1955
+ reads it from the network. An `ApiError` has none unless the code that called
1956
+ the API set it, and a thrown `Response` is turned into a route error only when
1957
+ it has the `x-request-id` header. A loader that returns `routeError(error)`
1958
+ instead of throwing it never reaches the boundary at all.
1959
+
1960
+ **Fix:** keep the id on the error where the API is called, and `throw` what
1961
+ `routeError` gives back
1962
+ ([loaders and the error page](guide/errors.md#loaders-and-the-error-page)):
1963
+
1964
+ ```ts
1965
+ import { requestIdOf, routeError } from '@alxia/react-router/errors';
1966
+
1967
+ // Your own call to the API, declared here as a stub.
1968
+ declare function call(): Promise<Response>;
1969
+
1970
+ export async function loader() {
1971
+ const response = await call();
1972
+ if (!response.ok) {
1973
+ // A Response of 401, 403, 404 or 503 with the header is a route error.
1974
+ throw routeError(response);
1975
+ }
1976
+ return { requestId: requestIdOf(response) };
1977
+ }
1978
+ ```
1979
+
1980
+ ### An action shows the error page
1981
+
1982
+ An action that calls `fetcherResult`, `fetcherRefusal` or `submitForm` ends on
1983
+ the route's error page instead of answering `{ ok: false }` or the form's
1984
+ errors.
1985
+
1986
+ **Why:** by design, these helpers answer only what the page can word. They
1987
+ throw `routeError(error)` for a 401 (the session code redirects to the sign-in
1988
+ first), for a failure that is not an API refusal (a network error, a bug of the
1989
+ app's own code), and, for `submitForm`, for a 404 that names none of the
1990
+ form's fields: the record itself is gone, and no field message could say so.
1991
+
1992
+ **Fix:** none is needed for a 401 or a real failure: the error page is the
1993
+ right answer. For a 404 that is about a field, name it in `fieldOf`
1994
+ (`(path) => (path === 'todo.id' ? 'id' : undefined)`) so it sits under that
1995
+ field. For a refusal you would rather word yourself, build on `apiRefusal`,
1996
+ which returns `undefined` instead of throwing:
1997
+
1998
+ ```ts
1999
+ const refusal = apiErrors.apiRefusal(error, 'en');
2000
+ if (!refusal) throw error;
2001
+ return Response.json(
2002
+ { ok: false, error: refusal.message },
2003
+ { status: refusal.status },
2004
+ );
2005
+ ```
2006
+
2007
+ ### Behind a TLS proxy every action answers 403
2008
+
2009
+ Every action that reads JSON answers 403 `Cross-site request refused`
2010
+ in production, yet works under `react-router dev` and in the specs.
2011
+
2012
+ **Why:** `jsonActions()` compares the browser's `Origin` (`https://app.example.com`)
2013
+ with the origin of `request.url`. Behind a proxy that terminates TLS the
2014
+ app is asked over plain HTTP on an internal host (`http://10.0.0.5:3000`),
2015
+ so no post matches. See also
2016
+ [`request.url` in a loader is `http://`](#requesturl-in-a-loader-is-http-and-the-internal-host-behind-a-proxy).
2017
+
2018
+ **Fix:** name the proxy with `createServer({ proxy: trustProxy(…) })`
2019
+ (`trustProxy` is `@alxia/core`'s; see
2020
+ [Behind a proxy](guide/customising-the-server.md#behind-a-proxy)). The
2021
+ catch-all hands React Router the URL `originalUrl` reads from the trusted
2022
+ proxy's `X-Forwarded-Proto` and `X-Forwarded-Host`, so an action's
2023
+ `request.url` already carries the public origin and `jsonActions()` needs no
2024
+ `origin`. Without a trusted proxy, or when the proxy does not send those
2025
+ headers, pass the public origin, as a string or as a getter read at each
2026
+ request (so a value set at startup is found):
2027
+
2028
+ ```ts
2029
+ import { jsonActions } from '@alxia/react-router/errors';
2030
+
2031
+ export const { readJson, requireJsonPost, readInput } = jsonActions({
2032
+ origin: () => process.env.APP_ORIGIN,
2033
+ });
2034
+ ```
2035
+
2036
+ When the getter returns `undefined`, the request's own origin is used, so
2037
+ development needs no setting. An empty string is not `undefined`: it is
2038
+ kept, and every request with an `Origin` is refused.
2039
+
2040
+ ### Right after start, refusals read in the API's words
2041
+
2042
+ For the first seconds after the server starts, a refusal shows the message
2043
+ the API worded (in its default language, say) instead of the page's language.
2044
+ It reads right a few seconds later.
2045
+
2046
+ **Why:** a language's catalogue is read on first use, and a request waits for
2047
+ it only up to `firstReadWaitMs` (1.5 seconds by default), counted from when the
2048
+ read started. A slow API, or a failed read, lets the request go on with the
2049
+ API's own words; the read carries on in the background (or is retried after
2050
+ `retryMs`), and the next request finds it held.
2051
+
2052
+ **Fix:** read both languages at startup, so the first request finds them held.
2053
+ In `server.ts`:
2054
+
2055
+ ```ts
2056
+ import { catalogue } from './catalogue.server';
2057
+
2058
+ catalogue.warm();
2059
+ ```
2060
+
2061
+ Raise `firstReadWaitMs` only if the page should wait longer for the first
2062
+ words; it holds every cold request that long when the API hangs.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alxia/react-router",
3
- "version": "0.10.0",
4
- "description": "alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served; the code with no UI an installable app shares (`/pwa`), its hooks (`/pwa/react`), `noCache` for the worker's script (`/pwa/server`), the service worker on Workbox (`/pwa/worker`) and its build (`/pwa/build`)",
3
+ "version": "0.11.0",
4
+ "description": "alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served; the code with no UI an installable app shares (`/pwa`), its hooks (`/pwa/react`), `noCache` for the worker's script (`/pwa/server`), the service worker on Workbox (`/pwa/worker`) and its build (`/pwa/build`) and an API's refusals worded for the page (`/errors`)",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -42,6 +42,11 @@
42
42
  "import": "./dist/pwa/server/index.js",
43
43
  "default": "./dist/pwa/server/index.js"
44
44
  },
45
+ "./errors": {
46
+ "types": "./dist/errors/index.d.ts",
47
+ "import": "./dist/errors/index.js",
48
+ "default": "./dist/errors/index.js"
49
+ },
45
50
  "./pwa/build": {
46
51
  "types": "./dist/pwa/build/index.d.ts",
47
52
  "import": "./dist/pwa/build/index.js",
@@ -75,6 +80,7 @@
75
80
  "src/pwa/index.ts",
76
81
  "src/pwa/react/index.ts",
77
82
  "src/pwa/server/index.ts",
83
+ "src/errors/index.ts",
78
84
  "src/pwa/worker/index.ts",
79
85
  "src/pwa/build/index.ts",
80
86
  "src/cli/index.ts"
@@ -84,10 +90,13 @@
84
90
  "@alxia/compress": "^0.3.10",
85
91
  "@alxia/core": "^0.15.1",
86
92
  "@alxia/openapi": "^0.6.10",
93
+ "@nxgt/graphql-client": "^0.4.0",
87
94
  "@react-router/dev": "^8.5.0",
88
95
  "@types/bun": "^1.4.2",
89
96
  "@types/react": "^19.3.0",
90
97
  "@types/react-dom": "^19.3.0",
98
+ "graphql": "^17.0.2",
99
+ "intl-messageformat": "^12.1.4",
91
100
  "isbot": "^5.2.2",
92
101
  "react": "^19.3.1",
93
102
  "react-dom": "^19.3.1",
@@ -98,7 +107,8 @@
98
107
  "workbox-navigation-preload": "^7.4.1",
99
108
  "workbox-precaching": "^7.4.1",
100
109
  "workbox-routing": "^7.4.1",
101
- "workbox-strategies": "^7.4.1"
110
+ "workbox-strategies": "^7.4.1",
111
+ "zod": "^4.2.0"
102
112
  },
103
113
  "peerDependencies": {
104
114
  "@alxia/core": "^0.15.1",