@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.
- package/README.md +176 -1
- 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/docs/README.md +2 -1
- package/docs/guide/errors.md +838 -0
- package/docs/roadmap.md +19 -0
- package/docs/troubleshooting.md +481 -0
- 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
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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.
|
|
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",
|