@alxia/react-router 0.9.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 +301 -13
- package/dist/chunks/index-59vhfj6n.js +10 -0
- package/dist/chunks/index-59vhfj6n.js.map +10 -0
- package/dist/chunks/index-5gqcpyyg.js +34 -0
- package/dist/chunks/index-5gqcpyyg.js.map +10 -0
- package/dist/chunks/{index-zqa30gr7.js → index-9xv2a8et.js} +7 -38
- package/dist/chunks/index-9xv2a8et.js.map +11 -0
- package/dist/chunks/index-mppz0806.js +119 -0
- package/dist/chunks/index-mppz0806.js.map +11 -0
- package/dist/chunks/index-rfr02vg4.js +40 -0
- package/dist/chunks/index-rfr02vg4.js.map +10 -0
- package/dist/chunks/index-zdbqzcjp.js +97 -0
- package/dist/chunks/index-zdbqzcjp.js.map +12 -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/pwa/build/build-worker.d.ts +41 -0
- package/dist/pwa/build/build-worker.d.ts.map +1 -0
- package/dist/pwa/build/index.d.ts +9 -0
- package/dist/pwa/build/index.d.ts.map +1 -0
- package/dist/pwa/build/index.js +9 -0
- package/dist/pwa/build/index.js.map +9 -0
- package/dist/pwa/build/precache.d.ts +17 -0
- package/dist/pwa/build/precache.d.ts.map +1 -0
- package/dist/pwa/index.js +34 -134
- package/dist/pwa/index.js.map +4 -8
- package/dist/pwa/react/index.js +1 -1
- package/dist/pwa/worker/index.d.ts +9 -0
- package/dist/pwa/worker/index.d.ts.map +1 -0
- package/dist/pwa/worker/index.js +144 -0
- package/dist/pwa/worker/index.js.map +12 -0
- package/dist/pwa/worker/injected.d.ts +15 -0
- package/dist/pwa/worker/injected.d.ts.map +1 -0
- package/dist/pwa/worker/plan.d.ts +65 -0
- package/dist/pwa/worker/plan.d.ts.map +1 -0
- package/dist/pwa/worker/scope.d.ts +18 -0
- package/dist/pwa/worker/scope.d.ts.map +1 -0
- package/dist/pwa/worker/start-worker.d.ts +17 -0
- package/dist/pwa/worker/start-worker.d.ts.map +1 -0
- package/dist/vite/index.d.ts +10 -0
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +49 -3
- package/dist/vite/index.js.map +6 -5
- package/dist/vite/runtime.d.ts +1 -1
- package/dist/vite/runtime.d.ts.map +1 -1
- package/dist/vite/worker.d.ts +16 -0
- package/dist/vite/worker.d.ts.map +1 -0
- package/docs/README.md +3 -2
- package/docs/guide/errors.md +838 -0
- package/docs/guide/how-it-works.md +7 -1
- package/docs/guide/pwa.md +372 -211
- package/docs/roadmap.md +38 -5
- package/docs/troubleshooting.md +689 -3
- package/package.json +56 -4
- package/dist/chunks/index-zqa30gr7.js.map +0 -12
package/docs/troubleshooting.md
CHANGED
|
@@ -8,12 +8,14 @@ a loader, a message React Router or the browser prints, or an error from
|
|
|
8
8
|
**Thrown or printed**
|
|
9
9
|
|
|
10
10
|
- [`alxia-react-router: … is running on Node, and alxia's server runs on Bun. …`](#alxia-react-router--is-running-on-node-and-alxias-server-runs-on-bun-)
|
|
11
|
+
- [`alxia-react-router: react-router build with a service worker is running on Node, and the worker is bundled by Bun.build`](#alxia-react-router-react-router-build-with-a-service-worker-is-running-on-node-and-the-worker-is-bundled-by-bunbuild)
|
|
11
12
|
- [`ReferenceError: Bun is not defined`](#referenceerror-bun-is-not-defined)
|
|
12
13
|
- [`alxiaOf(): this request has no alxia context. …`](#alxiaof-this-request-has-no-alxia-context-)
|
|
13
14
|
- [`TypeError: undefined is not an object (evaluating 'context.alxia.user')`](#typeerror-undefined-is-not-an-object-evaluating-contextalxiauser)
|
|
14
15
|
- [`Error: No value found for context`](#error-no-value-found-for-context)
|
|
15
16
|
- [`alxia-react-router: … must export createServer() from @alxia/react-router as its default export: …`](#alxia-react-router--must-export-createserver-from-alxiareact-router-as-its-default-export-)
|
|
16
17
|
- [`alxia-react-router: the entry … does not exist. …`](#alxia-react-router-the-entry--does-not-exist-)
|
|
18
|
+
- [`alxia-react-router: the worker … does not exist. …`](#alxia-react-router-the-worker--does-not-exist-)
|
|
17
19
|
- [`alxia-react-router: React Router's Vite plugin is not in this config. …`](#alxia-react-router-react-routers-vite-plugin-is-not-in-this-config-)
|
|
18
20
|
- [`alxia-react-router: serverBundles splits React Router's server build in several, and alxia serves one. …`](#alxia-react-router-serverbundles-splits-react-routers-server-build-in-several-and-alxia-serves-one-)
|
|
19
21
|
- [`alxia-react-router: Vite's ssr environment does not run modules in this process, so the server cannot be loaded.`](#alxia-react-router-vites-ssr-environment-does-not-run-modules-in-this-process-so-the-server-cannot-be-loaded)
|
|
@@ -42,6 +44,29 @@ a loader, a message React Router or the browser prints, or an error from
|
|
|
42
44
|
- [`TypeError: defineWorkerContract(): cacheVersion must be a non-empty name without '-', …`](#typeerror-defineworkercontract-cacheversion-must-be-a-non-empty-name-without---)
|
|
43
45
|
- [`parseBrowserManifest(): not React Router's browser manifest …`](#parsebrowsermanifest-not-react-routers-browser-manifest-)
|
|
44
46
|
- [`error: Cannot find package 'react' from '…/@alxia/react-router/dist/pwa/react/index.js'`](#error-cannot-find-package-react-from-alxiareact-routerdistpwareactindexjs)
|
|
47
|
+
- [`startWorker(): no precache list. …`](#startworker-no-precache-list-)
|
|
48
|
+
- [`TypeError: startWorker(): contract is required: …`](#typeerror-startworker-contract-is-required-)
|
|
49
|
+
- [`error: Could not resolve: "workbox-core". Maybe you need to "bun install"?`](#error-could-not-resolve-workbox-core-maybe-you-need-to-bun-install)
|
|
50
|
+
- [`buildWorker() runs on Bun: it is running on Node`](#buildworker-runs-on-bun-it-is-running-on-node)
|
|
51
|
+
- [`buildWorker(): app/service-worker.ts does not exist.`](#buildworker-appservice-workerts-does-not-exist)
|
|
52
|
+
- [`buildWorker(): expected one React Router browser manifest`](#buildworker-expected-one-react-router-browser-manifest)
|
|
53
|
+
- [`buildWorker(): build/client/offline.html does not exist`](#buildworker-buildclientofflinehtml-does-not-exist)
|
|
54
|
+
- [`buildWorker(): … is outside ….`](#buildworker--is-outside-)
|
|
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)
|
|
45
70
|
|
|
46
71
|
**Types**
|
|
47
72
|
|
|
@@ -71,17 +96,27 @@ a loader, a message React Router or the browser prints, or an error from
|
|
|
71
96
|
- [A cache the contract did not make is swept](#a-cache-the-contract-did-not-make-is-swept)
|
|
72
97
|
- [The update notice never shows](#the-update-notice-never-shows)
|
|
73
98
|
- [The worker registers under `react-router dev`](#the-worker-registers-under-react-router-dev)
|
|
99
|
+
- [The service worker is not built](#the-service-worker-is-not-built)
|
|
100
|
+
- [Offline, a client navigation to a route never visited shows the offline page](#offline-a-client-navigation-to-a-route-never-visited-shows-the-offline-page)
|
|
74
101
|
- [An update waits an hour](#an-update-waits-an-hour)
|
|
75
102
|
- [The worker cannot fetch another origin](#the-worker-cannot-fetch-another-origin)
|
|
76
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)
|
|
77
112
|
|
|
78
113
|
## Thrown or printed
|
|
79
114
|
|
|
80
115
|
### `alxia-react-router: … is running on Node, and alxia's server runs on Bun. …`
|
|
81
116
|
|
|
82
117
|
**When:** `bun run dev`, `vite preview`, or a `react-router build` that
|
|
83
|
-
prerenders
|
|
84
|
-
`react-router dev`:
|
|
118
|
+
prerenders or builds a service worker (`app/service-worker.ts`), on a machine
|
|
119
|
+
where a node is installed. The full message under `react-router dev`:
|
|
85
120
|
|
|
86
121
|
```
|
|
87
122
|
alxia-react-router: react-router dev is running on Node, and alxia's server runs on Bun. Add a bunfig.toml beside package.json with "[run]" and "bun = true", so bun run starts it on Bun, or run it as bun --bun react-router dev.
|
|
@@ -103,7 +138,18 @@ bun = true
|
|
|
103
138
|
Or run the one command on Bun: `bun --bun react-router dev`. Under
|
|
104
139
|
`vite preview` or a prerendering build, the message begins
|
|
105
140
|
`alxia-react-router: vite preview, or a react-router build that prerenders, is running on Node`
|
|
106
|
-
and names `bun --bun vite preview` and `bun --bun react-router build`.
|
|
141
|
+
and names `bun --bun vite preview` and `bun --bun react-router build`. A
|
|
142
|
+
build that has a service worker reads
|
|
143
|
+
`alxia-react-router: react-router build with a service worker is running on Node, and the worker is bundled by Bun.build`
|
|
144
|
+
and names the same `bun --bun react-router build`.
|
|
145
|
+
|
|
146
|
+
### `alxia-react-router: react-router build with a service worker is running on Node, and the worker is bundled by Bun.build`
|
|
147
|
+
|
|
148
|
+
The same cause as
|
|
149
|
+
[the entry above](#alxia-react-router--is-running-on-node-and-alxias-server-runs-on-bun-):
|
|
150
|
+
the build runs on Node, and the worker is bundled with `Bun.build`. Run it as
|
|
151
|
+
`bun --bun react-router build`, or add a `bunfig.toml` beside `package.json`
|
|
152
|
+
with `[run]` and `bun = true`.
|
|
107
153
|
|
|
108
154
|
### `ReferenceError: Bun is not defined`
|
|
109
155
|
|
|
@@ -273,6 +319,20 @@ Vite's root. Under `react-router dev` each request is a 500 saying so;
|
|
|
273
319
|
|
|
274
320
|
**Fix:** correct the path, or leave `entry` out.
|
|
275
321
|
|
|
322
|
+
### `alxia-react-router: the worker … does not exist. …`
|
|
323
|
+
|
|
324
|
+
```text
|
|
325
|
+
Error: alxia-react-router: the worker app/pwa/worker.ts does not exist. Create it, or leave worker out to build app/service-worker.ts when it exists.
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**When:** `alxia({ worker })` names a file that is not there, relative to
|
|
329
|
+
Vite's root, and `react-router build` fails.
|
|
330
|
+
|
|
331
|
+
**Why:** a missing `app/service-worker.ts` just means no worker, but a
|
|
332
|
+
`worker` you named is taken as meant.
|
|
333
|
+
|
|
334
|
+
**Fix:** correct the path, or leave `worker` out.
|
|
335
|
+
|
|
276
336
|
### `alxia-react-router: React Router's Vite plugin is not in this config. …`
|
|
277
337
|
|
|
278
338
|
```text
|
|
@@ -850,6 +910,409 @@ this message too.
|
|
|
850
910
|
`/pwa/react` from the page alone. The worker and a build script import
|
|
851
911
|
`@alxia/react-router/pwa`, which imports nothing.
|
|
852
912
|
|
|
913
|
+
### `startWorker(): no precache list. …`
|
|
914
|
+
|
|
915
|
+
**Why:** the full message ends "Build this worker with `alxia()` from
|
|
916
|
+
`@alxia/react-router/vite` (`app/service-worker.ts`) or `buildWorker()` from
|
|
917
|
+
`@alxia/react-router/pwa/build`, which write it in." `startWorker` precaches
|
|
918
|
+
the list the build defines in the bundle (`globalThis.__ALXIA_PRECACHE__`);
|
|
919
|
+
a worker bundled by anything else has none, and the page would install a
|
|
920
|
+
worker that precaches nothing.
|
|
921
|
+
|
|
922
|
+
**Fix:** either build the worker with the build that writes the list
|
|
923
|
+
(`alxia()` or `buildWorker()`), or pass the list yourself, which works for a
|
|
924
|
+
worker bundled any other way:
|
|
925
|
+
|
|
926
|
+
```ts
|
|
927
|
+
startWorker({
|
|
928
|
+
contract,
|
|
929
|
+
precache: [{ url: '/offline.html', revision: 'v1' }],
|
|
930
|
+
});
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
`precache` wins over the list the build wrote. A bundle of your own may
|
|
934
|
+
instead define `globalThis.__ALXIA_PRECACHE__`.
|
|
935
|
+
|
|
936
|
+
### `TypeError: startWorker(): contract is required: …`
|
|
937
|
+
|
|
938
|
+
**Why:** the full message continues "pass `defineWorkerContract({ cachePrefix })`
|
|
939
|
+
from `@alxia/react-router/pwa`." The worker's cache names, its offline page
|
|
940
|
+
and its URL come from the contract the page shares, so there is no default.
|
|
941
|
+
|
|
942
|
+
**Fix:**
|
|
943
|
+
|
|
944
|
+
```ts
|
|
945
|
+
// file: app/service-worker.ts
|
|
946
|
+
import { defineWorkerContract } from '@alxia/react-router/pwa';
|
|
947
|
+
import { startWorker } from '@alxia/react-router/pwa/worker';
|
|
948
|
+
|
|
949
|
+
startWorker({ contract: defineWorkerContract({ cachePrefix: 'my-app' }) });
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
### `error: Could not resolve: "workbox-core". Maybe you need to "bun install"?`
|
|
953
|
+
|
|
954
|
+
**Why:** `@alxia/react-router/pwa/worker` imports six `workbox-*` packages,
|
|
955
|
+
optional peers of the package, so nothing installs them for you. They are
|
|
956
|
+
bundled into the worker, never loaded at run time.
|
|
957
|
+
|
|
958
|
+
**Fix:** add them as development dependencies:
|
|
959
|
+
|
|
960
|
+
```sh
|
|
961
|
+
bun add -d workbox-core workbox-expiration workbox-navigation-preload \
|
|
962
|
+
workbox-precaching workbox-routing workbox-strategies
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
The `buildWorker()` messages below are the ones a script of your own sees.
|
|
966
|
+
Under `alxia()` each of them begins with `alxia-react-router: `.
|
|
967
|
+
|
|
968
|
+
### `buildWorker() runs on Bun: it is running on Node`
|
|
969
|
+
|
|
970
|
+
```text
|
|
971
|
+
buildWorker() runs on Bun: it is running on Node. Run it with bun, or run react-router build with bun --bun.
|
|
972
|
+
```
|
|
973
|
+
|
|
974
|
+
**When:** a script of your own calls `buildWorker()` and runs on Node. Under
|
|
975
|
+
`alxia()` the check comes first and reads
|
|
976
|
+
[`… is running on Node, and the worker is bundled by Bun.build`](#alxia-react-router--is-running-on-node-and-alxias-server-runs-on-bun-).
|
|
977
|
+
|
|
978
|
+
**Why:** the worker is bundled with `Bun.build`, which Node does not have.
|
|
979
|
+
|
|
980
|
+
**Fix:** run the script with `bun scripts/build-worker.ts`, or
|
|
981
|
+
`react-router build` as `bun --bun react-router build`.
|
|
982
|
+
|
|
983
|
+
### `buildWorker(): app/service-worker.ts does not exist.`
|
|
984
|
+
|
|
985
|
+
```text
|
|
986
|
+
buildWorker(): app/service-worker.ts does not exist.
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
**Why:** `file` is read from `root` (`process.cwd()` by default), not from the
|
|
990
|
+
script's folder. Under `alxia()` the message begins
|
|
991
|
+
`alxia-react-router: buildWorker(): …`.
|
|
992
|
+
|
|
993
|
+
**Fix:** pass the path relative to the project root, or pass `root`.
|
|
994
|
+
|
|
995
|
+
### `buildWorker(): expected one React Router browser manifest`
|
|
996
|
+
|
|
997
|
+
```text
|
|
998
|
+
buildWorker(): expected one React Router browser manifest, build/client/assets/manifest-*.js, found 0. Run react-router build first.
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
The message ends `found 2` when two are left.
|
|
1002
|
+
|
|
1003
|
+
**Why:** the precache list is read from React Router's browser manifest,
|
|
1004
|
+
which `react-router build` writes. None means the build did not run, or
|
|
1005
|
+
`client` is wrong. Two mean a stale build folder.
|
|
1006
|
+
|
|
1007
|
+
**Fix:** run `react-router build` first, check `client`, and delete `build/`
|
|
1008
|
+
when two manifests are left.
|
|
1009
|
+
|
|
1010
|
+
### `buildWorker(): build/client/offline.html does not exist`
|
|
1011
|
+
|
|
1012
|
+
```text
|
|
1013
|
+
buildWorker(): build/client/offline.html does not exist. Add public/offline.html, or pass offline: false (`worker: { offline: false }` in alxia()), or pass the app's contract (`worker: { contract }` in alxia()).
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
**Why:** the offline page is precached by default and is read from the client
|
|
1017
|
+
build, where `public/offline.html` is copied. The build reads the contract's
|
|
1018
|
+
`offlineUrl` from the `contract` option, and the worker from the contract it
|
|
1019
|
+
is given: two objects can disagree.
|
|
1020
|
+
|
|
1021
|
+
**Fix:** add the page to `public/`, or pass `offline: false` for a worker with
|
|
1022
|
+
no offline page. If the contract's `offlineUrl` is another path, pass the same
|
|
1023
|
+
contract object to the build (`alxia({ worker: { contract } })`, or
|
|
1024
|
+
`buildWorker({ contract })`).
|
|
1025
|
+
|
|
1026
|
+
### `buildWorker(): … is outside ….`
|
|
1027
|
+
|
|
1028
|
+
```text
|
|
1029
|
+
buildWorker(): /../service-worker.js is outside /app/build/client.
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
**Why:** the contract's `serviceWorkerUrl` or `offlineUrl` is joined onto the
|
|
1033
|
+
client build, and the result leaves it, or is the client build itself (`/`).
|
|
1034
|
+
The build refuses to write or read a file there. It also refuses an absolute
|
|
1035
|
+
URL (`https://example.com/sw.js`), a protocol-relative one (`//host/sw.js`) and
|
|
1036
|
+
one that does not start with `/`, since none names a file of the build.
|
|
1037
|
+
|
|
1038
|
+
**Fix:** use a root-relative URL inside the site, such as
|
|
1039
|
+
`/service-worker.js`, in the contract.
|
|
1040
|
+
|
|
1041
|
+
### `buildWorker(): Bun.build failed:`
|
|
1042
|
+
|
|
1043
|
+
```text
|
|
1044
|
+
buildWorker(): Bun.build failed:
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
Bun's logs follow the message.
|
|
1048
|
+
|
|
1049
|
+
**Why:** the worker's source or one of its imports did not bundle: a missing
|
|
1050
|
+
`workbox-*` package, a wrong import path.
|
|
1051
|
+
|
|
1052
|
+
**Fix:** read the first log line. The six `workbox-*` packages are optional
|
|
1053
|
+
peers, installed as
|
|
1054
|
+
[`Could not resolve: "workbox-core"`](#error-could-not-resolve-workbox-core-maybe-you-need-to-bun-install)
|
|
1055
|
+
shows.
|
|
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
|
+
|
|
853
1316
|
## Types
|
|
854
1317
|
|
|
855
1318
|
### `Property 'alxia' does not exist on type 'Readonly<RouterContextProvider>'`
|
|
@@ -1335,6 +1798,29 @@ import.meta.env.PROD })` ([the hook](guide/pwa.md#in-react)). Then unregister
|
|
|
1335
1798
|
the worker the dev server already took (DevTools, Application, Service
|
|
1336
1799
|
Workers, Unregister) and clear its site data.
|
|
1337
1800
|
|
|
1801
|
+
### The service worker is not built
|
|
1802
|
+
|
|
1803
|
+
`react-router build` ends and there is no `build/client/service-worker.js`.
|
|
1804
|
+
|
|
1805
|
+
**Why:** `alxia()` builds no worker when the file is not at
|
|
1806
|
+
`app/service-worker.ts` (the `appDirectory` of `react-router.config.ts` may
|
|
1807
|
+
hold another) and no `worker` option names it, when `worker: false` is set,
|
|
1808
|
+
or when the version of `@alxia/react-router` is older than 0.10.
|
|
1809
|
+
|
|
1810
|
+
**Fix:** put the file at `app/service-worker.ts`, or name it with
|
|
1811
|
+
`alxia({ worker: 'app/pwa/worker.ts' })`, and update the package.
|
|
1812
|
+
|
|
1813
|
+
### Offline, a client navigation to a route never visited shows the offline page
|
|
1814
|
+
|
|
1815
|
+
The page opens offline, but a link to a route the user never opened leads to
|
|
1816
|
+
`/offline.html`.
|
|
1817
|
+
|
|
1818
|
+
**Why:** React Router asks `/__manifest` for the routes a link needs, and the
|
|
1819
|
+
worker never caches that request.
|
|
1820
|
+
|
|
1821
|
+
**Fix:** set `routeDiscovery: { mode: 'initial' }` in `react-router.config.ts`,
|
|
1822
|
+
so the client has every route from its first load.
|
|
1823
|
+
|
|
1338
1824
|
### An update waits an hour
|
|
1339
1825
|
|
|
1340
1826
|
A new worker is deployed and tabs still run the old one for up to an hour.
|
|
@@ -1374,3 +1860,203 @@ cookie never reaches the server. Only the link's query does.
|
|
|
1374
1860
|
read it with `manifestLanguage(url, supported, fallback)`; the fallback is a
|
|
1375
1861
|
supported language the app resolves from the request's `Accept-Language`, not
|
|
1376
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.
|