@alxia/react-router 0.8.1 → 0.10.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 (94) hide show
  1. package/README.md +222 -10
  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-9xv2a8et.js +108 -0
  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/pwa/build/build-worker.d.ts +41 -0
  15. package/dist/pwa/build/build-worker.d.ts.map +1 -0
  16. package/dist/pwa/build/index.d.ts +9 -0
  17. package/dist/pwa/build/index.d.ts.map +1 -0
  18. package/dist/pwa/build/index.js +9 -0
  19. package/dist/pwa/build/index.js.map +9 -0
  20. package/dist/pwa/build/precache.d.ts +17 -0
  21. package/dist/pwa/build/precache.d.ts.map +1 -0
  22. package/dist/pwa/index.d.ts +14 -0
  23. package/dist/pwa/index.d.ts.map +1 -0
  24. package/dist/pwa/index.js +65 -0
  25. package/dist/pwa/index.js.map +10 -0
  26. package/dist/pwa/model/caching.d.ts +46 -0
  27. package/dist/pwa/model/caching.d.ts.map +1 -0
  28. package/dist/pwa/model/clear-app-caches.d.ts +9 -0
  29. package/dist/pwa/model/clear-app-caches.d.ts.map +1 -0
  30. package/dist/pwa/model/manifest.d.ts +80 -0
  31. package/dist/pwa/model/manifest.d.ts.map +1 -0
  32. package/dist/pwa/model/precache-list.d.ts +40 -0
  33. package/dist/pwa/model/precache-list.d.ts.map +1 -0
  34. package/dist/pwa/model/registration.d.ts +39 -0
  35. package/dist/pwa/model/registration.d.ts.map +1 -0
  36. package/dist/pwa/model/start-service-worker.d.ts +24 -0
  37. package/dist/pwa/model/start-service-worker.d.ts.map +1 -0
  38. package/dist/pwa/model/storable.d.ts +8 -0
  39. package/dist/pwa/model/storable.d.ts.map +1 -0
  40. package/dist/pwa/model/update-notice.d.ts +38 -0
  41. package/dist/pwa/model/update-notice.d.ts.map +1 -0
  42. package/dist/pwa/model/worker-contract.d.ts +61 -0
  43. package/dist/pwa/model/worker-contract.d.ts.map +1 -0
  44. package/dist/pwa/react/index.d.ts +9 -0
  45. package/dist/pwa/react/index.d.ts.map +1 -0
  46. package/dist/pwa/react/index.js +75 -0
  47. package/dist/pwa/react/index.js.map +13 -0
  48. package/dist/pwa/react/use-online.d.ts +6 -0
  49. package/dist/pwa/react/use-online.d.ts.map +1 -0
  50. package/dist/pwa/react/use-service-worker.d.ts +33 -0
  51. package/dist/pwa/react/use-service-worker.d.ts.map +1 -0
  52. package/dist/pwa/react/use-update-notice.d.ts +14 -0
  53. package/dist/pwa/react/use-update-notice.d.ts.map +1 -0
  54. package/dist/pwa/server/index.d.ts +7 -0
  55. package/dist/pwa/server/index.d.ts.map +1 -0
  56. package/dist/pwa/server/index.js +18 -0
  57. package/dist/pwa/server/index.js.map +10 -0
  58. package/dist/pwa/server/no-cache.d.ts +22 -0
  59. package/dist/pwa/server/no-cache.d.ts.map +1 -0
  60. package/dist/pwa/worker/index.d.ts +9 -0
  61. package/dist/pwa/worker/index.d.ts.map +1 -0
  62. package/dist/pwa/worker/index.js +144 -0
  63. package/dist/pwa/worker/index.js.map +12 -0
  64. package/dist/pwa/worker/injected.d.ts +15 -0
  65. package/dist/pwa/worker/injected.d.ts.map +1 -0
  66. package/dist/pwa/worker/plan.d.ts +65 -0
  67. package/dist/pwa/worker/plan.d.ts.map +1 -0
  68. package/dist/pwa/worker/scope.d.ts +18 -0
  69. package/dist/pwa/worker/scope.d.ts.map +1 -0
  70. package/dist/pwa/worker/start-worker.d.ts +17 -0
  71. package/dist/pwa/worker/start-worker.d.ts.map +1 -0
  72. package/dist/vite/index.d.ts +10 -0
  73. package/dist/vite/index.d.ts.map +1 -1
  74. package/dist/vite/index.js +49 -3
  75. package/dist/vite/index.js.map +6 -5
  76. package/dist/vite/runtime.d.ts +1 -1
  77. package/dist/vite/runtime.d.ts.map +1 -1
  78. package/dist/vite/worker.d.ts +16 -0
  79. package/dist/vite/worker.d.ts.map +1 -0
  80. package/docs/README.md +12 -3
  81. package/docs/guide/beside-the-pages.md +178 -0
  82. package/docs/guide/csp-nonce.md +100 -0
  83. package/docs/guide/customising-the-server.md +292 -0
  84. package/docs/guide/deploying.md +99 -0
  85. package/docs/guide/getting-started.md +112 -0
  86. package/docs/guide/how-it-works.md +268 -0
  87. package/docs/guide/openapi.md +51 -0
  88. package/docs/guide/pwa.md +636 -0
  89. package/docs/guide/testing.md +42 -0
  90. package/docs/guide/typing-the-loaders.md +311 -0
  91. package/docs/guide.md +27 -1391
  92. package/docs/roadmap.md +39 -1
  93. package/docs/troubleshooting.md +402 -11
  94. package/package.json +70 -6
@@ -0,0 +1,178 @@
1
+ # Beside the pages
2
+
3
+ This page covers what runs around and beside the pages: the middlewares
4
+ before the catch-all, routes of the app's own, WebSockets in dev, in preview
5
+ and in the build, and the client's files.
6
+
7
+ ## Middlewares around the pages
8
+
9
+ Every middleware declared before the catch-all runs around each page, as around
10
+ any route, and `use` observers go first so they wrap everything:
11
+
12
+ - **`@alxia/logger`** writes one entry per request and sets
13
+ `x-request-id`. It times a streamed page by its first byte, since
14
+ the response is settled when its headers leave. Every request is logged,
15
+ 404s and errors included.
16
+ - **`@alxia/compress`** compresses documents and data, and flushes a
17
+ streamed page as React writes it: the shell and its `<Suspense>`
18
+ fallback still arrive first, in zstd, Brotli or gzip. See [the troubleshooting entry](../troubleshooting.md#a-streamed-page-arrives-in-one-piece).
19
+ - **`@alxia/secure-headers`**' default policy, `default-src 'none'`, blocks
20
+ every script of the page, React Router's inline ones included, and its
21
+ `form-action 'none'` blocks a `<Form>`'s post. Give the pages a policy of
22
+ their own: see [the troubleshooting entry](../troubleshooting.md#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-default-src-none).
23
+ With `nonce: true`, the scripts need no `'unsafe-inline'`:
24
+ [A CSP nonce](csp-nonce.md#a-csp-nonce).
25
+ - **A guard** — `@alxia/jwt`'s `bearer`, `@alxia/janus`' session — in
26
+ `configure` guards every page and its data alike; in `beforeAll`, the
27
+ client's files too. On the app, a guard also answers a request no route
28
+ matches with its 401.
29
+
30
+ ## Routes beside the pages
31
+
32
+ alxia's own routes answer their paths wherever they are declared:
33
+
34
+ ```ts
35
+ // app/server.ts
36
+ import { createServer } from '@alxia/react-router';
37
+
38
+ export default createServer({
39
+ configure: (app) =>
40
+ app
41
+ .get('/api/health', ({ reply }) => reply.ok({ ok: true }))
42
+ .get('/api/orders/:id', ({ params, reply }) => reply.ok({ id: params.id })),
43
+ });
44
+ ```
45
+
46
+ Each answers with its own middlewares and replies. The core ranks
47
+ paths as `Bun.serve` does, through `listen`, `app.fetch` and
48
+ `app.request` alike: segment by segment, a literal beats a parameter,
49
+ which beats the catch-all's wildcard. Two consequences:
50
+
51
+ - **The path is chosen before the method.** With only `POST /api/orders`,
52
+ a `GET /api/orders` is alxia's 405, not a page.
53
+ - **A route whose path covers pages takes them.** `GET /:slug` answers
54
+ every one-segment path, `/about` included, before React Router sees it.
55
+ Put alxia's routes under a prefix of their own, `/api`.
56
+
57
+ Pages and single-fetch data are not something a generated client calls:
58
+ leave the catch-all out of `matchesSpec`'s check with `isReactRouterRoute`
59
+ ([OpenAPI](openapi.md#openapi)); what a client does call, your `/api`, is what the
60
+ OpenAPI document declares.
61
+
62
+ What React Router answers comes back as it sent it: documents,
63
+ single-fetch data (`/_.data`, `/login.data`), lazy route discovery
64
+ (`/__manifest`), redirects with every `Set-Cookie` they carry, and the
65
+ error pages, a 404 or a 500 rendered by the app's `ErrorBoundary`.
66
+
67
+ ## WebSockets
68
+
69
+ A `ws` route declared in `configure` (or `beforeAll`) is a socket under
70
+ `react-router dev`, under `vite preview` and from
71
+ `bun build/server/index.js` alike. There is
72
+ nothing to add: no option, no package, no second server to start.
73
+
74
+ ```ts
75
+ // app/server.ts
76
+ import { createServer } from '@alxia/react-router';
77
+
78
+ export default createServer({
79
+ configure: (app) =>
80
+ app
81
+ .derive(({ request }) => {
82
+ const name = request.headers.get('x-user');
83
+ return { user: name === null ? null : { name } };
84
+ })
85
+ .ws('/api/echo', {}, {
86
+ open: (socket) => socket.send({ hello: socket.data.user?.name ?? 'anonymous' }),
87
+ message: (socket, message) => socket.send({ echo: String(message) }),
88
+ })
89
+ .group((guarded) =>
90
+ guarded
91
+ .derive(({ user, reply }) =>
92
+ user === null ? reply(401, { error: 'unauthenticated' as const }) : { user },
93
+ )
94
+ .ws('/api/rooms/:room', {}, {
95
+ open: (socket) => socket.subscribe(socket.data.params.room),
96
+ message: (socket, message) =>
97
+ socket.publish(socket.data.params.room, {
98
+ from: socket.data.user.name,
99
+ text: String(message),
100
+ }),
101
+ }),
102
+ ),
103
+ });
104
+ ```
105
+
106
+ The upgrade runs the middlewares declared before the route, and the route's
107
+ validation, as any request does. A middleware's reply refuses it: the client
108
+ gets the 401 and its body, and no socket opens. `socket.data` holds the
109
+ validated request and what each middleware derived, typed. See
110
+ [`@alxia/core`'s WebSockets](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/websockets.md)
111
+ for the schemas and the rest of the socket.
112
+
113
+ The guard sits in a `group` so it refuses the sockets alone: declared on
114
+ `app` itself, it would apply to every route after it, the pages'
115
+ catch-all included.
116
+
117
+ ### Under `react-router dev`
118
+
119
+ Vite's dev server is a `node:http` server, and an alxia socket is
120
+ `Bun.serve`'s upgrade. The plugin listens for Vite's `upgrade` event and
121
+ leaves Vite's own socket alone: an upgrade asking for the `vite-hmr` or
122
+ `vite-ping` subprotocol is Vite's, and HMR works as before. Any other
123
+ upgrade, except one Vite's `server.proxy` relays itself (an entry with
124
+ `ws: true` or a `ws:` target, matched as Vite matches it), is relayed,
125
+ byte for byte, to a `Bun.serve` of the app on a loopback port of its
126
+ own, started on the first upgrade or the first request and given
127
+ `app.fetch` and `app.websocket`, as `listen` gives them. So in dev:
128
+
129
+ - **The same middlewares, refusals and handlers run** as from the build:
130
+ `open`, `message`, `close`, `drain`, the `message` and `send` schemas,
131
+ `subscribe` and `publish`.
132
+ - **An edit to `app/server.ts`, or to a module it imports, is used from
133
+ the next connection.** The edit makes a new app, and the next upgrade
134
+ starts a new `Bun.serve` for it. A socket opened before the edit keeps
135
+ the handlers it opened with until it closes.
136
+ - **A `publish` reaches the sockets opened since the same edit**: the
137
+ sockets opened before it are on the previous server. Reload the pages
138
+ after an edit to reconnect them.
139
+ - **A loader's or an action's `alxiaOf(context).server` is that loopback
140
+ server**, the one the sockets are on, so its `publish` reaches them as
141
+ from the build: see [The server](typing-the-loaders.md#the-server-server).
142
+ - **A socket's `ctx.ip` is the loopback address**, the relay's. The
143
+ browser is local in dev, so that is usually its address anyway.
144
+ - **An upgrade to a path with no `ws` route** gets what the build answers
145
+ it too, never a socket: alxia's 404 where nothing matches, the page
146
+ where the catch-all does.
147
+ - **Another plugin that listens for upgrades** competes with the relay
148
+ on the paths it shares with it: give its socket a path under a
149
+ `server.proxy` entry with `ws: true` or a `ws:` target, or outside the
150
+ app's.
151
+ - **With Vite in middleware mode**, there is no `node:http` server of
152
+ Vite's to listen on: serve the app yourself, as in
153
+ [A server of your own](customising-the-server.md#a-server-of-your-own-without-the-plugin).
154
+
155
+ Under `vite preview` the same relay runs, to a `Bun.serve` of the built
156
+ app, loaded once. Under `bun build/server/index.js`, nothing of this runs:
157
+ `listen` serves the sockets itself, as any alxia app's.
158
+
159
+ ## The client's files
160
+
161
+ In a build, each top-level entry of `build/client` becomes a route, after
162
+ `beforeAll` and before `configure`:
163
+
164
+ | entry | route | `Cache-Control` |
165
+ | --- | --- | --- |
166
+ | `assets/`, the hashed bundles | `static('/assets', …)` | `public, max-age=31536000, immutable` |
167
+ | another folder, such as `images/` from `public/images/` | `static('/images', …)` | `public, max-age=3600` |
168
+ | a file, such as `robots.txt` or `favicon.ico` | `file('/robots.txt', …)` | `public, max-age=3600` |
169
+
170
+ They are the core's `static` and `file` routes, so ETags,
171
+ `Last-Modified`, 304s and ranges are included. A missing file under one
172
+ of those folders gets alxia's JSON 404, `{ "error": "not_found" }`, not a
173
+ page, so a folder of `public/` shadows any page path under the same name.
174
+ Dotfiles are skipped. In dev, Vite serves them.
175
+
176
+ A folder that is not one, or a file whose name no route can carry, is
177
+ refused when the server starts: see
178
+ [the troubleshooting entries](../troubleshooting.md#typeerror-reactrouter-client-is--which-is-not-a-directory-).
@@ -0,0 +1,100 @@
1
+ # A CSP nonce
2
+
3
+ This page covers a Content-Security-Policy without `'unsafe-inline'`: the
4
+ policy in `app/server.ts`, the nonce passed on in `app/entry.server.tsx`, and
5
+ why the nonce can stay loose.
6
+
7
+ React Router renders inline scripts: the hydration data, the module
8
+ loader, the scroll restoration, and with streaming one more per resolved
9
+ `<Await>`. A policy without `'unsafe-inline'` allows them only when each
10
+ carries the nonce the policy names. React Router writes it on every one of
11
+ them, and on its `modulepreload` links, when `entry.server.tsx` passes it
12
+ to `<ServerRouter nonce>` and to React's renderer
13
+ ([React Router's security guide](https://reactrouter.com/how-to/security)).
14
+
15
+ `@alxia/secure-headers` makes the nonce: `secureHeaders({ nonce: true })`
16
+ draws a fresh one per request, adds it to the policy's `script-src`, and
17
+ puts it on the context of the routes after it, the catch-all included.
18
+ `nonceOf(loadContext)` reads it in the entry.
19
+
20
+ ## 1. The policy, in `app/server.ts`
21
+
22
+ ```ts
23
+ import { createServer } from '@alxia/react-router';
24
+ import { secureHeaders } from '@alxia/secure-headers';
25
+
26
+ export default createServer({
27
+ configure: (app) =>
28
+ app.use(
29
+ secureHeaders({
30
+ nonce: true,
31
+ contentSecurityPolicy: [
32
+ "default-src 'self'",
33
+ "script-src 'self'",
34
+ "style-src 'self' 'unsafe-inline'",
35
+ "img-src 'self' data:",
36
+ "connect-src 'self'",
37
+ "form-action 'self'",
38
+ "base-uri 'self'",
39
+ "frame-ancestors 'none'",
40
+ ].join('; '),
41
+ }),
42
+ ),
43
+ });
44
+ ```
45
+
46
+ `script-src 'self'` goes out as `script-src 'self' 'nonce-…'`, a new
47
+ value on every response. `'self'` still allows the bundles under
48
+ `/assets`, and `connect-src 'self'` the single-fetch data requests.
49
+
50
+ ## 2. The nonce, in `app/entry.server.tsx`
51
+
52
+ React Router's template has no entry; reveal the default one, then add an
53
+ import and the nonce in two places:
54
+
55
+ ```sh
56
+ bunx react-router reveal entry.server
57
+ ```
58
+
59
+ ```diff
60
+ // app/entry.server.tsx, as reveal writes it
61
+ import { PassThrough } from "node:stream";
62
+
63
+ +import { nonceOf } from "@alxia/react-router";
64
+ import type { EntryContext, RouterContextProvider } from "react-router";
65
+ …
66
+ const { pipe, abort } = renderToPipeableStream(
67
+ - <ServerRouter context={routerContext} url={request.url} />,
68
+ + <ServerRouter context={routerContext} url={request.url} nonce={nonceOf(loadContext)} />,
69
+ {
70
+ + nonce: nonceOf(loadContext),
71
+ [readyOption]() {
72
+ ```
73
+
74
+ `loadContext` is the entry's fifth parameter, the `RouterContextProvider`
75
+ alxia hands React Router; the revealed file already declares it. With
76
+ `renderToReadableStream`, the web entry, the option is the same: `{ nonce:
77
+ nonceOf(loadContext), … }`.
78
+
79
+ That is all: under `react-router dev` and from the build, every `<script>`
80
+ of a page carries the nonce of its own response's policy, Vite's dev
81
+ scripts included.
82
+
83
+ ## How it stays loose
84
+
85
+ `nonceOf` reads `nonce` from alxia's context if it is a string, and
86
+ returns `undefined` otherwise: no middleware set one, or the request did not come
87
+ through alxia at all. `ServerRouter` and React then render no `nonce`
88
+ attribute. So:
89
+
90
+ - this package does not depend on `@alxia/secure-headers`, nor the reverse;
91
+ - the same `entry.server.tsx` serves an app with or without the policy;
92
+ - a nonce of your own works too, from any `derive` in `configure` that
93
+ returns `{ nonce: string }`; the policy is then yours to write with it.
94
+
95
+ ```ts
96
+ configure: (app) => app.derive(() => ({ nonce: myNonce() })),
97
+ ```
98
+
99
+ Under `exactOptionalPropertyTypes`, spread it in only when there is one:
100
+ see [the troubleshooting entry](../troubleshooting.md#type---nonce-string--undefined--is-not-assignable-to-type-serverrouterprops-with-exactoptionalpropertytypes-true).
@@ -0,0 +1,292 @@
1
+ # Customising the server
2
+
3
+ This page covers changing the default server with `createServer()`, and the
4
+ escape hatches for leaving it: another entry, overriding the wiring, or a
5
+ server of your own.
6
+
7
+ Write `app/server.ts`, with `createServer()` as its default export, or let
8
+ the package's bin write it for you.
9
+
10
+ ## Revealing the default server
11
+
12
+ From the app's root, once `@alxia/react-router` is installed:
13
+
14
+ ```sh
15
+ bunx alxia-react-router reveal
16
+ ```
17
+
18
+ ```text
19
+ alxia-react-router: wrote app/server.ts, the server alxia() runs by default.
20
+ Next: uncomment configure in app/server.ts to add the app's middlewares and /api; bun run dev picks it up.
21
+ ```
22
+
23
+ The file is the server the plugin runs without one, `createServer()`, so
24
+ the app answers as before. It holds `beforeAll`, `configure` and
25
+ `getLoadContext`, commented, each of which compiles once uncommented
26
+ (`getLoadContext`'s key is yours to make, below), and the `Register`
27
+ declaration that types the loaders:
28
+
29
+ ```ts
30
+ // app/server.ts, as reveal writes it, less its comments
31
+ import { createServer } from '@alxia/react-router';
32
+ // import { userAgentContext } from './context';
33
+
34
+ const server = createServer({
35
+ // beforeAll: (app) => app,
36
+ // configure: (app) => app.get('/api/health', ({ reply }) => reply.ok({ ok: true })),
37
+ // getLoadContext: (ctx, context) => {
38
+ // context.set(userAgentContext, ctx.request.headers.get('user-agent'));
39
+ // },
40
+ });
41
+
42
+ export default server;
43
+
44
+ declare module '@alxia/react-router' {
45
+ interface Register {
46
+ server: typeof server;
47
+ }
48
+ }
49
+ ```
50
+
51
+ `getLoadContext`'s example sets a key of the app's own, imported from
52
+ `app/context.ts`. Make it there before uncommenting both lines:
53
+
54
+ ```ts
55
+ // app/context.ts
56
+ import { createContext } from 'react-router';
57
+
58
+ export const userAgentContext = createContext<string | null>(null);
59
+ ```
60
+
61
+ - **Where it writes**: the `entry` that `alxia({ entry: '…' })` names in
62
+ `vite.config.ts`, or `server.ts` in React Router's `appDirectory`
63
+ (`app/` unless `react-router.config.ts` names another). It reads both as
64
+ string literals, past comments. A computed one is refused, since
65
+ reveal would write a file the plugin does not load: give it as a
66
+ literal.
67
+ - **It never overwrites**: a file already there is left as it is, and the
68
+ command exits 1. `bunx alxia-react-router reveal --force` overwrites it.
69
+ - **Bun only**: the bin starts with `#!/usr/bin/env bun`, so no Node is
70
+ needed.
71
+
72
+ React Router's own rendering entries, `app/entry.server.tsx` and
73
+ `app/entry.client.tsx`, are revealed by React Router:
74
+ `bunx react-router reveal`.
75
+
76
+ ## By hand
77
+
78
+ The example uses `@alxia/logger` and `@alxia/compress`
79
+ (`bun add @alxia/logger @alxia/compress`); any middleware works the same way.
80
+
81
+ ```ts
82
+ // app/server.ts
83
+ import { compress } from '@alxia/compress';
84
+ import { logger } from '@alxia/logger';
85
+ import { createServer } from '@alxia/react-router';
86
+
87
+ const server = createServer({
88
+ configure: (app) =>
89
+ app
90
+ .use(logger())
91
+ .use(compress())
92
+ .get('/api/health', ({ reply }) => reply.ok({ ok: true }))
93
+ .derive(({ request }) => {
94
+ const name = request.headers.get('x-user');
95
+ return { user: name === null ? null : { name } };
96
+ }),
97
+ });
98
+
99
+ export default server;
100
+
101
+ declare module '@alxia/react-router' {
102
+ interface Register {
103
+ server: typeof server;
104
+ }
105
+ }
106
+ ```
107
+
108
+ Every option is optional:
109
+
110
+ | option | what it does |
111
+ | --- | --- |
112
+ | `configure(app)` | the app the pages run behind: its middlewares and `/api`. It returns the app, and what it builds is what the loaders read |
113
+ | `beforeAll(app)` | runs first, on a new app. What it declares applies to the client's files too: a rate limit, a guard on everything, a logger that should see every asset. It returns the app, which `configure` then receives |
114
+ | `getLoadContext(ctx, context)` | sets the app's own keys on React Router's provider, `ctx` typed by `configure`'s app |
115
+ | `build`, `mode`, `client` | override what the plugin wires; see [Escape hatches](#escape-hatches) |
116
+ | `listen` | `listen`'s options for `bun build/server/index.js`: `port`, `hostname`, `idleTimeout`, `maxRequestBodySize`, `tls`, `onListen`. A `port` or `hostname` given here wins over `PORT` and `HOST`; an `onListen` here, told `@alxia/core`'s `ListenInfo` — `url`, `routes`, `table`, `dev` — wins over the option below |
117
+ | `onListen(server)` | called once the built server listens and its `SIGINT` and `SIGTERM` handlers are in place, in place of the `alxia listening on …` line (and, in dev, of the route table); a signal sent from then on runs the `onStop` hooks |
118
+
119
+ A request goes through four layers, in order:
120
+
121
+ 1. what `beforeAll` declared;
122
+ 2. the client's files, `build/client`, in a build;
123
+ 3. what `configure` declared;
124
+ 4. the pages: `GET`, `POST`, `PUT`, `PATCH` and `DELETE` at `/*`.
125
+
126
+ A middleware given to `use` runs on every route declared after it, and on a
127
+ request no route matches. So a session or a guard in `configure` runs around
128
+ every page and its data, and not around what is declared before it: the
129
+ client's files, which sit between `beforeAll` and `configure`. What must see
130
+ every request, the client's files included, goes in `beforeAll`: the logger,
131
+ `@alxia/cors`, `@alxia/compress`, `@alxia/secure-headers`. A guard on the app
132
+ answers a request no route matches too: an anonymous request to a missing path
133
+ gets the 401, not the 404. Scope it with a path, `use('/app', guard)`
134
+ (a guard that adds nothing to the context: `bearer` and `session` add `user`),
135
+ to guard only some pages.
136
+
137
+ ```ts
138
+ // app/server.ts: a guard on everything, the assets included
139
+ import { createServer } from '@alxia/react-router';
140
+
141
+ export default createServer({
142
+ beforeAll: (app) =>
143
+ app.derive(({ request, reply }) =>
144
+ request.headers.get('authorization') === `Bearer ${process.env['TOKEN']}`
145
+ ? {}
146
+ : reply(401, { error: 'unauthorized' }),
147
+ ),
148
+ });
149
+ ```
150
+
151
+ `configure` and `beforeAll` must return the app; one that returns nothing
152
+ is a compile error. A middleware that needs something an earlier one
153
+ derived is typed by the app it is given, as anywhere in alxia.
154
+
155
+ ## Behind a proxy
156
+
157
+ A proxy or a load balancer that terminates TLS asks the server over plain
158
+ HTTP, at an address of its own, so React Router's `request.url` reads
159
+ `http://10.0.0.5:3000/…`. Name the proxies with `proxy`, `@alxia/core`'s
160
+ `trustProxy`:
161
+
162
+ ```ts
163
+ // app/server.ts
164
+ import { trustProxy } from '@alxia/core';
165
+ import { createServer } from '@alxia/react-router';
166
+
167
+ export default createServer({
168
+ proxy: trustProxy({ trusted: ['10.0.0.0/8'], untrusted: 'refuse' }),
169
+ });
170
+ ```
171
+
172
+ ```tsx
173
+ // app/routes/login.tsx
174
+ import type { LoaderFunctionArgs } from 'react-router';
175
+
176
+ export function loader({ request }: LoaderFunctionArgs) {
177
+ // https://example.com/auth/callback behind the proxy
178
+ return { callback: new URL('/auth/callback', request.url).href };
179
+ }
180
+ ```
181
+
182
+ Every request through the catch-all is handed to React Router at
183
+ `originalUrl(ctx)`: the scheme and host the trusted proxy wrote in
184
+ `X-Forwarded-Proto` and `X-Forwarded-Host` (or `Forwarded`, with
185
+ `header: 'forwarded'`), each checked, and the request's own for any it did
186
+ not. A direct client's forwarded headers are ignored, or, with
187
+ `untrusted: 'refuse'`, answered 403 before the pages. `ctx.url` and what
188
+ alxia's middlewares read stay the request as it reached the server. With a
189
+ server of your own, give `alxia({ proxy })` the same option. See
190
+ [Behind a proxy](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/serving.md#behind-a-proxy-proxy)
191
+ for what is believed and what is valid.
192
+
193
+ ## Escape hatches
194
+
195
+ ### Another server file
196
+
197
+ `alxia({ entry })` names the server file, relative to Vite's root, in
198
+ place of `app/server.ts`:
199
+
200
+ ```ts
201
+ // vite.config.ts
202
+ import { alxia } from '@alxia/react-router/vite';
203
+ import { reactRouter } from '@react-router/dev/vite';
204
+ import { defineConfig } from 'vite';
205
+
206
+ export default defineConfig({
207
+ plugins: [reactRouter(), alxia({ entry: 'server/main.ts' })],
208
+ });
209
+ ```
210
+
211
+ A missing `entry` is an error, where a missing `app/server.ts` falls back
212
+ to the default server.
213
+
214
+ ### Overriding the wiring
215
+
216
+ `build`, `mode` and `client` override what the plugin passes:
217
+
218
+ ```ts
219
+ // app/server.ts: the client's files with headers of your own
220
+ import { createServer } from '@alxia/react-router';
221
+
222
+ export default createServer({
223
+ client: false,
224
+ beforeAll: (app) =>
225
+ app.static('/assets', 'build/client/assets', {
226
+ cacheControl: 'public, max-age=31536000, immutable',
227
+ precompressed: ['br'],
228
+ }),
229
+ });
230
+ ```
231
+
232
+ - **`client: false`** serves none of `build/client`: declare the files
233
+ yourself, in `beforeAll` or `configure`. A relative path there is
234
+ resolved against the process's working directory, the project's when it
235
+ runs `bun run start`.
236
+ - **`client`**, a path or a `file:` URL, serves another folder in its
237
+ place.
238
+ - **`mode`** forces React Router's server mode. `development` makes React
239
+ Router send its errors' stacks, and asks `build` for the build on every
240
+ request.
241
+ - **`build`**, a `ServerBuild` or a function returning one, replaces React
242
+ Router's build: rarely wanted.
243
+
244
+ ### A server of your own, without the plugin
245
+
246
+ `reactRouter(app, options)` is the catch-all itself, for a server that
247
+ imports the build. Without the plugin, the dev server is React Router's
248
+ own, and the loaders get no alxia context in dev.
249
+
250
+ ```ts
251
+ // server.ts, beside build/
252
+ import { alxia } from '@alxia/core';
253
+ import { logger } from '@alxia/logger';
254
+ import { reactRouter } from '@alxia/react-router';
255
+ import type { ServerBuild } from 'react-router';
256
+
257
+ export const base = alxia()
258
+ .use(logger())
259
+ .get('/api/health', ({ reply }) => reply.ok({ ok: true }));
260
+
261
+ /** What the loaders read: alxiaOf<Base>(context). */
262
+ export type Base = typeof base;
263
+
264
+ const app = base.plugin((app) =>
265
+ reactRouter(app, {
266
+ build: () =>
267
+ import(new URL('./build/server/index.js', import.meta.url).href) as Promise<ServerBuild>,
268
+ client: new URL('./build/client', import.meta.url),
269
+ }),
270
+ );
271
+
272
+ app.listen({ port: Number(process.env['PORT'] ?? 3000) });
273
+ ```
274
+
275
+ ```sh
276
+ bun run build && bun server.ts
277
+ ```
278
+
279
+ | option | |
280
+ | --- | --- |
281
+ | `build` | a `ServerBuild`, loaded at startup; or a function returning one, called on the first request in `production`, again after a failed load, and on **every** request in `development` |
282
+ | `mode` | React Router's server mode, `production` by default |
283
+ | `getLoadContext(ctx, context)` | as `createServer`'s |
284
+ | `client` | the client build's folder, a path or a `file:` URL, served before the catch-all in `production` |
285
+
286
+ `reactRouter()` goes through `app.plugin`, as `@alxia/graphql`'s `graphql(app, …)`
287
+ does: that is how it knows the app's context type. A `HEAD` is handed to
288
+ React Router as a `GET`, since React Router answers a `HEAD` of its own
289
+ with no headers at all; the core then drops the body. In a monorepo where
290
+ this package is linked rather than installed, add
291
+ `ssr: { external: ['@alxia/react-router'] }` to `vite.config.ts`: see
292
+ [the troubleshooting entry](../troubleshooting.md#alxiaof-this-request-has-no-alxia-context-).
@@ -0,0 +1,99 @@
1
+ # Deploying
2
+
3
+ This page covers running the build with Bun, and the Docker image.
4
+
5
+ Run `bun run build`, then `bun build/server/index.js`. The build is
6
+ [self-contained](how-it-works.md#self-contained): `build/` is all the server needs, with
7
+ no `node_modules`, unless the app keeps a package external with
8
+ `ssr.external`.
9
+
10
+ ## Docker
11
+
12
+ The `react-router` template of
13
+ [`@alxia/create`](https://github.com/softistx/alxia/tree/develop/packages/create)
14
+ and the [example](https://github.com/softistx/alxia/tree/develop/examples/react-router)
15
+ ship this `Dockerfile`, on Bun's official images: built on `oven/bun:1`,
16
+ Debian's, and run on `oven/bun:1-alpine`, about 130 MB. For an app started from
17
+ `create-react-router`, it replaces the template's, which builds and runs
18
+ on Node:
19
+
20
+ ```dockerfile
21
+ # Bun builds the app, and the image holds build/ alone: alxia's plugin
22
+ # bundles every dependency into build/server/index.js, alxia's server, which
23
+ # runs on Bun with no node_modules. Pinned to Bun 1, the major alxia
24
+ # supports.
25
+
26
+ # Every dependency, then react-router build: build/client and
27
+ # build/server/index.js.
28
+ FROM oven/bun:1 AS build
29
+ WORKDIR /app
30
+ COPY package.json bun.lock* bunfig.toml* ./
31
+ RUN bun install --frozen-lockfile
32
+ COPY . .
33
+ RUN bun run build
34
+
35
+ # The build, run as Bun's own non-root user with the start script's
36
+ # command, and --no-install: with no node_modules, Bun would otherwise
37
+ # fetch a package the build left out from the registry at startup,
38
+ # where this fails.
39
+ # The server listens on PORT (3000) and HOST (0.0.0.0). On Alpine, about
40
+ # 200 MB lighter: build/ is JavaScript, which runs the same on musl. A
41
+ # native addon built for glibc alone needs oven/bun:1 here.
42
+ FROM oven/bun:1-alpine
43
+ WORKDIR /app
44
+ ENV NODE_ENV=production
45
+ COPY --from=build /app/build ./build
46
+ USER bun
47
+ EXPOSE 3000
48
+ CMD ["bun", "--no-install", "build/server/index.js"]
49
+ ```
50
+
51
+ ```sh
52
+ docker build -t my-app .
53
+ docker run -p 3000:3000 my-app
54
+ ```
55
+
56
+ Keep the template's `.dockerignore` beside it: `node_modules`, `build` and
57
+ `.react-router` stay out of the context, so the image installs and builds
58
+ its own. The `bunfig.toml` goes in, and `bun run build` runs React
59
+ Router's CLI on Bun, as it does outside Docker. The image's command is
60
+ `start`'s, run directly, so the platform's `SIGTERM` reaches the server,
61
+ with `--no-install`: a package missing from `build/` then fails at
62
+ startup, where Bun would otherwise fetch it from npm.
63
+
64
+ - **Commit `bun.lock`.** The installs are `--frozen-lockfile`: the image
65
+ gets the versions you tested, and a `package.json` changed without a
66
+ `bun install` fails the build
67
+ ([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#error-lockfile-had-changes-but-lockfile-is-frozen)).
68
+ - **The final stage is Alpine's**, musl where the build stage has glibc.
69
+ `build/` is JavaScript, which runs the same on both. A native addon
70
+ kept external and built for glibc alone does not load there: put the
71
+ final stage back on `oven/bun:1`
72
+ ([`@alxia/create`'s troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/create/docs/troubleshooting.md#error--is-linked-against-glibc-dt_needed-libmso6-but-this-bun-build-uses-musl)).
73
+ - **The server runs as `bun`**, a user with no write access to `/app`.
74
+ Write files to a volume the user owns
75
+ ([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#eacces-permission-denied-open-app)).
76
+ - **The image holds `build/` alone**, no `node_modules`: the build is
77
+ [self-contained](how-it-works.md#self-contained). An app that keeps a package external
78
+ with `ssr.external` copies the production dependencies too, from a
79
+ stage of their own:
80
+
81
+ ```dockerfile
82
+ # Before the build stage: the production dependencies alone.
83
+ FROM oven/bun:1 AS production-dependencies
84
+ WORKDIR /app
85
+ COPY package.json bun.lock* bunfig.toml* ./
86
+ RUN bun install --frozen-lockfile --production
87
+
88
+ # In the final stage, beside build/.
89
+ COPY --from=production-dependencies /app/node_modules ./node_modules
90
+ ```
91
+
92
+ Without it, the container stops at startup with
93
+ `Cannot find package '…'`
94
+ ([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#error-cannot-find-package--from-appbuildserverindexjs)).
95
+
96
+ `PORT` and `HOST` set where the server listens. The platform's `SIGTERM`
97
+ stops it once the requests in flight are answered, within
98
+ `shutdownTimeout`, and runs the app's `onStop` hooks. Give the platform a
99
+ readiness probe with `@alxia/core`'s `health()`, mounted in `beforeAll`.