@alxia/react-router 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +370 -20
  2. package/dist/chunks/index-5rt6hnhn.js +42 -0
  3. package/dist/chunks/index-5rt6hnhn.js.map +11 -0
  4. package/dist/chunks/{index-mppz0806.js → index-6jf7zpxz.js} +5 -5
  5. package/dist/chunks/{index-mppz0806.js.map → index-6jf7zpxz.js.map} +1 -1
  6. package/dist/chunks/index-y6vpnjr3.js +123 -0
  7. package/dist/chunks/index-y6vpnjr3.js.map +14 -0
  8. package/dist/context.d.ts +0 -7
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/document/index.d.ts +11 -0
  11. package/dist/document/index.d.ts.map +1 -0
  12. package/dist/document/index.js +19 -0
  13. package/dist/document/index.js.map +9 -0
  14. package/dist/document/inline-script.d.ts +27 -0
  15. package/dist/document/inline-script.d.ts.map +1 -0
  16. package/dist/document/language-store.d.ts +8 -0
  17. package/dist/document/language-store.d.ts.map +1 -0
  18. package/dist/document/nonce.d.ts +34 -0
  19. package/dist/document/nonce.d.ts.map +1 -0
  20. package/dist/document/page-language.d.ts +39 -0
  21. package/dist/document/page-language.d.ts.map +1 -0
  22. package/dist/document/text-direction.d.ts +10 -0
  23. package/dist/document/text-direction.d.ts.map +1 -0
  24. package/dist/entry/abort.d.ts +21 -0
  25. package/dist/entry/abort.d.ts.map +1 -0
  26. package/dist/entry/create-entry.d.ts +32 -0
  27. package/dist/entry/create-entry.d.ts.map +1 -0
  28. package/dist/entry/index.d.ts +8 -0
  29. package/dist/entry/index.d.ts.map +1 -0
  30. package/dist/entry/index.js +128 -0
  31. package/dist/entry/index.js.map +12 -0
  32. package/dist/entry/whole-page.d.ts +10 -0
  33. package/dist/entry/whole-page.d.ts.map +1 -0
  34. package/dist/errors/api-error.d.ts +86 -0
  35. package/dist/errors/api-error.d.ts.map +1 -0
  36. package/dist/errors/api-errors.d.ts +96 -0
  37. package/dist/errors/api-errors.d.ts.map +1 -0
  38. package/dist/errors/call-failure.d.ts +45 -0
  39. package/dist/errors/call-failure.d.ts.map +1 -0
  40. package/dist/errors/catalogue-check.d.ts +24 -0
  41. package/dist/errors/catalogue-check.d.ts.map +1 -0
  42. package/dist/errors/catalogue-read.d.ts +33 -0
  43. package/dist/errors/catalogue-read.d.ts.map +1 -0
  44. package/dist/errors/catalogue.d.ts +59 -0
  45. package/dist/errors/catalogue.d.ts.map +1 -0
  46. package/dist/errors/code-resolver.d.ts +44 -0
  47. package/dist/errors/code-resolver.d.ts.map +1 -0
  48. package/dist/errors/describe-refusal.d.ts +6 -0
  49. package/dist/errors/describe-refusal.d.ts.map +1 -0
  50. package/dist/errors/describe.d.ts +3 -0
  51. package/dist/errors/describe.d.ts.map +1 -0
  52. package/dist/errors/fetcher-result.d.ts +21 -0
  53. package/dist/errors/fetcher-result.d.ts.map +1 -0
  54. package/dist/errors/form-errors.d.ts +8 -0
  55. package/dist/errors/form-errors.d.ts.map +1 -0
  56. package/dist/errors/index.d.ts +15 -0
  57. package/dist/errors/index.d.ts.map +1 -0
  58. package/dist/errors/index.js +602 -0
  59. package/dist/errors/index.js.map +29 -0
  60. package/dist/errors/json-actions.d.ts +38 -0
  61. package/dist/errors/json-actions.d.ts.map +1 -0
  62. package/dist/errors/log.d.ts +8 -0
  63. package/dist/errors/log.d.ts.map +1 -0
  64. package/dist/errors/message-values.d.ts +11 -0
  65. package/dist/errors/message-values.d.ts.map +1 -0
  66. package/dist/errors/messages.d.ts +63 -0
  67. package/dist/errors/messages.d.ts.map +1 -0
  68. package/dist/errors/page-path.d.ts +9 -0
  69. package/dist/errors/page-path.d.ts.map +1 -0
  70. package/dist/errors/provider.d.ts +20 -0
  71. package/dist/errors/provider.d.ts.map +1 -0
  72. package/dist/errors/request-id.d.ts +5 -0
  73. package/dist/errors/request-id.d.ts.map +1 -0
  74. package/dist/errors/route-error.d.ts +31 -0
  75. package/dist/errors/route-error.d.ts.map +1 -0
  76. package/dist/errors/standard-schema.d.ts +34 -0
  77. package/dist/errors/standard-schema.d.ts.map +1 -0
  78. package/dist/errors/submit-form.d.ts +16 -0
  79. package/dist/errors/submit-form.d.ts.map +1 -0
  80. package/dist/errors/use-json-fetcher.d.ts +18 -0
  81. package/dist/errors/use-json-fetcher.d.ts.map +1 -0
  82. package/dist/index.js +13 -34
  83. package/dist/index.js.map +3 -5
  84. package/dist/pwa/build/index.js +1 -1
  85. package/dist/pwa/index.js +6 -6
  86. package/dist/pwa/index.js.map +1 -1
  87. package/dist/vite/index.d.ts.map +1 -1
  88. package/dist/vite/index.js +29 -2
  89. package/dist/vite/index.js.map +5 -4
  90. package/dist/vite/inline.d.ts +18 -0
  91. package/dist/vite/inline.d.ts.map +1 -0
  92. package/docs/README.md +3 -1
  93. package/docs/guide/csp-nonce.md +35 -3
  94. package/docs/guide/document.md +310 -0
  95. package/docs/guide/errors.md +838 -0
  96. package/docs/roadmap.md +36 -1
  97. package/docs/troubleshooting.md +712 -1
  98. package/package.json +33 -3
@@ -26,6 +26,13 @@ a loader, a message React Router or the browser prints, or an error from
26
26
  - [`TypeError: reactRouter(): client is …, which is not a directory. …`](#typeerror-reactrouter-client-is--which-is-not-a-directory-)
27
27
  - [`TypeError: reactRouter(): … cannot be served at a path of its own name; rename it. …`](#typeerror-reactrouter--cannot-be-served-at-a-path-of-its-own-name-rename-it-)
28
28
  - [`Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'none'"`](#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-default-src-none)
29
+ - [`Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…'"`](#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-script-src-self-nonce-)
30
+ - [`A tree hydrated but some attributes of the server rendered HTML didn't match the client properties`](#a-tree-hydrated-but-some-attributes-of-the-server-rendered-html-didnt-match-the-client-properties)
31
+ - [`useNonce()` is undefined on the server](#usenonce-is-undefined-on-the-server)
32
+ - [`TypeError: InlineScript(): the code contains "</script" or "<!--", which ends or confuses the script element; escape it, such as "<\/script" inside a string`](#typeerror-inlinescript-the-code-contains-script-or----which-ends-or-confuses-the-script-element-escape-it-such-as-script-inside-a-string)
33
+ - [`TypeError: createPageLanguage(): the fallback … is not one of the languages; got ….`](#typeerror-createpagelanguage-the-fallback--is-not-one-of-the-languages-got-)
34
+ - [The error page shows in the fallback language](#the-error-page-shows-in-the-fallback-language)
35
+ - [`Error: You must render this element inside a <HydratedRouter> element`](#error-you-must-render-this-element-inside-a-hydratedrouter-element)
29
36
  - [`alxia-react-router: … already exists, and reveal leaves it as it is. Run alxia-react-router reveal --force to overwrite it.`](#alxia-react-router--already-exists-and-reveal-leaves-it-as-it-is-run-alxia-react-router-reveal---force-to-overwrite-it)
30
37
  - [`alxia-react-router: no vite.config.ts in …. Run reveal from the app's root, beside vite.config.ts.`](#alxia-react-router-no-viteconfigts-in--run-reveal-from-the-apps-root-beside-viteconfigts)
31
38
  - [`alxia-react-router: … gives alxia() an entry reveal cannot read. …`](#alxia-react-router--gives-alxia-an-entry-reveal-cannot-read-)
@@ -44,6 +51,7 @@ a loader, a message React Router or the browser prints, or an error from
44
51
  - [`TypeError: defineWorkerContract(): cacheVersion must be a non-empty name without '-', …`](#typeerror-defineworkercontract-cacheversion-must-be-a-non-empty-name-without---)
45
52
  - [`parseBrowserManifest(): not React Router's browser manifest …`](#parsebrowsermanifest-not-react-routers-browser-manifest-)
46
53
  - [`error: Cannot find package 'react' from '…/@alxia/react-router/dist/pwa/react/index.js'`](#error-cannot-find-package-react-from-alxiareact-routerdistpwareactindexjs)
54
+ - [`error: Cannot find package 'isbot' from '…/@alxia/react-router/dist/entry/index.js'`](#error-cannot-find-package-isbot-from-alxiareact-routerdistentryindexjs)
47
55
  - [`startWorker(): no precache list. …`](#startworker-no-precache-list-)
48
56
  - [`TypeError: startWorker(): contract is required: …`](#typeerror-startworker-contract-is-required-)
49
57
  - [`error: Could not resolve: "workbox-core". Maybe you need to "bun install"?`](#error-could-not-resolve-workbox-core-maybe-you-need-to-bun-install)
@@ -53,6 +61,20 @@ a loader, a message React Router or the browser prints, or an error from
53
61
  - [`buildWorker(): build/client/offline.html does not exist`](#buildworker-buildclientofflinehtml-does-not-exist)
54
62
  - [`buildWorker(): … is outside ….`](#buildworker--is-outside-)
55
63
  - [`buildWorker(): Bun.build failed:`](#buildworker-bunbuild-failed)
64
+ - [`Cross-site request refused`](#cross-site-request-refused)
65
+ - [`Expected a JSON body`](#expected-a-json-body)
66
+ - [`Malformed JSON body`](#malformed-json-body)
67
+ - [`The form sent an input the API would refuse`](#the-form-sent-an-input-the-api-would-refuse)
68
+ - [`alxia-react-router: the error catalogue (…) was not read`](#alxia-react-router-the-error-catalogue--was-not-read)
69
+ - [`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)
70
+ - [`alxia-react-router: the error catalogue was not warmed`](#alxia-react-router-the-error-catalogue-was-not-warmed)
71
+ - [`alxia-react-router: ttlMs threw, using retryMs`](#alxia-react-router-ttlms-threw-using-retryms)
72
+ - [`alxia-react-router: ttlMs is not a duration, using retryMs`](#alxia-react-router-ttlms-is-not-a-duration-using-retryms)
73
+ - [`TypeError: createErrorCatalogue(): needs at least one language.`](#typeerror-createerrorcatalogue-needs-at-least-one-language)
74
+ - [`<api>: <operation> (request <id>) answered <status> <code>`](#api-operation-request-id-answered-status-code)
75
+ - [`<api>: <operation> (request <id>) answered <status> without GraphQL`](#api-operation-request-id-answered-status-without-graphql)
76
+ - [`<api>: <operation> (request <id>) unreachable`, `timeout` or `invalid-response`](#api-operation-request-id-unreachable-timeout-or-invalid-response)
77
+ - [`<api>: <operation> (request <id>) aborted`](#api-operation-request-id-aborted)
56
78
 
57
79
  **Types**
58
80
 
@@ -87,6 +109,14 @@ a loader, a message React Router or the browser prints, or an error from
87
109
  - [An update waits an hour](#an-update-waits-an-hour)
88
110
  - [The worker cannot fetch another origin](#the-worker-cannot-fetch-another-origin)
89
111
  - [The manifest is in the wrong language](#the-manifest-is-in-the-wrong-language)
112
+ - [A form's own check shows the code (`todos.errors.…`) instead of words](#a-forms-own-check-shows-the-code-todoserrors-instead-of-words)
113
+ - [A message shows its `{braces}`](#a-message-shows-its-braces)
114
+ - [A refusal shows the API's raw message](#a-refusal-shows-the-apis-raw-message)
115
+ - [`apiRefusal` returns undefined](#apirefusal-returns-undefined)
116
+ - [The error page shows no request id](#the-error-page-shows-no-request-id)
117
+ - [An action shows the error page](#an-action-shows-the-error-page)
118
+ - [Behind a TLS proxy every action answers 403](#behind-a-tls-proxy-every-action-answers-403)
119
+ - [Right after start, refusals read in the API's words](#right-after-start-refusals-read-in-the-apis-words)
90
120
 
91
121
  ## Thrown or printed
92
122
 
@@ -516,6 +546,202 @@ or `contentSecurityPolicy: false`. Better than `'unsafe-inline'`: a nonce
516
546
  per request, with `nonce: true` and `nonceOf(loadContext)` in
517
547
  `entry.server.tsx` ([guide](guide/csp-nonce.md#a-csp-nonce)).
518
548
 
549
+ ### `Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…'"`
550
+
551
+ **When:** the root renders an `<InlineScript>` (a theme's boot script, say)
552
+ and the policy names a nonce.
553
+
554
+ **Why:** the script rendered with no `nonce` attribute, because
555
+ `useNonce()` found no nonce: the server render has no `<DocumentNonce>`
556
+ around the router (an entry of your own that does not provide it), so
557
+ `InlineScript` renders a bare `<script>`. The browser then refuses it, and
558
+ the page flashes the wrong theme.
559
+
560
+ **Fix:** wrap the router in the nonce `secureHeaders({ nonce: true })`
561
+ made, in `entry.server.tsx`, or pass one to the script:
562
+
563
+ ```tsx
564
+ // app/entry.server.tsx
565
+ import { nonceOf } from '@alxia/react-router';
566
+ import { DocumentNonce } from '@alxia/react-router/document';
567
+
568
+ return (
569
+ <DocumentNonce nonce={nonceOf(loadContext)}>
570
+ <ServerRouter context={routerContext} url={request.url} />
571
+ </DocumentNonce>
572
+ );
573
+ ```
574
+
575
+ or, for one script, `<InlineScript code={THEME_BOOT} nonce={nonce} />`.
576
+
577
+ Check the response: `curl -s -A 'Mozilla/5.0 Chrome/130' http://localhost:5173/ | grep -o '<script[^>]*>'`
578
+ should show `nonce="…"` on the boot script too
579
+ ([guide](guide/document.md#the-nonce-on-the-server)).
580
+
581
+ ### `A tree hydrated but some attributes of the server rendered HTML didn't match the client properties`
582
+
583
+ **When:** the page hydrates, and the warning names `<html>` with a `class`
584
+ or `style` the server did not render.
585
+
586
+ **Why:** a boot script (a theme's) sets `class` and `style` on `<html>`
587
+ before React hydrates, so the DOM no longer equals the server's HTML.
588
+
589
+ **Fix:** keep `suppressHydrationWarning` on `<html>`. It silences that
590
+ element's own attributes only, not its children:
591
+
592
+ ```tsx
593
+ // app/root.tsx
594
+ <html lang="en" suppressHydrationWarning>
595
+ <head>
596
+ <InlineScript code={THEME_BOOT} />
597
+ </head>
598
+ <body>{children}</body>
599
+ </html>
600
+ ```
601
+
602
+ A warning on another element is a real mismatch: a `nonce` the browser hid
603
+ is read by `useNonce()` from the document, so never pass it from loader
604
+ data ([guide](guide/document.md#the-theme-boot-script)).
605
+
606
+ ### `useNonce()` is undefined on the server
607
+
608
+ **When:** the page's inline scripts or `<CspNonceMeta />` render with no
609
+ `nonce` attribute although `secureHeaders({ nonce: true })` set one, and
610
+ `useNonce()` returns `undefined` in the root.
611
+
612
+ **Why:** the server render has no `<DocumentNonce>` around the router. The
613
+ entry of `@alxia/react-router/entry` adds it; an entry of your own, such
614
+ as React Router's revealed one, does not, and `ServerRouter`'s own `nonce`
615
+ prop only reaches the scripts React Router writes.
616
+
617
+ **Fix:** re-export the package's entry, or wrap the router in your own:
618
+
619
+ ```tsx
620
+ // app/entry.server.tsx
621
+ export {
622
+ handleRequest as default,
623
+ streamTimeout,
624
+ } from '@alxia/react-router/entry';
625
+ ```
626
+
627
+ ```tsx
628
+ // An entry of your own
629
+ import { nonceOf } from '@alxia/react-router';
630
+ import { DocumentNonce } from '@alxia/react-router/document';
631
+
632
+ <DocumentNonce nonce={nonceOf(loadContext)}>
633
+ <ServerRouter context={routerContext} url={request.url} />
634
+ </DocumentNonce>;
635
+ ```
636
+
637
+ ([guide](guide/document.md#the-entry))
638
+
639
+ ### `TypeError: InlineScript(): the code contains "</script" or "<!--", which ends or confuses the script element; escape it, such as "<\/script" inside a string`
640
+
641
+ **When:** a page renders an `<InlineScript code={…} />` whose code holds
642
+ `</script` (in any case) or `<!--`.
643
+
644
+ **Why:** the code is written into the page unescaped. A `</script` ends the
645
+ element early, so what follows is HTML, not script, and can be markup an
646
+ attacker chose; a `<!--` makes the HTML parser treat the following
647
+ `<script` as part of the script's text. A nonce does not help: it only says
648
+ the script may run. The component refuses the code instead of rendering it.
649
+
650
+ **Fix:** keep the code a constant of the app and escape the sequence inside
651
+ a string. The code that reaches `InlineScript` must contain `<\/`, which the
652
+ browser's script reads as `</`, and which is written `"<\\/script"` in a
653
+ source string literal:
654
+
655
+ ```tsx
656
+ <InlineScript code={'document.title = "<\\/script>"'} />
657
+ ```
658
+
659
+ Never build the code from user input; pass such a value through a `data-`
660
+ attribute and read it from the script.
661
+ ([guide](guide/document.md#the-theme-boot-script))
662
+
663
+ ### `TypeError: createPageLanguage(): the fallback … is not one of the languages; got ….`
664
+
665
+ ```text
666
+ TypeError: createPageLanguage(): the fallback "de" is not one of the languages; got ["en","fr"].
667
+ ```
668
+
669
+ **When:** the module that calls `createPageLanguage` loads.
670
+
671
+ **Why:** `fallback` is the language of a page whose root data gives none, so
672
+ it must be one of `languages`. The usual cause is a fallback in another
673
+ spelling (`en-US` for `en`), or a `languages` list built from a different
674
+ source than the fallback.
675
+
676
+ **Fix:** name a language of the list:
677
+
678
+ ```ts
679
+ createPageLanguage({ languages: ['en', 'fr'], fallback: 'en' });
680
+ ```
681
+
682
+ ### The error page shows in the fallback language
683
+
684
+ **When:** on an error document, the root's `<html lang>` or text shows the
685
+ fallback language although the visitor uses another.
686
+
687
+ **Why:** a child route's error keeps the root's loader data. React Router
688
+ keeps `loaderData` for every loader that succeeded, so
689
+ `useRouteLoaderData('root')` still has it in `Layout` and `ErrorBoundary`.
690
+ The data is missing in only two cases:
691
+
692
+ - the URL matches no route, which is a 404 that runs no loader at all, the
693
+ root's included;
694
+ - the root's own loader threw.
695
+
696
+ With no data, `pageLanguage(undefined)` has no language to read. In the
697
+ browser it keeps the last language it saw and the one picked with
698
+ `chooseLanguage`; a document the server renders as an error has seen
699
+ neither, so the fallback shows.
700
+
701
+ **Fix, 404:** add a splat route whose loader throws a 404, so the URL
702
+ matches and the root loader runs:
703
+
704
+ ```ts
705
+ // app/routes.ts: add it last in your route config
706
+ import { route } from '@react-router/dev/routes';
707
+
708
+ route('*', 'routes/not-found.tsx');
709
+ ```
710
+
711
+ ```ts
712
+ // app/routes/not-found.tsx
713
+ import { data } from 'react-router';
714
+
715
+ export const loader = () => {
716
+ throw data(null, { status: 404 });
717
+ };
718
+ ```
719
+
720
+ **Fix, root loader:** keep the root loader to `{ language: alxia.language }`,
721
+ with nothing fallible in it. Load fallible data in child routes, or catch it
722
+ and return it as a field.
723
+
724
+ **Fix, client:** none is needed: a failed client navigation keeps the last
725
+ language through `pageLanguage`.
726
+
727
+ ### `Error: You must render this element inside a <HydratedRouter> element`
728
+
729
+ **When:** under `react-router dev` or after `react-router build`, every page
730
+ answers "Unexpected Server Error" with this message, once
731
+ `entry.server.tsx` re-exports `@alxia/react-router/entry`.
732
+
733
+ **Why:** the server holds two copies of `react-router`: the one that
734
+ renders `ServerRouter` in the entry, and the one `<Links>` and `<Scripts>`
735
+ in the root read. They share no context. `alxia()` from
736
+ `@alxia/react-router/vite` keeps them to one (it inlines `/entry` and
737
+ `/document`, by an alias in dev and in the build, so they import the app's
738
+ `react-router`). The message means `@alxia/react-router` is listed in
739
+ `ssr.external` and `alxia()` is not in use.
740
+
741
+ **Fix:** add `alxia()` to `vite.config.ts` beside `reactRouter()`, or list
742
+ `@alxia/react-router` in `ssr.noExternal` and remove it from
743
+ `ssr.external`.
744
+
519
745
  ### `alxia-react-router: … already exists, and reveal leaves it as it is. Run alxia-react-router reveal --force to overwrite it.`
520
746
 
521
747
  ```text
@@ -888,6 +1114,30 @@ this message too.
888
1114
  `/pwa/react` from the page alone. The worker and a build script import
889
1115
  `@alxia/react-router/pwa`, which imports nothing.
890
1116
 
1117
+ ### `error: Cannot find package 'isbot' from '…/@alxia/react-router/dist/entry/index.js'`
1118
+
1119
+ **When:** a request with a user agent reaches `entry.server.tsx` re-exporting
1120
+ `@alxia/react-router/entry` (or a `createEntry()` with no `isBot` option).
1121
+
1122
+ ```text
1123
+ error: Cannot find package 'isbot' from '/app/node_modules/@alxia/react-router/dist/entry/index.js'
1124
+ ```
1125
+
1126
+ **Why:** with no `isBot` option, `/entry` asks `isbot` whether a request
1127
+ comes from a crawler, which then gets the whole page, and imports it on the
1128
+ first request that needs it. `isbot` and `react-dom` are optional peers of
1129
+ the package, so nothing installs them for you. React Router's templates
1130
+ install both; an app made another way may not.
1131
+
1132
+ **Fix:** add them where the server is built and run:
1133
+
1134
+ ```sh
1135
+ bun add isbot react-dom
1136
+ ```
1137
+
1138
+ Or give the `isBot` option, which replaces the check: `isbot` is then never
1139
+ imported, and only `react-dom` is needed.
1140
+
891
1141
  ### `startWorker(): no precache list. …`
892
1142
 
893
1143
  **Why:** the full message ends "Build this worker with `alxia()` from
@@ -1032,6 +1282,265 @@ peers, installed as
1032
1282
  [`Could not resolve: "workbox-core"`](#error-could-not-resolve-workbox-core-maybe-you-need-to-bun-install)
1033
1283
  shows.
1034
1284
 
1285
+ ### `Cross-site request refused`
1286
+
1287
+ **When:** an action reads its body with `readJson` or `readInput` (from
1288
+ `jsonActions()`), and the page's post answers 403 with this text.
1289
+
1290
+ **Why:** the request carried an `Origin` other than the app's own, or a
1291
+ `Sec-Fetch-Site` that is neither `same-origin` nor `none`. That is what a
1292
+ post from another site looks like, so it is refused before the body is read.
1293
+ The usual honest cause is that the app's origin is not the one the request
1294
+ reaches: see [Behind a TLS proxy every action answers 403](#behind-a-tls-proxy-every-action-answers-403).
1295
+
1296
+ **Fix:** if the post comes from another site, nothing: the refusal is the
1297
+ point. If it comes from your own page, give `jsonActions` the origin your
1298
+ users see:
1299
+
1300
+ ```ts
1301
+ export const { readJson } = jsonActions({
1302
+ origin: 'https://app.example.com',
1303
+ });
1304
+ ```
1305
+
1306
+ ### `Expected a JSON body`
1307
+
1308
+ **When:** an action answers 415 with this text.
1309
+
1310
+ **Why:** `readJson` takes only `application/json`. A native form post
1311
+ (`application/x-www-form-urlencoded` or `multipart/form-data`), a
1312
+ `text/plain` body and a request with no content type are refused, since only
1313
+ JSON needs a preflight from another site.
1314
+
1315
+ **Fix:** post from `useJsonFetcher`, which sets the content type
1316
+ (`encType: 'application/json'`), or set `Content-Type: application/json` on
1317
+ your own `fetch`. For an action that a native form also posts to before the
1318
+ page hydrates, call `requireJsonPost(request)` first: it sends that post back
1319
+ to its page, which then posts JSON.
1320
+
1321
+ ```ts
1322
+ const { requireJsonPost, readJson } = jsonActions();
1323
+
1324
+ export async function action({ request }: Route.ActionArgs) {
1325
+ requireJsonPost(request);
1326
+ const body = await readJson(request);
1327
+ // …
1328
+ }
1329
+ ```
1330
+
1331
+ ### `Malformed JSON body`
1332
+
1333
+ **When:** an action answers 400 with this text.
1334
+
1335
+ **Why:** the request declared `application/json`, but `request.json()` could
1336
+ not parse the body: it is empty, truncated, or not JSON at all (a form's
1337
+ `FormData` posted with the JSON content type, for example).
1338
+
1339
+ **Fix:** post an object through `useJsonFetcher`'s `post(body)`, or
1340
+ `JSON.stringify` the body of your own `fetch`. Do not set the content type by
1341
+ hand on a `FormData` body.
1342
+
1343
+ ### `The form sent an input the API would refuse`
1344
+
1345
+ **When:** an action that reads its body with `readInput(request, schema)`
1346
+ answers 400 with this text.
1347
+
1348
+ **Why:** the body was JSON, but its `input` field did not pass the schema: it
1349
+ is missing, or one of the checks failed. `readInput` reads `{ input }`, not
1350
+ the whole body, and it answers a bare 400 on purpose: the schema is the API's
1351
+ own input, so a form that passed its own checks never gets here.
1352
+
1353
+ **Fix:** post `{ input: … }`, and make the form's checks the schema's. To see
1354
+ what the schema refused while you debug, call it yourself:
1355
+
1356
+ ```ts
1357
+ const body = (await readJson(request)) as { input?: unknown };
1358
+ const result = await schema['~standard'].validate(body.input);
1359
+ console.log(result.issues);
1360
+ ```
1361
+
1362
+ ### `alxia-react-router: the error catalogue (…) was not read`
1363
+
1364
+ **When:** the server logs this line (with the error after it, at the level of
1365
+ `log.error`) while a language's catalogue is read for the first time, and the
1366
+ refusals in that language read in the API's own words.
1367
+
1368
+ **Why:** the app's `read(language)` threw: the API was down, slow past its own
1369
+ timeout, or answered an error. The catalogue keeps no partial result. It tries
1370
+ again once `retryMs` (5 seconds by default) has passed since the failure, on
1371
+ the next request that asks for that language; one read is in flight at a time.
1372
+
1373
+ **Fix:** read the error that follows the line. Once the API answers, the next
1374
+ request after the delay reads the catalogue and the refusals are worded; no
1375
+ restart is needed. To see the calls, give `createErrorCatalogue` a `log`
1376
+ that records them.
1377
+
1378
+ ### `alxia-react-router: the error catalogue was not checked, keeping what is held`
1379
+
1380
+ **When:** the server logs this line (with the error after it) once a held
1381
+ catalogue is older than the TTL and the check of the versions failed.
1382
+
1383
+ **Why:** `versions()` threw. The catalogue keeps serving the messages it
1384
+ holds, which may be stale, and asks again after `retryMs` instead of after a
1385
+ whole TTL.
1386
+
1387
+ **Fix:** read the error that follows. Nothing to restart: when the API
1388
+ answers, the check goes on and re-reads the languages whose version moved.
1389
+ If it repeats while the API is up, check that `versions()` returns a version
1390
+ for every language in `languages`: a language missing from its answer is read
1391
+ again at each check.
1392
+
1393
+ ### `alxia-react-router: the error catalogue was not warmed`
1394
+
1395
+ **When:** the server logs this line at startup, after `warm()`.
1396
+
1397
+ **Why:** `warm()` reads every language without holding the boot back, and
1398
+ the promise it started rejected. The reads themselves report their failures
1399
+ (`the error catalogue (…) was not read`), so this line means something else
1400
+ threw.
1401
+
1402
+ **Fix:** read the error that follows. The first request that asks for a
1403
+ language reads it anyway, so the server is not left without a catalogue.
1404
+
1405
+ ### `alxia-react-router: ttlMs threw, using retryMs`
1406
+
1407
+ **When:** the server logs this line (with the error after it) each time the
1408
+ catalogue reads its TTL and the `ttlMs` getter throws.
1409
+
1410
+ **Why:** `ttlMs` is a getter of the app's, such as one that reads a setting,
1411
+ and it threw. The catalogue does not let it break a request: it uses
1412
+ `retryMs` as the TTL for that read, so the held messages are still served
1413
+ and the versions are checked again soon.
1414
+
1415
+ **Fix:** read the error that follows and fix the getter, or pass a number.
1416
+
1417
+ ```ts
1418
+ const catalogue = createErrorCatalogue({
1419
+ // …
1420
+ ttlMs: () => settings.catalogueTtlMs ?? 60_000,
1421
+ });
1422
+ ```
1423
+
1424
+ ### `alxia-react-router: ttlMs is not a duration, using retryMs`
1425
+
1426
+ **When:** the server logs this line each time the catalogue reads its TTL and
1427
+ `ttlMs` is not a finite number of at least 0: `NaN`, `Infinity`, a negative
1428
+ number, or a value that is not a number, such as the string a setting
1429
+ returned.
1430
+
1431
+ **Why:** a TTL that is not a duration would never expire a held catalogue
1432
+ (`Infinity`) or make every comparison false (`NaN`). The catalogue treats it
1433
+ like a getter that threw: it uses `retryMs` for that read.
1434
+
1435
+ **Fix:** pass a number of milliseconds, converted where the setting is read:
1436
+
1437
+ ```ts
1438
+ const catalogue = createErrorCatalogue({
1439
+ // …
1440
+ ttlMs: () => Number(process.env.CATALOGUE_TTL_MS ?? 60_000),
1441
+ });
1442
+ ```
1443
+
1444
+ ### `TypeError: createErrorCatalogue(): needs at least one language.`
1445
+
1446
+ **When:** the server starts, or the module that calls `createErrorCatalogue`
1447
+ is imported.
1448
+
1449
+ **Why:** `languages` is empty, usually a list built from an environment
1450
+ variable or a config that was not set.
1451
+
1452
+ **Fix:** pass the languages the app serves, the same list the page's
1453
+ translator knows:
1454
+
1455
+ ```ts
1456
+ const catalogue = createErrorCatalogue({
1457
+ messages: apiMessages,
1458
+ languages: ['en', 'fr'],
1459
+ // …
1460
+ });
1461
+ ```
1462
+
1463
+ ### `<api>: <operation> (request <id>) answered <status> <code>`
1464
+
1465
+ Logged by `apiCallFailure` (a warning for a 4xx, an error for a 5xx), after the
1466
+ `api` the app gave it, when the
1467
+ API refused a call with a GraphQL body, or answered a status that is passed
1468
+ through, such as a 404, which has no `<code>`. For example
1469
+ `my-api https://api.example.com/graphql: Todos (request r-1) answered 403 todos.errors.forbidden`.
1470
+
1471
+ **Why:** the API answered, and refused: `<status>` and `<code>` are the
1472
+ `ApiError`'s. A 4xx is expected (a forbidden page, a missing record), which is
1473
+ why it is a warning. The `(request …)` part is there only if the API sent an
1474
+ `x-request-id` and your code passed it as `requestId`; without it the line
1475
+ carries no id and an error page can show none.
1476
+
1477
+ **Fix:** nothing to fix in the package. To find the API's own log line, search
1478
+ for the id. To get the id in the line, pass `requestId: requestIdOf(response)`
1479
+ from the `fetch` the client calls:
1480
+
1481
+ ```ts
1482
+ import { apiCallFailure, requestIdOf } from '@alxia/react-router/errors';
1483
+
1484
+ let requestId: string | undefined;
1485
+ const send = async (request: Request) => {
1486
+ const response = await fetch(request);
1487
+ requestId = requestIdOf(response);
1488
+ return response;
1489
+ };
1490
+
1491
+ export const failed = (error: unknown) =>
1492
+ apiCallFailure(error, { api: 'my-api', requestId });
1493
+ ```
1494
+
1495
+ ### `<api>: <operation> (request <id>) answered <status> without GraphQL`
1496
+
1497
+ Logged as an error by `apiCallFailure`, and the loader gets a 503 `Response`
1498
+ with no body (the reason is in the log).
1499
+
1500
+ **Why:** the API, or a gateway in front of it, answered a status that is not
1501
+ 401, 403 or 404 with a body that is not GraphQL (an HTML 502 page, a 429
1502
+ from a rate limiter). The log line's second argument is that body.
1503
+
1504
+ **Fix:** read the body in the log and fix the gateway. If a status such as 429
1505
+ should reach the page as its own, name it:
1506
+
1507
+ ```ts
1508
+ import { apiCallFailure } from '@alxia/react-router/errors';
1509
+
1510
+ export const failed = (error: unknown) =>
1511
+ apiCallFailure(error, {
1512
+ api: 'my-api',
1513
+ passedThrough: new Set([401, 403, 404, 429]),
1514
+ });
1515
+ ```
1516
+
1517
+ ### `<api>: <operation> (request <id>) unreachable`, `timeout` or `invalid-response`
1518
+
1519
+ Logged as an error by `apiCallFailure`, and the loader gets a 503 `Response`
1520
+ with no body (the reason is in the log line).
1521
+
1522
+ **Why:** `@nxgt/graphql-client` could not get an answer: `unreachable` is a
1523
+ refused connection or a DNS failure, `timeout` is a call past the deadline the
1524
+ app gave it, and `invalid-response` is an answer that is no GraphQL response
1525
+ (a 2xx with no `data`, garbled JSON). The log line's second argument is the
1526
+ cause.
1527
+
1528
+ **Fix:** check the API's address and that it is up. For a `timeout`, the call's
1529
+ `timeout` option is the app's, not the package's. The 503 reaches the error
1530
+ page through `routeError`, which words it with the request id when there is
1531
+ one.
1532
+
1533
+ ### `<api>: <operation> (request <id>) aborted`
1534
+
1535
+ Logged as an error by `apiCallFailure`, and the loader gets a 503 `Response`.
1536
+
1537
+ **Why:** the `signal` you passed was aborted, so the visitor closed the tab or
1538
+ navigated away, and the call failed with whatever error that produced. Nobody
1539
+ sees the 503.
1540
+
1541
+ **Fix:** none. To keep it out of the logs, test `request.signal.aborted` in
1542
+ your own `catch` before calling `apiCallFailure`.
1543
+
1035
1544
  ## Types
1036
1545
 
1037
1546
  ### `Property 'alxia' does not exist on type 'Readonly<RouterContextProvider>'`
@@ -1209,7 +1718,9 @@ error TS2375: Type '{ context: EntryContext; url: string; nonce: string | undefi
1209
1718
  `ServerRouterProps.nonce` is optional without `undefined`. React's
1210
1719
  `nonce` option takes `undefined`, so only the prop complains.
1211
1720
 
1212
- **Fix:** spread the prop in only when there is a nonce:
1721
+ **Fix:** use `@alxia/react-router/entry`, whose `handleRequest` does it
1722
+ ([guide](guide/document.md#the-entry)), or spread the prop in only when
1723
+ there is a nonce, in an entry of your own:
1213
1724
 
1214
1725
  ```tsx
1215
1726
  const nonce = nonceOf(loadContext);
@@ -1579,3 +2090,203 @@ cookie never reaches the server. Only the link's query does.
1579
2090
  read it with `manifestLanguage(url, supported, fallback)`; the fallback is a
1580
2091
  supported language the app resolves from the request's `Accept-Language`, not
1581
2092
  from the cookie ([the manifest route](guide/pwa.md#the-web-app-manifest-and-why-the-language-travels-in-lang)).
2093
+
2094
+ ### A form's own check shows the code (`todos.errors.…`) instead of words
2095
+
2096
+ A refinement with `params: { code: 'todos.errors.title-taken' }` shows the
2097
+ code itself, or the form library's generic text, where the API's words should
2098
+ be.
2099
+
2100
+ **Why:** `apiCodeResolver` returns the code unchanged when its `messages`
2101
+ store has no text for it in the page's language, and the form library then
2102
+ words the issue itself. In the browser the store holds only what the root's
2103
+ data brought: the code is not in that list (the app's `clientCodes`), or
2104
+ `ApiMessagesProvider` is not around the routes, or it was given another store
2105
+ than the resolver's.
2106
+
2107
+ **Fix:** add the code to the few the root sends, and wrap the routes with the
2108
+ provider on the same instance the resolver reads:
2109
+
2110
+ ```tsx
2111
+ <ApiMessagesProvider messages={apiMessages} value={loaderData.apiMessages}>
2112
+ <Outlet />
2113
+ </ApiMessagesProvider>
2114
+ ```
2115
+
2116
+ Check it with `apiMessages.messageOf('fr', code)` in the browser's console
2117
+ or a spec ([the catalogue store](guide/errors.md#the-catalogue-store)).
2118
+
2119
+ ### A message shows its `{braces}`
2120
+
2121
+ A refusal reads `{count, plural, one {One item} other {# items}} outside {name}.`
2122
+ or `Hello {name}`, braces and all.
2123
+
2124
+ **Why:** the `format` given to `createApiMessages` is not an ICU formatter
2125
+ (an identity function, a plain `replace`), or a value the message names is
2126
+ missing and the formatter handed back the raw message. `apiMessage` returns
2127
+ `undefined` for a message the formatter reported to `onError`, but a
2128
+ formatter that never calls it returns the text as it found it.
2129
+
2130
+ **Fix:** pass the app's ICU formatter, such as `@nxgt/material`'s
2131
+ `formatMessage`, and make it call `onError` for a message it cannot format:
2132
+
2133
+ ```ts
2134
+ const apiMessages = createApiMessages<'en' | 'fr'>({ format: formatMessage });
2135
+ ```
2136
+
2137
+ A value is missing when the API's `extensions.params` lacks an argument the
2138
+ message names; the same call in a spec shows which.
2139
+
2140
+ ### A refusal shows the API's raw message
2141
+
2142
+ A page shows `Variable "$email" got invalid value …` or another message the
2143
+ API worded for developers, instead of the app's words.
2144
+
2145
+ **Why:** the error has no API code (a raw `BAD_USER_INPUT`, from a check that
2146
+ failed before the API's validation layer), or a code that `isApiCode`
2147
+ rejects. Without `refused`, `createApiErrors` says the API's own message for
2148
+ such an error.
2149
+
2150
+ **Fix:** pass `refused`, the app's generic refusal; it replaces the raw
2151
+ message for every error that is not an API code
2152
+ ([wording a refusal](guide/errors.md#wording-a-refusal)):
2153
+
2154
+ ```ts
2155
+ export const apiErrors = createApiErrors<'en' | 'fr'>({
2156
+ messages: apiMessages,
2157
+ unavailable: (language) => translate('app.error.unavailable', language),
2158
+ refused: (language) => translate('app.error.refused', language),
2159
+ });
2160
+ ```
2161
+
2162
+ ### `apiRefusal` returns undefined
2163
+
2164
+ `apiErrors.apiRefusal(error, language)` gives `undefined` for a call that
2165
+ failed, and the action returns nothing useful.
2166
+
2167
+ **Why:** the error is neither an API error (`isApiErrorLike`) nor a 503
2168
+ `Response`: a network failure, a `TypeError` of the app's own code, a 404.
2169
+ `apiRefusal` words only what the API refused or could not be reached for.
2170
+
2171
+ **Fix:** rethrow what it does not word, so the route's error boundary shows
2172
+ it:
2173
+
2174
+ ```ts
2175
+ const refusal = apiErrors.apiRefusal(error, 'en');
2176
+ if (!refusal) throw error;
2177
+ ```
2178
+
2179
+ ### The error page shows no request id
2180
+
2181
+ The root's `ErrorBoundary` calls `errorRequestId(error)` and gets `undefined`,
2182
+ though the API logged the request.
2183
+
2184
+ **Why:** `routeError` carries the `requestId` the error already has; it never
2185
+ reads it from the network. An `ApiError` has none unless the code that called
2186
+ the API set it, and a thrown `Response` is turned into a route error only when
2187
+ it has the `x-request-id` header. A loader that returns `routeError(error)`
2188
+ instead of throwing it never reaches the boundary at all.
2189
+
2190
+ **Fix:** keep the id on the error where the API is called, and `throw` what
2191
+ `routeError` gives back
2192
+ ([loaders and the error page](guide/errors.md#loaders-and-the-error-page)):
2193
+
2194
+ ```ts
2195
+ import { requestIdOf, routeError } from '@alxia/react-router/errors';
2196
+
2197
+ // Your own call to the API, declared here as a stub.
2198
+ declare function call(): Promise<Response>;
2199
+
2200
+ export async function loader() {
2201
+ const response = await call();
2202
+ if (!response.ok) {
2203
+ // A Response of 401, 403, 404 or 503 with the header is a route error.
2204
+ throw routeError(response);
2205
+ }
2206
+ return { requestId: requestIdOf(response) };
2207
+ }
2208
+ ```
2209
+
2210
+ ### An action shows the error page
2211
+
2212
+ An action that calls `fetcherResult`, `fetcherRefusal` or `submitForm` ends on
2213
+ the route's error page instead of answering `{ ok: false }` or the form's
2214
+ errors.
2215
+
2216
+ **Why:** by design, these helpers answer only what the page can word. They
2217
+ throw `routeError(error)` for a 401 (the session code redirects to the sign-in
2218
+ first), for a failure that is not an API refusal (a network error, a bug of the
2219
+ app's own code), and, for `submitForm`, for a 404 that names none of the
2220
+ form's fields: the record itself is gone, and no field message could say so.
2221
+
2222
+ **Fix:** none is needed for a 401 or a real failure: the error page is the
2223
+ right answer. For a 404 that is about a field, name it in `fieldOf`
2224
+ (`(path) => (path === 'todo.id' ? 'id' : undefined)`) so it sits under that
2225
+ field. For a refusal you would rather word yourself, build on `apiRefusal`,
2226
+ which returns `undefined` instead of throwing:
2227
+
2228
+ ```ts
2229
+ const refusal = apiErrors.apiRefusal(error, 'en');
2230
+ if (!refusal) throw error;
2231
+ return Response.json(
2232
+ { ok: false, error: refusal.message },
2233
+ { status: refusal.status },
2234
+ );
2235
+ ```
2236
+
2237
+ ### Behind a TLS proxy every action answers 403
2238
+
2239
+ Every action that reads JSON answers 403 `Cross-site request refused`
2240
+ in production, yet works under `react-router dev` and in the specs.
2241
+
2242
+ **Why:** `jsonActions()` compares the browser's `Origin` (`https://app.example.com`)
2243
+ with the origin of `request.url`. Behind a proxy that terminates TLS the
2244
+ app is asked over plain HTTP on an internal host (`http://10.0.0.5:3000`),
2245
+ so no post matches. See also
2246
+ [`request.url` in a loader is `http://`](#requesturl-in-a-loader-is-http-and-the-internal-host-behind-a-proxy).
2247
+
2248
+ **Fix:** name the proxy with `createServer({ proxy: trustProxy(…) })`
2249
+ (`trustProxy` is `@alxia/core`'s; see
2250
+ [Behind a proxy](guide/customising-the-server.md#behind-a-proxy)). The
2251
+ catch-all hands React Router the URL `originalUrl` reads from the trusted
2252
+ proxy's `X-Forwarded-Proto` and `X-Forwarded-Host`, so an action's
2253
+ `request.url` already carries the public origin and `jsonActions()` needs no
2254
+ `origin`. Without a trusted proxy, or when the proxy does not send those
2255
+ headers, pass the public origin, as a string or as a getter read at each
2256
+ request (so a value set at startup is found):
2257
+
2258
+ ```ts
2259
+ import { jsonActions } from '@alxia/react-router/errors';
2260
+
2261
+ export const { readJson, requireJsonPost, readInput } = jsonActions({
2262
+ origin: () => process.env.APP_ORIGIN,
2263
+ });
2264
+ ```
2265
+
2266
+ When the getter returns `undefined`, the request's own origin is used, so
2267
+ development needs no setting. An empty string is not `undefined`: it is
2268
+ kept, and every request with an `Origin` is refused.
2269
+
2270
+ ### Right after start, refusals read in the API's words
2271
+
2272
+ For the first seconds after the server starts, a refusal shows the message
2273
+ the API worded (in its default language, say) instead of the page's language.
2274
+ It reads right a few seconds later.
2275
+
2276
+ **Why:** a language's catalogue is read on first use, and a request waits for
2277
+ it only up to `firstReadWaitMs` (1.5 seconds by default), counted from when the
2278
+ read started. A slow API, or a failed read, lets the request go on with the
2279
+ API's own words; the read carries on in the background (or is retried after
2280
+ `retryMs`), and the next request finds it held.
2281
+
2282
+ **Fix:** read both languages at startup, so the first request finds them held.
2283
+ In `server.ts`:
2284
+
2285
+ ```ts
2286
+ import { catalogue } from './catalogue.server';
2287
+
2288
+ catalogue.warm();
2289
+ ```
2290
+
2291
+ Raise `firstReadWaitMs` only if the page should wait longer for the first
2292
+ words; it holds every cold request that long when the API hangs.