@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
@@ -0,0 +1,310 @@
1
+ # The document
2
+
3
+ This page covers what the root route renders for the page's CSP nonce, from
4
+ `@alxia/react-router/document`: the nonce on the server and in the browser,
5
+ an inline script that carries it, the theme-boot recipe, and the page's
6
+ language and text direction.
7
+
8
+ `/document` is safe to import in the browser and imports only `react`.
9
+
10
+ | export | |
11
+ | --- | --- |
12
+ | `DocumentNonce({ nonce, children })` | provides this response's nonce to the server render |
13
+ | `useNonce()` | the page's nonce, on the server and in the browser |
14
+ | `InlineScript({ code, nonce? })` | an inline `<script>` with the nonce; refuses `</script` and `<!--` |
15
+ | `CspNonceMeta()` | `<meta property="csp-nonce" nonce>` |
16
+ | `createPageLanguage({ languages, fallback })` | `pageLanguage`, `metaLanguage`, `chooseLanguage`, `useChosenLanguage` |
17
+ | `textDirection(language)` | `'ltr'` or `'rtl'` for `<html dir>` |
18
+
19
+ ## The nonce on the server
20
+
21
+ With `secureHeaders({ nonce: true })` the context carries a fresh nonce per
22
+ request ([A CSP nonce](csp-nonce.md)). The server render needs it in the
23
+ React tree too, so that the root, and every boundary that replaces it, can
24
+ read it with `useNonce()`. Wrap the router in `entry.server.tsx`:
25
+
26
+ ```tsx
27
+ // app/entry.server.tsx
28
+ import { nonceOf } from '@alxia/react-router';
29
+ import { DocumentNonce } from '@alxia/react-router/document';
30
+
31
+ const nonce = nonceOf(loadContext);
32
+
33
+ <DocumentNonce nonce={nonce}>
34
+ <ServerRouter context={routerContext} url={request.url} />
35
+ </DocumentNonce>;
36
+ ```
37
+
38
+ Do not put the nonce in a loader's data: the browser hides a nonce from the
39
+ page, and loader data would print it as text in the hydration script and in
40
+ every `.data` answer. An error page after an action's error runs no loader,
41
+ and still reads the nonce, since the provider is above the router.
42
+
43
+ ## The nonce in the browser
44
+
45
+ React Router's client knows no nonce, and React would find `nonce` on the
46
+ server's elements and none in its own: a hydration mismatch. So in the
47
+ browser `useNonce()` reads the `nonce` property of the first script or
48
+ `csp-nonce` meta in the document's `<head>` (the browser hides the
49
+ attribute, not the property), else of the first `script[nonce]` anywhere,
50
+ once, and caches it. `DocumentNonce` is not needed there. With none of them
51
+ it returns `undefined`.
52
+
53
+ "The browser" means a global `document`. A server that installs a DOM shim
54
+ on `globalThis` takes the browser path and finds no nonce, so it must not
55
+ render `useNonce()` that way (and passes `browser: () => false` to
56
+ `createPageLanguage`).
57
+
58
+ The first read is cached per module copy. A test with a DOM keeps it across
59
+ tests: reset modules between them, or render the server path with
60
+ `DocumentNonce`.
61
+
62
+ ## What React Router renders
63
+
64
+ With `@alxia/react-router/entry`, the root passes nothing. `<Links>`,
65
+ `<Scripts>` and `<ScrollRestoration>` fall back to the nonce `<ServerRouter
66
+ nonce>` put in React Router's framework context, so their `nonce` props are
67
+ optional:
68
+
69
+ ```tsx
70
+ // app/root.tsx
71
+ export function Layout({ children }: { children: React.ReactNode }) {
72
+ return (
73
+ <html lang="en">
74
+ <head>
75
+ <meta charSet="utf-8" />
76
+ <Links />
77
+ </head>
78
+ <body>
79
+ {children}
80
+ <ScrollRestoration />
81
+ <Scripts />
82
+ </body>
83
+ </html>
84
+ );
85
+ }
86
+ ```
87
+
88
+ Keep `useNonce()` for what React Router does not render: an `InlineScript`,
89
+ a `CspNonceMeta`, and any script or style the app inserts itself.
90
+
91
+ A page that carries a nonce is made for one response. A route's `headers`
92
+ export must never make it `public`: a shared cache would hand the same nonce
93
+ to every visitor.
94
+
95
+ ## The theme-boot script
96
+
97
+ A theme library stores the choice (light, dark) and applies it with a class
98
+ or an attribute on `<html>`. Applied after hydration, the page paints in the
99
+ wrong theme first. The cure is an inline, synchronous script in `<head>`,
100
+ which runs before the first paint. alxia ships the mechanism, not the
101
+ script: the code is the theme library's own boot script (for example a UI
102
+ kit's `themeBootScript()`), kept as a constant.
103
+
104
+ ```tsx
105
+ // app/root.tsx
106
+ import { CspNonceMeta, InlineScript } from '@alxia/react-router/document';
107
+ import { themeBootScript } from '<your theme library>';
108
+
109
+ const THEME_BOOT = themeBootScript();
110
+
111
+ export function Layout({ children }: { children: React.ReactNode }) {
112
+ return (
113
+ <html lang="en" suppressHydrationWarning>
114
+ <head>
115
+ <InlineScript code={THEME_BOOT} />
116
+ <meta charSet="utf-8" />
117
+ <CspNonceMeta />
118
+ <Links />
119
+ </head>
120
+ <body>{children}</body>
121
+ </html>
122
+ );
123
+ }
124
+ ```
125
+
126
+ - **Place it first in `<head>`**, before `<Links>`: an inline script is
127
+ synchronous and runs before the first paint, without waiting for a
128
+ stylesheet. Only a `<meta charSet>` may come before it.
129
+ - **Keep `suppressHydrationWarning` on `<html>`**: the script changes its
130
+ `class` and `style` before React hydrates, and React would warn that they
131
+ differ from the server's HTML. It silences that element's attributes only.
132
+ - **The nonce is the response's**: `script-src 'nonce-…' 'strict-dynamic'`
133
+ allows the script because it carries it. `InlineScript` uses `useNonce()`
134
+ on the server and the document's nonce in the browser, so the attribute
135
+ matches during hydration. `nonce` overrides it for one script.
136
+ - **With no nonce**, the script renders with no `nonce` attribute. The policy
137
+ must then allow it by `'unsafe-inline'` or a hash of the code.
138
+
139
+ > **Warning:** the code is written unescaped. It is a constant of the app,
140
+ > never user input. `InlineScript` throws a `TypeError` when it holds
141
+ > `</script` or `<!--`, which end or confuse the element
142
+ > ([the message](../troubleshooting.md#typeerror-inlinescript-the-code-contains-script-or----which-ends-or-confuses-the-script-element-escape-it-such-as-script-inside-a-string)):
143
+ > escape it in a string: the code must contain `<\/script`, written
144
+ > `"<\\/script"` in source.
145
+
146
+
147
+ `CspNonceMeta` renders `<meta property="csp-nonce" nonce>`, which UI
148
+ libraries read to give the scripts and styles they add at runtime the same
149
+ nonce.
150
+
151
+ ## The entry
152
+
153
+ `@alxia/react-router/entry` is `entry.server.tsx` for an app on Bun. It is
154
+ server only, and it renders with the web stream renderer
155
+ (`renderToReadableStream`), so it needs neither `@react-router/node` nor
156
+ `node:stream`. It imports `react`, `react-dom/server`, `react-router` and
157
+ `isbot` only when no `isBot` option is given (it is imported on the first bot
158
+ check); `react-dom` and `isbot` are optional peers, which React Router's
159
+ templates install.
160
+
161
+ ```tsx
162
+ // app/entry.server.tsx
163
+ export {
164
+ handleRequest as default,
165
+ streamTimeout,
166
+ } from '@alxia/react-router/entry';
167
+ ```
168
+
169
+ It does what React Router's revealed entry does:
170
+
171
+ - **Streams** the shell and the rest as it resolves.
172
+ - **Waits for the whole page** for a bot (`isbot`) and in SPA mode.
173
+ - **Answers HEAD** with the status and the headers, and no body. Under
174
+ alxia a HEAD reaches the entry as a GET (the core drops the body), so this
175
+ is for a server that is not alxia.
176
+ - **Aborts** the render `streamTimeout + 1 000` ms in, or as soon as the
177
+ client leaves (before the shell, or while a bot waits for the whole
178
+ page), and clears the timer when the body ends or the client goes away.
179
+ - **Rejects when the shell fails**, so React Router logs it once and answers
180
+ with its error boundary. Once the shell is out, an error goes to `onError`
181
+ and the status stays as it was sent.
182
+ - Sends `Content-Type: text/html`.
183
+
184
+ And it gives the router the response's nonce: `<ServerRouter nonce>`,
185
+ React's `nonce` option, and `<DocumentNonce>` around the router, so the
186
+ root reads it with `useNonce()`.
187
+
188
+ To change something, build the entry:
189
+
190
+ ```tsx
191
+ // app/entry.server.tsx
192
+ import { createEntry } from '@alxia/react-router/entry';
193
+
194
+ const entry = createEntry({
195
+ streamTimeout: 8_000,
196
+ // Default: nonceOf(loadContext)
197
+ nonce: (loadContext, request) => nonceFor(request),
198
+ // Default: isbot(userAgent)
199
+ isBot: (userAgent) => /bot|crawl|spider/i.test(userAgent),
200
+ // Default: console.error
201
+ onError: (error) => logger.error(error),
202
+ });
203
+
204
+ export const streamTimeout = entry.streamTimeout;
205
+ export default entry.handleRequest;
206
+ ```
207
+
208
+ `createEntry` returns an `Entry`, `{ handleRequest, streamTimeout }`.
209
+
210
+ | option | default | |
211
+ | --- | --- | --- |
212
+ | `streamTimeout` | `5_000` | ms React Router waits for a pending promise; export it as `streamTimeout` |
213
+ | `nonce` | `nonceOf(loadContext)` | this response's CSP nonce |
214
+ | `isBot` | `isbot`, imported when no `isBot` is given | whether a user agent gets the whole page |
215
+ | `onError` | `console.error` | an error after the shell is out |
216
+
217
+ An error inside a `<Suspense>` that fails during the first render pass, before the shell is out, turns the status into 500 and is not reported to `onError`, as in React Router's own entry; log it in the route's loader or `ErrorBoundary`.
218
+
219
+ The nonce is passed to `ServerRouter` only when there is one, which
220
+ satisfies `exactOptionalPropertyTypes`
221
+ ([the message](../troubleshooting.md#type---nonce-string--undefined--is-not-assignable-to-type-serverrouterprops-with-exactoptionalpropertytypes-true)).
222
+ Under `react-router dev`, keep `alxia()` in `vite.config.ts`: it keeps one
223
+ copy of `react-router` for the entry and the root
224
+ ([the message](../troubleshooting.md#error-you-must-render-this-element-inside-a-hydratedrouter-element)).
225
+
226
+ ## An entry of your own
227
+
228
+ An entry that is not the package's wraps `<ServerRouter>` in
229
+ `<DocumentNonce nonce={nonceOf(loadContext)}>` as shown
230
+ [above](#the-nonce-on-the-server). Without it `useNonce()` is `undefined`
231
+ on the server, and the page's inline scripts render with no nonce
232
+ ([the message](../troubleshooting.md#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-script-src-self-nonce-)).
233
+
234
+ ## The page's language
235
+
236
+ `createPageLanguage` gives the root the page's language for `<html lang>` and
237
+ a route's `meta`, and `textDirection` the `dir` that goes with it. The server
238
+ side, negotiating the request's language from a cookie and `Accept-Language`,
239
+ is [`@alxia/language`](https://github.com/softistx/alxia/blob/develop/packages/language/README.md):
240
+
241
+ ```ts
242
+ // app/server.ts
243
+ import { language } from '@alxia/language';
244
+ import { createServer } from '@alxia/react-router';
245
+
246
+ const server = createServer({
247
+ configure: (app) =>
248
+ app.use(language({ supported: ['en', 'fr', 'ar'], fallback: 'en', order: ['cookie', 'header'] })),
249
+ });
250
+ export default server;
251
+ declare module '@alxia/react-router' {
252
+ interface Register { server: typeof server }
253
+ }
254
+ ```
255
+
256
+ ```ts
257
+ // app/language.ts: one instance, imported by the root and the routes
258
+ import { createPageLanguage } from '@alxia/react-router/document';
259
+
260
+ export const { pageLanguage, metaLanguage, chooseLanguage, useChosenLanguage } =
261
+ createPageLanguage({ languages: ['en', 'fr', 'ar'], fallback: 'en' });
262
+ ```
263
+
264
+ ```tsx
265
+ // app/root.tsx
266
+ import { type AlxiaArgs, withAlxia } from '@alxia/react-router';
267
+ import { textDirection } from '@alxia/react-router/document';
268
+ import { useRouteLoaderData } from 'react-router';
269
+ import { metaLanguage, pageLanguage, useChosenLanguage } from './language';
270
+ import type { Route } from './+types/root';
271
+
272
+ const titles = { en: 'Home', fr: 'Accueil', ar: 'الرئيسية' };
273
+
274
+ // the root loader returns alxia.language, set by the middleware
275
+ export const loader = withAlxia(({ alxia }: Route.LoaderArgs & AlxiaArgs) => ({ language: alxia.language }));
276
+
277
+ export function Layout({ children }: { children: React.ReactNode }) {
278
+ const data = useRouteLoaderData<typeof loader>('root');
279
+ const language = pageLanguage(data?.language);
280
+ useChosenLanguage(); // re-renders the shell when the menu picks a language
281
+ return (
282
+ <html lang={language} dir={textDirection(language)} suppressHydrationWarning>
283
+ {/* … */}
284
+ </html>
285
+ );
286
+ }
287
+
288
+ export const meta: Route.MetaFunction = ({ matches }) => [
289
+ { title: titles[metaLanguage(matches)] },
290
+ ];
291
+ ```
292
+
293
+ - `pageLanguage(language)` is the supported language, else the fallback. In
294
+ the browser the one picked with `chooseLanguage(language)` wins, and the
295
+ last one seen stands in when the root's data is gone. On the server it
296
+ remembers nothing: one module serves every request. A server that
297
+ registers a DOM shim globally (happy-dom or jsdom) is read as a browser:
298
+ pass `browser: false` there.
299
+ - `metaLanguage(matches)` reads `matches[0].loaderData.language`.
300
+ - `useChosenLanguage()` is the picked language, or `undefined`; it
301
+ re-renders on a pick, and is `undefined` in the server render so
302
+ hydration matches.
303
+ - `textDirection(tag)` is `'rtl'` for `ar`, `fa`, `he`, `ur`, `ps`, `yi`,
304
+ `dv`, `ckb`, `sd` and `ug` (and the legacy `iw` and `ji`), else `'ltr'`, reading the base tag whatever its
305
+ case (`ar-EG`, `AR`). A script subtag is not read: `az-Arab` is `'ltr'`.
306
+ - A fallback outside `languages` throws
307
+ ([the message](../troubleshooting.md#typeerror-createpagelanguage-the-fallback--is-not-one-of-the-languages-got-)).
308
+ An error page keeps the root's data, so it keeps the language, except
309
+ for a URL that matches no route or a root loader that threw: there it
310
+ shows the last language seen in the browser, or the fallback ([why](../troubleshooting.md#the-error-page-shows-in-the-fallback-language)).