@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.
Files changed (100) hide show
  1. package/README.md +301 -13
  2. package/dist/chunks/index-59vhfj6n.js +10 -0
  3. package/dist/chunks/index-59vhfj6n.js.map +10 -0
  4. package/dist/chunks/index-5gqcpyyg.js +34 -0
  5. package/dist/chunks/index-5gqcpyyg.js.map +10 -0
  6. package/dist/chunks/{index-zqa30gr7.js → index-9xv2a8et.js} +7 -38
  7. package/dist/chunks/index-9xv2a8et.js.map +11 -0
  8. package/dist/chunks/index-mppz0806.js +119 -0
  9. package/dist/chunks/index-mppz0806.js.map +11 -0
  10. package/dist/chunks/index-rfr02vg4.js +40 -0
  11. package/dist/chunks/index-rfr02vg4.js.map +10 -0
  12. package/dist/chunks/index-zdbqzcjp.js +97 -0
  13. package/dist/chunks/index-zdbqzcjp.js.map +12 -0
  14. package/dist/errors/api-error.d.ts +86 -0
  15. package/dist/errors/api-error.d.ts.map +1 -0
  16. package/dist/errors/api-errors.d.ts +96 -0
  17. package/dist/errors/api-errors.d.ts.map +1 -0
  18. package/dist/errors/call-failure.d.ts +45 -0
  19. package/dist/errors/call-failure.d.ts.map +1 -0
  20. package/dist/errors/catalogue-check.d.ts +24 -0
  21. package/dist/errors/catalogue-check.d.ts.map +1 -0
  22. package/dist/errors/catalogue-read.d.ts +33 -0
  23. package/dist/errors/catalogue-read.d.ts.map +1 -0
  24. package/dist/errors/catalogue.d.ts +59 -0
  25. package/dist/errors/catalogue.d.ts.map +1 -0
  26. package/dist/errors/code-resolver.d.ts +44 -0
  27. package/dist/errors/code-resolver.d.ts.map +1 -0
  28. package/dist/errors/describe-refusal.d.ts +6 -0
  29. package/dist/errors/describe-refusal.d.ts.map +1 -0
  30. package/dist/errors/describe.d.ts +3 -0
  31. package/dist/errors/describe.d.ts.map +1 -0
  32. package/dist/errors/fetcher-result.d.ts +21 -0
  33. package/dist/errors/fetcher-result.d.ts.map +1 -0
  34. package/dist/errors/form-errors.d.ts +8 -0
  35. package/dist/errors/form-errors.d.ts.map +1 -0
  36. package/dist/errors/index.d.ts +15 -0
  37. package/dist/errors/index.d.ts.map +1 -0
  38. package/dist/errors/index.js +602 -0
  39. package/dist/errors/index.js.map +29 -0
  40. package/dist/errors/json-actions.d.ts +38 -0
  41. package/dist/errors/json-actions.d.ts.map +1 -0
  42. package/dist/errors/log.d.ts +8 -0
  43. package/dist/errors/log.d.ts.map +1 -0
  44. package/dist/errors/message-values.d.ts +11 -0
  45. package/dist/errors/message-values.d.ts.map +1 -0
  46. package/dist/errors/messages.d.ts +63 -0
  47. package/dist/errors/messages.d.ts.map +1 -0
  48. package/dist/errors/page-path.d.ts +9 -0
  49. package/dist/errors/page-path.d.ts.map +1 -0
  50. package/dist/errors/provider.d.ts +20 -0
  51. package/dist/errors/provider.d.ts.map +1 -0
  52. package/dist/errors/request-id.d.ts +5 -0
  53. package/dist/errors/request-id.d.ts.map +1 -0
  54. package/dist/errors/route-error.d.ts +31 -0
  55. package/dist/errors/route-error.d.ts.map +1 -0
  56. package/dist/errors/standard-schema.d.ts +34 -0
  57. package/dist/errors/standard-schema.d.ts.map +1 -0
  58. package/dist/errors/submit-form.d.ts +16 -0
  59. package/dist/errors/submit-form.d.ts.map +1 -0
  60. package/dist/errors/use-json-fetcher.d.ts +18 -0
  61. package/dist/errors/use-json-fetcher.d.ts.map +1 -0
  62. package/dist/pwa/build/build-worker.d.ts +41 -0
  63. package/dist/pwa/build/build-worker.d.ts.map +1 -0
  64. package/dist/pwa/build/index.d.ts +9 -0
  65. package/dist/pwa/build/index.d.ts.map +1 -0
  66. package/dist/pwa/build/index.js +9 -0
  67. package/dist/pwa/build/index.js.map +9 -0
  68. package/dist/pwa/build/precache.d.ts +17 -0
  69. package/dist/pwa/build/precache.d.ts.map +1 -0
  70. package/dist/pwa/index.js +34 -134
  71. package/dist/pwa/index.js.map +4 -8
  72. package/dist/pwa/react/index.js +1 -1
  73. package/dist/pwa/worker/index.d.ts +9 -0
  74. package/dist/pwa/worker/index.d.ts.map +1 -0
  75. package/dist/pwa/worker/index.js +144 -0
  76. package/dist/pwa/worker/index.js.map +12 -0
  77. package/dist/pwa/worker/injected.d.ts +15 -0
  78. package/dist/pwa/worker/injected.d.ts.map +1 -0
  79. package/dist/pwa/worker/plan.d.ts +65 -0
  80. package/dist/pwa/worker/plan.d.ts.map +1 -0
  81. package/dist/pwa/worker/scope.d.ts +18 -0
  82. package/dist/pwa/worker/scope.d.ts.map +1 -0
  83. package/dist/pwa/worker/start-worker.d.ts +17 -0
  84. package/dist/pwa/worker/start-worker.d.ts.map +1 -0
  85. package/dist/vite/index.d.ts +10 -0
  86. package/dist/vite/index.d.ts.map +1 -1
  87. package/dist/vite/index.js +49 -3
  88. package/dist/vite/index.js.map +6 -5
  89. package/dist/vite/runtime.d.ts +1 -1
  90. package/dist/vite/runtime.d.ts.map +1 -1
  91. package/dist/vite/worker.d.ts +16 -0
  92. package/dist/vite/worker.d.ts.map +1 -0
  93. package/docs/README.md +3 -2
  94. package/docs/guide/errors.md +838 -0
  95. package/docs/guide/how-it-works.md +7 -1
  96. package/docs/guide/pwa.md +372 -211
  97. package/docs/roadmap.md +38 -5
  98. package/docs/troubleshooting.md +689 -3
  99. package/package.json +56 -4
  100. package/dist/chunks/index-zqa30gr7.js.map +0 -12
@@ -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, on a machine where a node is installed. The full message under
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.