@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
package/docs/guide.md CHANGED
@@ -1,1446 +1,82 @@
1
1
  # Guide
2
2
 
3
- `@alxia/react-router` serves a React Router **framework-mode** app with
4
- server rendering from an alxia app, under Bun. This page walks an app
5
- author through it: the setup, what happens in dev, in a build and under
6
- `vite preview`, what the build sets for Bun,
7
- customising the server, typing the loaders, the app's own context keys,
8
- a CSP nonce, the escape hatches, WebSockets, the client's files, OpenAPI, testing and
9
- deploying.
10
-
11
- - [Setup](#setup)
12
- - [How it works](#how-it-works)
13
- - [Built for Bun](#built-for-bun)
14
- - [Customising the server](#customising-the-server)
15
- - [Typing the loaders](#typing-the-loaders)
16
- - [The app's own context keys](#the-apps-own-context-keys)
17
- - [A CSP nonce](#a-csp-nonce)
18
- - [Escape hatches](#escape-hatches)
19
- - [Middlewares around the pages](#middlewares-around-the-pages)
20
- - [Routes beside the pages](#routes-beside-the-pages)
21
- - [WebSockets](#websockets)
22
- - [The client's files](#the-clients-files)
23
- - [OpenAPI](#openapi)
24
- - [Testing](#testing)
25
- - [Deploying](#deploying)
3
+ The guide moved to `docs/guide/`, one page per topic; the
4
+ [documentation index](README.md) lists them. This page only keeps the old
5
+ anchors, so a link to one of them still lands on the topic: each heading
6
+ below links to where it went.
26
7
 
27
8
  ## Setup
28
9
 
29
- ### A new app: `bun create @alxia`
30
-
31
- ```sh
32
- bun create @alxia my-app --template react-router
33
- cd my-app
34
- bun dev
35
- ```
36
-
37
- [`@alxia/create`](https://www.npmjs.com/package/@alxia/create) copies React
38
- Router's official template with alxia's layer already applied: the steps
39
- of the next section, done.
40
-
41
- - `@alxia/core` and `@alxia/react-router` installed, and every dependency
42
- moved to the newest release alxia accepts (see
43
- [`@alxia/create`'s Versions](https://github.com/softistx/alxia/blob/develop/packages/create/docs/guide.md#versions));
44
- - `alxia()` in `vite.config.ts`, the `bunfig.toml`, and `start` running
45
- `NODE_ENV=production bun build/server/index.js`;
46
- - Biome, with `lint`, `format`, `check`, `check:ci` and `verify` scripts;
47
- - a `Dockerfile` that builds on `oven/bun:1` and runs `build/` alone on
48
- `oven/bun:1-alpine`, as [Deploying](#deploying) describes.
49
-
50
- `bun run verify` checks, typechecks and builds it.
51
- [`@alxia/create`'s guide](https://github.com/softistx/alxia/blob/develop/packages/create/docs/guide.md#running-it)
52
- lists the command's options. The template keeps `@react-router/serve` in its
53
- dependencies, unused: remove it if you like.
54
-
55
- ### An existing React Router app
56
-
57
- Start from React Router's official template, or your own app:
58
-
59
- ```sh
60
- bunx create-react-router@latest my-app
61
- cd my-app
62
- ```
63
-
64
- Then make three changes.
65
-
66
- **1. Install** alxia and this package:
67
-
68
- ```sh
69
- bun add @alxia/core @alxia/react-router
70
- ```
71
-
72
- **2. Add `alxia()`** to the plugins in `vite.config.ts`:
73
-
74
- ```ts
75
- // vite.config.ts
76
- import { alxia } from '@alxia/react-router/vite';
77
- import { reactRouter } from '@react-router/dev/vite';
78
- import tailwindcss from '@tailwindcss/vite';
79
- import { defineConfig } from 'vite';
80
-
81
- export default defineConfig({
82
- plugins: [tailwindcss(), reactRouter(), alxia()],
83
- resolve: {
84
- tsconfigPaths: true,
85
- },
86
- });
87
- ```
88
-
89
- The order does not matter. The plugin always runs before React Router's:
90
- React Router's build reads its server input from it, and its dev
91
- middleware must come after alxia's.
92
-
93
- **3. Start with Bun**, in `package.json`:
94
-
95
- ```jsonc
96
- "scripts": {
97
- "build": "react-router build",
98
- "dev": "react-router dev",
99
- "start": "NODE_ENV=production bun build/server/index.js",
100
- "typecheck": "react-router typegen && tsc"
101
- }
102
- ```
103
-
104
- Only `start` changes. The `react-router` and `vite` CLIs start with
105
- `#!/usr/bin/env node`, so where a node is installed `bun run` would run
106
- them on Node, and alxia's server needs Bun. A `bunfig.toml` beside
107
- `package.json` makes `bun run` start them on Bun, for every script:
108
-
109
- ```toml
110
- # bunfig.toml, beside package.json
111
- [run]
112
- bun = true
113
- ```
114
-
115
- Then `bun run dev`, `bun run build` and `bun run typecheck` work as they
116
- are, with or without a node installed. Without that file, run them as
117
- `bun --bun react-router dev`. The plugin refuses a dev or preview server
118
- running on Node at startup, rather than failing on the first request:
119
- see [the troubleshooting entry](troubleshooting.md#alxia-react-router--is-running-on-node-and-alxias-server-runs-on-bun-). The
120
- template's `@react-router/serve` is no longer used, and you can remove it.
121
-
122
- What the template keeps:
123
-
124
- - `@react-router/node`, which React Router's default `entry.server`
125
- renders with: it streams under Bun.
126
- - Vite 7 or 8 (the template ships 8).
127
- - With `bunx create-react-router`, its `typescript` 5.9. This package's
128
- peer asks for 6 or 7, so `bun add` warns, but the template's code
129
- typechecks with 5.9. `bun add -d typescript@^6` silences the warning;
130
- `bun create @alxia` writes 6 or 7 already.
131
-
132
- React Router 8 is required: its loaders receive a `RouterContextProvider`,
133
- and alxia's context is set on it.
10
+ Moved to [Getting started](guide/getting-started.md).
134
11
 
135
12
  ## How it works
136
13
 
137
- With no `app/server.ts`, the plugin serves the app with
138
- `createServer()`, from this package, with no options: a fresh alxia app,
139
- the client's files, and the pages as a catch-all behind it.
140
-
141
- ### In dev
142
-
143
- `react-router dev` starts Vite's server. Vite answers its own requests
144
- first: modules, `/@fs/…`, `public/`, the HMR socket. The plugin hands
145
- every other request to the server: pages, single-fetch data, and alxia's
146
- routes. The server is loaded through Vite's SSR runner, so:
147
-
148
- - **HMR** works as in any React Router app: a component edit reaches the
149
- browser over Vite's socket.
150
- - **An edit to `app/server.ts`, or to a module it imports, is live on the
151
- next request**, with no restart: the runner reloads what changed.
152
- - **Creating or deleting `app/server.ts`** takes effect on the next
153
- request.
154
- - **The app's own context keys work**: the server and the routes are one
155
- module graph. See [The app's own context keys](#the-apps-own-context-keys).
156
- - **Errors** go to Vite's error page, with the stack mapped to the source.
157
- - **alxia's `ws` routes connect**: an upgrade Vite's HMR does not claim
158
- goes to the app, as from the build. See [WebSockets](#websockets).
159
-
160
- HTTP requests reach the app through `app.fetch`, not through `listen`,
161
- given the loopback server the sockets are relayed to as `ctx.server`
162
- ([The server](#the-server-server)). So `page()` is not served in dev; see
163
- [the troubleshooting entry](troubleshooting.md#a-page-is-not-served-under-react-router-dev).
164
-
165
- ### In a build
166
-
167
- `react-router build` builds the client as usual. It builds the server
168
- from `app/server.ts`, or from the default server, with React Router's
169
- server build inside it, into one file:
170
-
171
- - **`build/server/index.js`'s default export is the alxia app.** Beside it
172
- are React Router's own exports, so the file is a server build too, and
173
- React Router's `prerender` reads it. The server file's other exports
174
- stay out of it.
175
- - **Run, it listens**: `bun build/server/index.js` listens on `PORT`
176
- (3000 by default) and `HOST` (`0.0.0.0`). It prints
177
- `alxia listening on <url>`, and in dev — under `react-router dev`; a
178
- production build never is, whatever `NODE_ENV` says — the route table
179
- ([`@alxia/core`'s Development](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/development.md#the-route-table)). On `SIGINT` or `SIGTERM` it shuts the app
180
- down as `@alxia/core`'s `listen` does: readiness turns 503, new
181
- connections are refused, the requests in flight finish within
182
- `shutdownTimeout` (10 s; `listen: { shutdownTimeout }` sets it), the
183
- `onStop` hooks run, and the process exits
184
- ([core's guide](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/health-and-shutdown.md)).
185
- - **Imported, it starts nothing**: prerendering, a test or another server
186
- can import it safely, since it listens only when it is the process's
187
- entry point (`import.meta.main`).
188
- - **The mode follows the command**: `development` under `react-router dev`,
189
- `production` in a build, whatever `NODE_ENV` says. The app's dev switch
190
- follows it: `alxia({ dev: mode === 'development' })`, so a 500 of the
191
- build never shows its stack.
192
- - **The client folder is resolved against the built file**: `../client`,
193
- from React Router's `buildDirectory`, so it is found wherever the
194
- process starts.
195
- - **It is self-contained**: every package the server imports, React,
196
- React Router and alxia included, is bundled into it
197
- ([Self-contained](#self-contained)), so `build/` runs with no
198
- `node_modules`.
199
-
200
- ```sh
201
- bun run build
202
- PORT=8080 bun run start
203
- ```
204
-
205
- React Router's `serverBundles` split the server build in several, and
206
- alxia serves one: the plugin refuses them. With `ssr: false`, a
207
- single-page app, the plugin does nothing.
208
-
209
- ### Under `vite preview`
210
-
211
- After a build, Vite's preview server serves the built server:
212
-
213
- ```sh
214
- bun run build
215
- bunx --bun vite preview
216
- ```
217
-
218
- `--bun` runs Vite under Bun even where Node is installed, since Vite's bin
219
- asks for Node: the built server runs inside Vite's process, and it needs
220
- Bun's APIs. With the [`bunfig.toml`](#setup) (`[run]`,
221
- `bun = true`), or with no Node installed, a `"preview": "vite preview"`
222
- script run with `bun run preview` works too, as the template's other
223
- scripts do.
224
-
225
- The plugin loads `build/server/index.js` on the first request and hands
226
- every request to its default export, the alxia app, before Vite's own
227
- files. So the preview answers as `bun run start` does: the pages and their
228
- data, `/api`, `build/client` with the cache headers of
229
- [the client's files](#the-clients-files), and every middleware of `beforeAll`
230
- and `configure`. Requests, their bodies and every `Set-Cookie` pass
231
- through whole, and pages stream.
232
-
233
- What differs from `bun run start`:
234
-
235
- - **Vite listens**, on `preview.port` (4173), `preview.host` and
236
- `preview.https`. `listen`, `onListen`, `PORT` and `HOST` are not read.
237
- - **Requests arrive through `app.fetch`**, as under `react-router dev`:
238
- `page()` and an HTTP request's `ctx.server` are absent. alxia's `ws`
239
- routes connect, relayed to a `Bun.serve` of the built app as in dev
240
- ([WebSockets](#websockets)); an upgrade `preview.proxy` relays stays
241
- Vite's.
242
- - **Vite's preview options that come after the plugin never run**:
243
- `preview.proxy` for HTTP requests, `preview.headers` and Vite's file
244
- serving.
245
- `preview.cors` and `preview.allowedHosts` still apply.
246
- - **The build is loaded once**: after `bun run build` again, restart the
247
- preview.
248
-
249
- React Router prerenders the `prerender` paths of `react-router.config.ts`
250
- through the same preview server, during `react-router build`. A
251
- prerendered page is therefore rendered by the built server too: its loader
252
- reads `alxiaOf(context)` and `getLoadContext`'s keys, and `beforeAll`'s
253
- and `configure`'s middlewares run around it.
14
+ Moved to [How it works](guide/how-it-works.md).
254
15
 
255
16
  ## Built for Bun
256
17
 
257
- `build/server/index.js` runs on Bun, and the plugin builds it for Bun,
258
- with nothing to configure. It adds to Vite's `ssr` environment, under
259
- `react-router dev` and `react-router build` alike but for the last row,
260
- the build's alone:
261
-
262
- | option | what the plugin adds | why |
263
- | --- | --- | --- |
264
- | `resolve.conditions` | `bun` | a package bundled into the server whose `exports` has a `bun` condition is bundled as its Bun variant |
265
- | `resolve.externalConditions` | `bun` | a dependency left external, as every one is in dev and one the app names in `ssr.external` is in the build, is loaded as its Bun variant, as Bun loads it |
266
- | `resolve.builtins` | `bun`, and `bun:*` (`bun:sqlite`, `bun:ffi`, …) | Bun's own modules stay imports of the build, whichever runtime runs Vite. Vite knows `bun:*`, and bare `bun` only when it runs on Bun |
267
- | `build.target` | `esnext` | the newest syntax is left as written: Bun runs it, and Vite's default targets browsers |
268
- | `resolve.noExternal` | `true`, under `react-router build` only | every package is bundled into `build/server/index.js`, so `build/` runs with no `node_modules` ([Self-contained](#self-contained)) |
269
-
270
- A package written for Bun, then, gives the build the variant Bun would
271
- load, and Bun's own modules are used as they are:
272
-
273
- ```jsonc
274
- // node_modules/some-package/package.json
275
- { "exports": { "bun": "./bun.js", "default": "./node.js" } }
276
- ```
277
-
278
- ```ts
279
- // app/server.ts
280
- import { createServer } from '@alxia/react-router';
281
- import { Database } from 'bun:sqlite';
282
-
283
- const db = new Database(':memory:');
284
-
285
- export default createServer({
286
- configure: (app) =>
287
- app.get('/api/count', ({ reply }) =>
288
- reply.ok(db.query('select sqlite_version() as version').get()),
289
- ),
290
- });
291
- ```
292
-
293
- ```js
294
- // build/server/index.js, in part
295
- import { Database } from "bun:sqlite";
296
- ```
297
-
298
- Which variant wins is still the package's own order: `exports` is read
299
- top to bottom, and the first condition the build has wins. A package that
300
- lists `node` before `bun` gives its Node variant, as Bun itself would load
301
- it.
302
-
303
- What the plugin leaves alone:
304
-
305
- - **`ssr.target` stays `node`.** Bun runs Node's modules, and `node`
306
- keeps `node:*` external and resolves packages as a server does;
307
- `webworker` would bundle every dependency with the browser's conditions.
308
- An app that sets `ssr.target: 'webworker'` itself gets none of the
309
- above: that environment keeps Vite's own defaults.
310
- - **No polyfill is added.** Vite adds none to a server build, and the
311
- plugin adds none.
312
- - **The client build** is a browser's, as before: none of this reaches
313
- it. A Bun module imported by code that reaches the client is replaced
314
- by an empty module there: Vite 7 fails the build, Vite 8 fails in the
315
- browser ([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#module--has-been-externalized-for-browser-compatibility-imported-by-)).
316
-
317
- What the app sets wins. The plugin adds to the `ssr` environment once
318
- every plugin's `config` has run, and Vite merges what it adds by
319
- concatenating arrays, so conditions and builtins of the app's own are
320
- kept, and `bun` is added only when they lack it. A `build.target`, at the
321
- top level or on the environment, is kept as it is:
322
-
323
- ```ts
324
- // vite.config.ts
325
- export default defineConfig({
326
- // Kept: the build resolves `worker`, then `bun`, and targets es2022.
327
- environments: {
328
- ssr: {
329
- resolve: { conditions: ['worker'] },
330
- build: { target: 'es2022' },
331
- },
332
- },
333
- plugins: [reactRouter(), alxia()],
334
- });
335
- ```
336
-
337
- `ssr.resolve.conditions` at the top level is the same option, and is kept
338
- the same way.
339
-
340
- ### Self-contained
341
-
342
- Under `react-router build`, the plugin sets `resolve.noExternal: true` on
343
- the `ssr` environment: Vite bundles every package the server imports into
344
- `build/server/index.js`, where it would otherwise leave each one under
345
- `node_modules` as an `import`. What the file still imports is Node's and
346
- Bun's own modules:
347
-
348
- ```js
349
- // build/server/index.js, its imports
350
- import { createRequire } from "node:module";
351
- import { PassThrough } from "node:stream";
352
- // … and no "react", "react-router" or "@alxia/core"
353
- ```
18
+ Moved to [Built for Bun](guide/how-it-works.md#built-for-bun).
354
19
 
355
- So `build/` is all a server needs, beside Bun: a Docker image's last
356
- stage copies it alone ([Docker](#docker)). React and `react-dom/server`,
357
- React Router, alxia and its plugins bundle as they are, CommonJS included;
358
- the `bun` condition above picks each package's Bun variant. One copy of
359
- each package is in the file, so `alxiaContext` and the app's own keys are
360
- one object for the server and the routes. React Router's prerendering and
361
- `vite preview` load the same file.
20
+ ## Self-contained
362
21
 
363
- `react-router dev` is left as it was: Vite's SSR runner loads the
364
- packages from `node_modules`.
365
-
366
- What the app sets wins. Vite reads `external` before `noExternal`:
367
-
368
- - **`ssr.external: ['sharp']`**, or the same on
369
- `environments.ssr.resolve.external`, keeps those packages external,
370
- imported from `node_modules` at runtime. Use it for a package that
371
- cannot be bundled: a native addon (a `.node` file), or one that reads
372
- files of its own folder at runtime. The image must then hold that
373
- package ([Docker](#docker)).
374
- - **`ssr.external: true`** keeps every package external, Vite's own
375
- behaviour: the plugin adds no `noExternal` then, and the server needs
376
- the production `node_modules` beside `build/`.
377
- - **`ssr.noExternal`**, a list, changes nothing: everything is bundled
378
- already.
379
-
380
- ```ts
381
- // vite.config.ts
382
- export default defineConfig({
383
- // Bundled but for sharp, a native addon.
384
- ssr: { external: ['sharp'] },
385
- plugins: [reactRouter(), alxia()],
386
- });
387
- ```
388
-
389
- A package the build could not bundle and was not told to leave external
390
- fails at runtime, from `build/` alone, with Bun's
391
- `Cannot find package '…'`: see
392
- [the troubleshooting entry](troubleshooting.md#error-cannot-find-package--from-appbuildserverindexjs).
22
+ Moved to [Self-contained](guide/how-it-works.md#self-contained).
393
23
 
394
24
  ## Customising the server
395
25
 
396
- Write `app/server.ts`, with `createServer()` as its default export, or let
397
- the package's bin write it for you.
398
-
399
- ### Revealing the default server
400
-
401
- From the app's root, once `@alxia/react-router` is installed:
402
-
403
- ```sh
404
- bunx alxia-react-router reveal
405
- ```
406
-
407
- ```text
408
- alxia-react-router: wrote app/server.ts, the server alxia() runs by default.
409
- Next: uncomment configure in app/server.ts to add the app's middlewares and /api; bun run dev picks it up.
410
- ```
411
-
412
- The file is the server the plugin runs without one, `createServer()`, so
413
- the app answers as before. It holds `beforeAll`, `configure` and
414
- `getLoadContext`, commented, each of which compiles once uncommented
415
- (`getLoadContext`'s key is yours to make, below), and the `Register`
416
- declaration that types the loaders:
417
-
418
- ```ts
419
- // app/server.ts, as reveal writes it, less its comments
420
- import { createServer } from '@alxia/react-router';
421
- // import { userAgentContext } from './context';
422
-
423
- const server = createServer({
424
- // beforeAll: (app) => app,
425
- // configure: (app) => app.get('/api/health', ({ reply }) => reply.ok({ ok: true })),
426
- // getLoadContext: (ctx, context) => {
427
- // context.set(userAgentContext, ctx.request.headers.get('user-agent'));
428
- // },
429
- });
430
-
431
- export default server;
432
-
433
- declare module '@alxia/react-router' {
434
- interface Register {
435
- server: typeof server;
436
- }
437
- }
438
- ```
439
-
440
- `getLoadContext`'s example sets a key of the app's own, imported from
441
- `app/context.ts`. Make it there before uncommenting both lines:
442
-
443
- ```ts
444
- // app/context.ts
445
- import { createContext } from 'react-router';
446
-
447
- export const userAgentContext = createContext<string | null>(null);
448
- ```
449
-
450
- - **Where it writes**: the `entry` that `alxia({ entry: '…' })` names in
451
- `vite.config.ts`, or `server.ts` in React Router's `appDirectory`
452
- (`app/` unless `react-router.config.ts` names another). It reads both as
453
- string literals, past comments. A computed one is refused, since
454
- reveal would write a file the plugin does not load: give it as a
455
- literal.
456
- - **It never overwrites**: a file already there is left as it is, and the
457
- command exits 1. `bunx alxia-react-router reveal --force` overwrites it.
458
- - **Bun only**: the bin starts with `#!/usr/bin/env bun`, so no Node is
459
- needed.
460
-
461
- React Router's own rendering entries, `app/entry.server.tsx` and
462
- `app/entry.client.tsx`, are revealed by React Router:
463
- `bunx react-router reveal`.
464
-
465
- ### By hand
466
-
467
- The example uses `@alxia/logger` and `@alxia/compress`
468
- (`bun add @alxia/logger @alxia/compress`); any middleware works the same way.
469
-
470
- ```ts
471
- // app/server.ts
472
- import { compress } from '@alxia/compress';
473
- import { logger } from '@alxia/logger';
474
- import { createServer } from '@alxia/react-router';
475
-
476
- const server = createServer({
477
- configure: (app) =>
478
- app
479
- .use(logger())
480
- .use(compress())
481
- .get('/api/health', ({ reply }) => reply.ok({ ok: true }))
482
- .derive(({ request }) => {
483
- const name = request.headers.get('x-user');
484
- return { user: name === null ? null : { name } };
485
- }),
486
- });
487
-
488
- export default server;
489
-
490
- declare module '@alxia/react-router' {
491
- interface Register {
492
- server: typeof server;
493
- }
494
- }
495
- ```
496
-
497
- Every option is optional:
498
-
499
- | option | what it does |
500
- | --- | --- |
501
- | `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 |
502
- | `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 |
503
- | `getLoadContext(ctx, context)` | sets the app's own keys on React Router's provider, `ctx` typed by `configure`'s app |
504
- | `build`, `mode`, `client` | override what the plugin wires; see [Escape hatches](#escape-hatches) |
505
- | `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 |
506
- | `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 |
507
-
508
- A request goes through four layers, in order:
509
-
510
- 1. what `beforeAll` declared;
511
- 2. the client's files, `build/client`, in a build;
512
- 3. what `configure` declared;
513
- 4. the pages: `GET`, `POST`, `PUT`, `PATCH` and `DELETE` at `/*`.
514
-
515
- A middleware given to `use` runs on every route declared after it, and on a
516
- request no route matches. So a session or a guard in `configure` runs around
517
- every page and its data, and not around what is declared before it: the
518
- client's files, which sit between `beforeAll` and `configure`. What must see
519
- every request, the client's files included, goes in `beforeAll`: the logger,
520
- `@alxia/cors`, `@alxia/compress`, `@alxia/secure-headers`. A guard on the app
521
- answers a request no route matches too: an anonymous request to a missing path
522
- gets the 401, not the 404. Scope it with a path, `use('/app', guard)`
523
- (a guard that adds nothing to the context: `bearer` and `session` add `user`),
524
- to guard only some pages.
525
-
526
- ```ts
527
- // app/server.ts: a guard on everything, the assets included
528
- import { createServer } from '@alxia/react-router';
529
-
530
- export default createServer({
531
- beforeAll: (app) =>
532
- app.derive(({ request, reply }) =>
533
- request.headers.get('authorization') === `Bearer ${process.env['TOKEN']}`
534
- ? {}
535
- : reply(401, { error: 'unauthorized' }),
536
- ),
537
- });
538
- ```
539
-
540
- `configure` and `beforeAll` must return the app; one that returns nothing
541
- is a compile error. A middleware that needs something an earlier one
542
- derived is typed by the app it is given, as anywhere in alxia.
543
-
544
- ### Behind a proxy
545
-
546
- A proxy or a load balancer that terminates TLS asks the server over plain
547
- HTTP, at an address of its own, so React Router's `request.url` reads
548
- `http://10.0.0.5:3000/…`. Name the proxies with `proxy`, `@alxia/core`'s
549
- `trustProxy`:
550
-
551
- ```ts
552
- // app/server.ts
553
- import { trustProxy } from '@alxia/core';
554
- import { createServer } from '@alxia/react-router';
555
-
556
- export default createServer({
557
- proxy: trustProxy({ trusted: ['10.0.0.0/8'], untrusted: 'refuse' }),
558
- });
559
- ```
560
-
561
- ```tsx
562
- // app/routes/login.tsx
563
- import type { LoaderFunctionArgs } from 'react-router';
564
-
565
- export function loader({ request }: LoaderFunctionArgs) {
566
- // https://example.com/auth/callback behind the proxy
567
- return { callback: new URL('/auth/callback', request.url).href };
568
- }
569
- ```
570
-
571
- Every request through the catch-all is handed to React Router at
572
- `originalUrl(ctx)`: the scheme and host the trusted proxy wrote in
573
- `X-Forwarded-Proto` and `X-Forwarded-Host` (or `Forwarded`, with
574
- `header: 'forwarded'`), each checked, and the request's own for any it did
575
- not. A direct client's forwarded headers are ignored, or, with
576
- `untrusted: 'refuse'`, answered 403 before the pages. `ctx.url` and what
577
- alxia's middlewares read stay the request as it reached the server. With a
578
- server of your own, give `alxia({ proxy })` the same option. See
579
- [Behind a proxy](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/serving.md#behind-a-proxy-proxy)
580
- for what is believed and what is valid.
26
+ Moved to [Customising the server](guide/customising-the-server.md).
581
27
 
582
28
  ## Typing the loaders
583
29
 
584
- Every request through the catch-all sets `alxiaContext`, this package's
585
- key, on React Router's context provider, to what alxia's middlewares built for
586
- that request. Three forms read it, each the same object:
587
-
588
- - **`withAlxia(fn)`**, for a route typed by the generated `Route.*Args`:
589
- `fn` gets `alxia` beside React Router's arguments;
590
- - **`alxiaOf(context)`**, inside a plain function, and with a type argument;
591
- - **`context.alxia`**, where the arguments are typed with `react-router`'s
592
- own `LoaderFunctionArgs` and `ActionFunctionArgs`
593
- ([below](#the-shorthand-contextalxia)).
594
-
595
- ### `withAlxia(fn)`
596
-
597
- ```ts
598
- // app/routes/account.tsx
599
- import { type AlxiaArgs, withAlxia } from '@alxia/react-router';
600
- import { redirect } from 'react-router';
601
- import type { Route } from './+types/account';
602
-
603
- export const loader = withAlxia(({ alxia, params }: Route.LoaderArgs & AlxiaArgs) => {
604
- if (alxia.user === null) throw redirect('/login');
605
- return { name: alxia.user.name, tab: params.tab ?? 'profile' };
606
- });
607
- // The page's Route.ComponentProps['loaderData']: { name: string; tab: string }
608
- ```
609
-
610
- Annotate `fn`'s parameter as the generated arguments and `AlxiaArgs`:
611
- `AlxiaArgs` is `{ alxia }`, typed by `Register`, and `AlxiaArgs<typeof server>`
612
- names a server where nothing is registered. `withAlxia` returns a function of
613
- React Router's arguments alone, with `fn`'s return type, so the generated
614
- `loaderData` and `actionData`, and `useLoaderData<typeof loader>`, read what
615
- `fn` returns. An action is written the same way; a middleware too, and its
616
- `next` is passed on:
617
-
618
- ```ts
619
- import { type AlxiaArgs, withAlxia } from '@alxia/react-router';
620
- import type { Route } from './+types/admin';
621
-
622
- export const middleware: Route.MiddlewareFunction[] = [
623
- withAlxia(async ({ alxia }: Parameters<Route.MiddlewareFunction>[0] & AlxiaArgs, next) => {
624
- alxia.log.info('admin');
625
- return next();
626
- }),
627
- ];
628
- ```
629
-
630
- Why a wrapper: `context.alxia` cannot be typed under the generated
631
- arguments ([below](#the-shorthand-contextalxia)), and a wrapper that adds a
632
- key is the one form TypeScript infers from the annotation alone. A type
633
- argument, `withAlxia<Route.LoaderArgs>(fn)`, would fix the arguments but
634
- stop TypeScript inferring `fn`'s return type, since it infers no type
635
- argument once one is given: the page's `loaderData` would be lost. On a request that did not come
636
- through alxia it throws what `alxiaOf` throws.
637
-
638
- ### With `Register`: no type argument
639
-
640
- The `declare module` block in `app/server.ts` names the server once:
641
-
642
- ```ts
643
- declare module '@alxia/react-router' {
644
- interface Register {
645
- server: typeof server;
646
- }
647
- }
648
- ```
649
-
650
- Every loader, action and middleware then reads it typed, with no import:
651
-
652
- ```ts
653
- // app/routes/account.tsx
654
- import { alxiaOf } from '@alxia/react-router';
655
- import { data, redirect } from 'react-router';
656
- import type { Route } from './+types/account';
657
-
658
- export function loader({ context }: Route.LoaderArgs) {
659
- const { user, log, requestId } = alxiaOf(context);
660
- if (user === null) throw redirect('/login');
661
- log.info('account');
662
- return { name: user.name, requestId };
663
- }
664
-
665
- export async function action({ request, context }: Route.ActionArgs) {
666
- const { user } = alxiaOf(context);
667
- const form = await request.formData();
668
- if (user === null) return data({ error: 'signed out' }, { status: 401 });
669
- return { saved: form.get('name'), by: user.name };
670
- }
671
- ```
672
-
673
- Name `typeof server`, the default export's type, not the module's
674
- (`typeof import('./server')`): a wrong registration makes every read a
675
- compile error, as [the troubleshooting entry](troubleshooting.md#property--does-not-exist-on-type-basecontext---readonly-registerserver-must-be-typeof-server-the-default-export-of-createserver-never-)
676
- shows. The checks it gives:
677
-
678
- ```ts
679
- const ctx = alxiaOf(context);
680
- ctx.tenant; // error: no middleware derives `tenant`
681
- ctx.user.name; // error: `user` may be null
682
- ```
30
+ Moved to [Typing the loaders](guide/typing-the-loaders.md).
683
31
 
684
- ### Without `Register`: the type argument
32
+ ## The server: `server`
685
33
 
686
- Pass the server's type, imported with `import type`, so nothing is
687
- bundled and the route never imports the server at runtime:
688
-
689
- ```ts
690
- // app/server.ts
691
- const server = createServer({ configure: (app) => app.use(logger()) });
692
- export default server;
693
- export type Server = typeof server;
694
- ```
695
-
696
- ```ts
697
- // app/routes/home.tsx
698
- import { alxiaOf } from '@alxia/react-router';
699
- import type { Server } from '../server';
700
- import type { Route } from './+types/home';
701
-
702
- export function loader({ context }: Route.LoaderArgs) {
703
- const { log } = alxiaOf<Server>(context);
704
- log.info('home');
705
- return null;
706
- }
707
- ```
708
-
709
- The type argument may also be an alxia app, the app *before* the
710
- catch-all, for a server of your own. Anything else is a compile error.
711
-
712
- ### With `@alxia/core`'s `Register`
713
-
714
- `@alxia/core` has a `Register` of its own, which names the chain that
715
- builds an app's context, for its route files
716
- ([The app's type](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/types.md#register-and-appcontext)).
717
- `alxiaOf(context)` reads it when this package's `Register` names no
718
- server:
719
-
720
- ```ts
721
- // app/context.ts
722
- import { alxia } from '@alxia/core';
723
-
724
- export const base = alxia().derive(({ request }) => ({ user: request.headers.get('x-user') }));
725
-
726
- declare module '@alxia/core' {
727
- interface Register {
728
- context: typeof base;
729
- }
730
- }
731
-
732
- // app/server.ts
733
- const server = createServer({ configure: (app) => app.plugin(base) });
734
- export default server;
735
- ```
736
-
737
- `alxiaOf(context).user` is then typed with no declaration in
738
- `@alxia/react-router`. Which one is read, in order:
739
-
740
- 1. a type argument, `alxiaOf<typeof server>(context)`;
741
- 2. this package's `Register`, `server: typeof server`: the whole app the
742
- pages run behind, `base` and everything `configure` adds after it;
743
- 3. `@alxia/core`'s `Register`, `context: typeof base`: only what `base`
744
- builds;
745
- 4. neither: `BaseContext`, the request, its URL, `reply` and the rest of
746
- what every middleware reads.
747
-
748
- Declare this package's `Register` when `configure` adds to the context
749
- after the base, and the loaders read it.
750
-
751
- ### Why a global augmentation is right here
752
-
753
- alxia's core refuses a global augmentation of a context key: a plugin
754
- that added `user` to every route, declared before it or after, would
755
- type `user` on routes that run before the plugin. That is the lie "order
756
- is meaning" forbids. Core's own `Register` names the base that builds
757
- the context instead, and what reads it requires it. `Register` here does
758
- something else: it names the **one** server of the React Router build,
759
- at the point of its catch-all, which is exactly what every loader runs
760
- behind. One build has one server entry, so there is no second app for a
761
- module to be confused with.
762
-
763
- If two React Router apps share one TypeScript program (one tsconfig over
764
- both folders of a monorepo), their two declarations conflict, and `tsc`
765
- says so (`Subsequent property declarations must have the same type`).
766
- Give each app its own tsconfig, or use the type argument in both.
767
-
768
- ### The server: `server`
769
-
770
- `alxiaOf(context).server` is core's `ctx.server`, the `Bun.Server` serving
771
- the request, `undefined` with none
772
- ([Serving](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/serving.md#the-server-ctxserver)).
773
- It is not `Register`'s `server`, which names what `createServer()` returned
774
- for the types. From an action, its `publish` reaches the app's
775
- [WebSocket](#websockets) subscribers:
776
-
777
- ```ts
778
- // app/routes/todos.tsx
779
- import { alxiaOf } from '@alxia/react-router';
780
- import type { Route } from './+types/todos';
781
-
782
- export async function action({ request, context }: Route.ActionArgs) {
783
- const title = String((await request.formData()).get('title'));
784
- // Bun's own publish: the bytes as given, not checked by a socket's `send` schema.
785
- alxiaOf(context).server?.publish('todos', JSON.stringify({ added: title }));
786
- return { added: title };
787
- }
788
- ```
789
-
790
- What it is, by how the app runs:
791
-
792
- | | `alxiaOf(context).server` |
793
- | --- | --- |
794
- | `bun build/server/index.js`, `server.start(app)`, `app.listen()` | the server `listen` started |
795
- | `react-router dev`, `vite preview` | the `Bun.Server` on a loopback port that the plugin relays the app's sockets to (Vite's own server is `node:http`, not a `Bun.Server`): `publish` reaches every socket the app opened, its `url` is that port's, and `requestIP` and `timeout` know nothing of the request Vite took, so `ctx.ip` stays `undefined` there |
796
- | Vite in middleware mode, a server of your own calling `app.fetch(request)` alone, `app.request(…)` in a test | `undefined` |
797
-
798
- ### The shorthand: `context.alxia`
799
-
800
- This package augments React Router's `RouterContextProvider` with a
801
- read-only `alxia` property: the object `alxiaOf(context)` returns, with no
802
- import and no call, typed by the same `Register` (core's, then
803
- `BaseContext`, with none of this package's):
804
-
805
- ```ts
806
- import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
807
-
808
- export function loader({ context }: LoaderFunctionArgs) {
809
- const { user, server } = context.alxia;
810
- return { name: user?.name ?? 'anonymous', listening: server !== undefined };
811
- }
812
-
813
- export async function action({ context }: ActionFunctionArgs) {
814
- context.alxia.server?.publish('todos', JSON.stringify({ changed: true }));
815
- return { ok: true };
816
- }
817
- ```
818
-
819
- **Typed with react-router's own argument types, not yet the generated
820
- ones.** `LoaderFunctionArgs`, `ActionFunctionArgs`, `MiddlewareFunction` and
821
- `RouterContextProvider`, imported from `react-router`, see `alxia`. The
822
- generated `Route.LoaderArgs` does not: the `+types` files import from
823
- `react-router/internal`, whose types (in react-router 8.4 and 8.5) point at its
824
- `dist/development` build, while `react-router` itself points at
825
- `dist/production`. That is a second declaration of `RouterContextProvider`,
826
- which no exported path reaches, so no package can augment it. With
827
- `Route.LoaderArgs`, `context.alxia` works at runtime and `tsc` reports
828
- TS2339 ([Troubleshooting](troubleshooting.md#property-alxia-does-not-exist-on-type-readonlyroutercontextprovider)):
829
- use `withAlxia` (or `alxiaOf(context)`) there, until react-router ships one declaration.
830
-
831
- It is a getter on the provider's prototype that reads `alxiaContext`: it
832
- holds exactly when that key is set, in dev as in production, and on a
833
- provider no catch-all filled it throws what `alxiaOf` throws. It is the
834
- server's alone: a `clientLoader` or a `clientAction` has no alxia context.
835
-
836
- ### Outside the catch-all
837
-
838
- Without the plugin, under a plain `react-router dev`, or in a unit test
839
- that calls a loader with a bare provider, there is no alxia context, and
840
- `alxiaOf` and `context.alxia` throw, saying so. The getter is installed
841
- when `@alxia/react-router` is first imported: a test whose program never
842
- imports it reads `context.alxia` as `undefined`. See
843
- [the troubleshooting entry](troubleshooting.md#alxiaof-this-request-has-no-alxia-context-).
34
+ Moved to [The server: `server`](guide/typing-the-loaders.md#the-server-server).
844
35
 
845
36
  ## The app's own context keys
846
37
 
847
- React Router 8 passes data to loaders through keys made by
848
- `createContext<T>()`, and matches a key by object identity. Under the
849
- plugin, the server is built and loaded with the routes, so a key in
850
- `app/` is one object for both:
851
-
852
- ```ts
853
- // app/context.ts
854
- import { createContext } from 'react-router';
855
-
856
- export const greetingContext = createContext<string>('unset');
857
- ```
858
-
859
- ```ts
860
- // app/server.ts
861
- import { createServer } from '@alxia/react-router';
862
- import { greetingContext } from './context';
863
-
864
- export default createServer({
865
- getLoadContext: (_ctx, context) => context.set(greetingContext, 'hello'),
866
- });
867
- ```
868
-
869
- ```ts
870
- // app/routes/home.tsx
871
- import { greetingContext } from '../context';
872
- import type { Route } from './+types/home';
873
-
874
- export function loader({ context }: Route.LoaderArgs) {
875
- return { greeting: context.get(greetingContext) };
876
- }
877
- ```
878
-
879
- `getLoadContext` runs before React Router, on every request, with
880
- `alxiaContext` already set. Its `ctx` is typed by `configure`'s app:
881
- reading something no middleware derives is a compile error. Keep the keys in a
882
- module of their own, such as `app/context.ts`, rather than in
883
- `app/server.ts`: a route imports them, and the server file then stays out
884
- of the client's module graph.
885
-
886
- A server of your own, without the plugin, holds another copy of every
887
- key under `app/`: see
888
- [the troubleshooting entry](troubleshooting.md#error-no-value-found-for-context).
38
+ Moved to [The app's own context keys](guide/typing-the-loaders.md#the-apps-own-context-keys).
889
39
 
890
40
  ## A CSP nonce
891
41
 
892
- React Router renders inline scripts: the hydration data, the module
893
- loader, the scroll restoration, and with streaming one more per resolved
894
- `<Await>`. A policy without `'unsafe-inline'` allows them only when each
895
- carries the nonce the policy names. React Router writes it on every one of
896
- them, and on its `modulepreload` links, when `entry.server.tsx` passes it
897
- to `<ServerRouter nonce>` and to React's renderer
898
- ([React Router's security guide](https://reactrouter.com/how-to/security)).
899
-
900
- `@alxia/secure-headers` makes the nonce: `secureHeaders({ nonce: true })`
901
- draws a fresh one per request, adds it to the policy's `script-src`, and
902
- puts it on the context of the routes after it, the catch-all included.
903
- `nonceOf(loadContext)` reads it in the entry.
904
-
905
- ### 1. The policy, in `app/server.ts`
906
-
907
- ```ts
908
- import { createServer } from '@alxia/react-router';
909
- import { secureHeaders } from '@alxia/secure-headers';
910
-
911
- export default createServer({
912
- configure: (app) =>
913
- app.use(
914
- secureHeaders({
915
- nonce: true,
916
- contentSecurityPolicy: [
917
- "default-src 'self'",
918
- "script-src 'self'",
919
- "style-src 'self' 'unsafe-inline'",
920
- "img-src 'self' data:",
921
- "connect-src 'self'",
922
- "form-action 'self'",
923
- "base-uri 'self'",
924
- "frame-ancestors 'none'",
925
- ].join('; '),
926
- }),
927
- ),
928
- });
929
- ```
930
-
931
- `script-src 'self'` goes out as `script-src 'self' 'nonce-…'`, a new
932
- value on every response. `'self'` still allows the bundles under
933
- `/assets`, and `connect-src 'self'` the single-fetch data requests.
934
-
935
- ### 2. The nonce, in `app/entry.server.tsx`
936
-
937
- React Router's template has no entry; reveal the default one, then add an
938
- import and the nonce in two places:
939
-
940
- ```sh
941
- bunx react-router reveal entry.server
942
- ```
943
-
944
- ```diff
945
- // app/entry.server.tsx, as reveal writes it
946
- import { PassThrough } from "node:stream";
947
-
948
- +import { nonceOf } from "@alxia/react-router";
949
- import type { EntryContext, RouterContextProvider } from "react-router";
950
- …
951
- const { pipe, abort } = renderToPipeableStream(
952
- - <ServerRouter context={routerContext} url={request.url} />,
953
- + <ServerRouter context={routerContext} url={request.url} nonce={nonceOf(loadContext)} />,
954
- {
955
- + nonce: nonceOf(loadContext),
956
- [readyOption]() {
957
- ```
958
-
959
- `loadContext` is the entry's fifth parameter, the `RouterContextProvider`
960
- alxia hands React Router; the revealed file already declares it. With
961
- `renderToReadableStream`, the web entry, the option is the same: `{ nonce:
962
- nonceOf(loadContext), … }`.
963
-
964
- That is all: under `react-router dev` and from the build, every `<script>`
965
- of a page carries the nonce of its own response's policy, Vite's dev
966
- scripts included.
967
-
968
- ### How it stays loose
969
-
970
- `nonceOf` reads `nonce` from alxia's context if it is a string, and
971
- returns `undefined` otherwise: no middleware set one, or the request did not come
972
- through alxia at all. `ServerRouter` and React then render no `nonce`
973
- attribute. So:
974
-
975
- - this package does not depend on `@alxia/secure-headers`, nor the reverse;
976
- - the same `entry.server.tsx` serves an app with or without the policy;
977
- - a nonce of your own works too, from any `derive` in `configure` that
978
- returns `{ nonce: string }`; the policy is then yours to write with it.
979
-
980
- ```ts
981
- configure: (app) => app.derive(() => ({ nonce: myNonce() })),
982
- ```
983
-
984
- Under `exactOptionalPropertyTypes`, spread it in only when there is one:
985
- see [the troubleshooting entry](troubleshooting.md#type---nonce-string--undefined--is-not-assignable-to-type-serverrouterprops-with-exactoptionalpropertytypes-true).
42
+ Moved to [A CSP nonce](guide/csp-nonce.md).
986
43
 
987
44
  ## Escape hatches
988
45
 
989
- ### Another server file
46
+ Moved to [Escape hatches](guide/customising-the-server.md#escape-hatches).
990
47
 
991
- `alxia({ entry })` names the server file, relative to Vite's root, in
992
- place of `app/server.ts`:
48
+ ## A server of your own, without the plugin
993
49
 
994
- ```ts
995
- // vite.config.ts
996
- import { alxia } from '@alxia/react-router/vite';
997
- import { reactRouter } from '@react-router/dev/vite';
998
- import { defineConfig } from 'vite';
999
-
1000
- export default defineConfig({
1001
- plugins: [reactRouter(), alxia({ entry: 'server/main.ts' })],
1002
- });
1003
- ```
1004
-
1005
- A missing `entry` is an error, where a missing `app/server.ts` falls back
1006
- to the default server.
1007
-
1008
- ### Overriding the wiring
1009
-
1010
- `build`, `mode` and `client` override what the plugin passes:
1011
-
1012
- ```ts
1013
- // app/server.ts: the client's files with headers of your own
1014
- import { createServer } from '@alxia/react-router';
1015
-
1016
- export default createServer({
1017
- client: false,
1018
- beforeAll: (app) =>
1019
- app.static('/assets', 'build/client/assets', {
1020
- cacheControl: 'public, max-age=31536000, immutable',
1021
- precompressed: ['br'],
1022
- }),
1023
- });
1024
- ```
1025
-
1026
- - **`client: false`** serves none of `build/client`: declare the files
1027
- yourself, in `beforeAll` or `configure`. A relative path there is
1028
- resolved against the process's working directory, the project's when it
1029
- runs `bun run start`.
1030
- - **`client`**, a path or a `file:` URL, serves another folder in its
1031
- place.
1032
- - **`mode`** forces React Router's server mode. `development` makes React
1033
- Router send its errors' stacks, and asks `build` for the build on every
1034
- request.
1035
- - **`build`**, a `ServerBuild` or a function returning one, replaces React
1036
- Router's build: rarely wanted.
1037
-
1038
- ### A server of your own, without the plugin
1039
-
1040
- `reactRouter(app, options)` is the catch-all itself, for a server that
1041
- imports the build. Without the plugin, the dev server is React Router's
1042
- own, and the loaders get no alxia context in dev.
1043
-
1044
- ```ts
1045
- // server.ts, beside build/
1046
- import { alxia } from '@alxia/core';
1047
- import { logger } from '@alxia/logger';
1048
- import { reactRouter } from '@alxia/react-router';
1049
- import type { ServerBuild } from 'react-router';
1050
-
1051
- export const base = alxia()
1052
- .use(logger())
1053
- .get('/api/health', ({ reply }) => reply.ok({ ok: true }));
1054
-
1055
- /** What the loaders read: alxiaOf<Base>(context). */
1056
- export type Base = typeof base;
1057
-
1058
- const app = base.plugin((app) =>
1059
- reactRouter(app, {
1060
- build: () =>
1061
- import(new URL('./build/server/index.js', import.meta.url).href) as Promise<ServerBuild>,
1062
- client: new URL('./build/client', import.meta.url),
1063
- }),
1064
- );
1065
-
1066
- app.listen({ port: Number(process.env['PORT'] ?? 3000) });
1067
- ```
1068
-
1069
- ```sh
1070
- bun run build && bun server.ts
1071
- ```
1072
-
1073
- | option | |
1074
- | --- | --- |
1075
- | `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` |
1076
- | `mode` | React Router's server mode, `production` by default |
1077
- | `getLoadContext(ctx, context)` | as `createServer`'s |
1078
- | `client` | the client build's folder, a path or a `file:` URL, served before the catch-all in `production` |
1079
-
1080
- `reactRouter()` goes through `app.plugin`, as `@alxia/graphql`'s `graphql(app, …)`
1081
- does: that is how it knows the app's context type. A `HEAD` is handed to
1082
- React Router as a `GET`, since React Router answers a `HEAD` of its own
1083
- with no headers at all; the core then drops the body. In a monorepo where
1084
- this package is linked rather than installed, add
1085
- `ssr: { external: ['@alxia/react-router'] }` to `vite.config.ts`: see
1086
- [the troubleshooting entry](troubleshooting.md#alxiaof-this-request-has-no-alxia-context-).
50
+ Moved to [A server of your own, without the plugin](guide/customising-the-server.md#a-server-of-your-own-without-the-plugin).
1087
51
 
1088
52
  ## Middlewares around the pages
1089
53
 
1090
- Every middleware declared before the catch-all runs around each page, as around
1091
- any route, and `use` observers go first so they wrap everything:
1092
-
1093
- - **`@alxia/logger`** writes one entry per request and sets
1094
- `x-request-id`. It times a streamed page by its first byte, since
1095
- the response is settled when its headers leave. Every request is logged,
1096
- 404s and errors included.
1097
- - **`@alxia/compress`** compresses documents and data, and flushes a
1098
- streamed page as React writes it: the shell and its `<Suspense>`
1099
- fallback still arrive first, in zstd, Brotli or gzip. See [the troubleshooting entry](troubleshooting.md#a-streamed-page-arrives-in-one-piece).
1100
- - **`@alxia/secure-headers`**' default policy, `default-src 'none'`, blocks
1101
- every script of the page, React Router's inline ones included, and its
1102
- `form-action 'none'` blocks a `<Form>`'s post. Give the pages a policy of
1103
- 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).
1104
- With `nonce: true`, the scripts need no `'unsafe-inline'`:
1105
- [A CSP nonce](#a-csp-nonce).
1106
- - **A guard** — `@alxia/jwt`'s `bearer`, `@alxia/janus`' session — in
1107
- `configure` guards every page and its data alike; in `beforeAll`, the
1108
- client's files too. On the app, a guard also answers a request no route
1109
- matches with its 401.
54
+ Moved to [Middlewares around the pages](guide/beside-the-pages.md#middlewares-around-the-pages).
1110
55
 
1111
56
  ## Routes beside the pages
1112
57
 
1113
- alxia's own routes answer their paths wherever they are declared:
1114
-
1115
- ```ts
1116
- // app/server.ts
1117
- import { createServer } from '@alxia/react-router';
1118
-
1119
- export default createServer({
1120
- configure: (app) =>
1121
- app
1122
- .get('/api/health', ({ reply }) => reply.ok({ ok: true }))
1123
- .get('/api/orders/:id', ({ params, reply }) => reply.ok({ id: params.id })),
1124
- });
1125
- ```
1126
-
1127
- Each answers with its own middlewares and replies. The core ranks
1128
- paths as `Bun.serve` does, through `listen`, `app.fetch` and
1129
- `app.request` alike: segment by segment, a literal beats a parameter,
1130
- which beats the catch-all's wildcard. Two consequences:
1131
-
1132
- - **The path is chosen before the method.** With only `POST /api/orders`,
1133
- a `GET /api/orders` is alxia's 405, not a page.
1134
- - **A route whose path covers pages takes them.** `GET /:slug` answers
1135
- every one-segment path, `/about` included, before React Router sees it.
1136
- Put alxia's routes under a prefix of their own, `/api`.
1137
-
1138
- Pages and single-fetch data are not something a generated client calls:
1139
- leave the catch-all out of `matchesSpec`'s check with `isReactRouterRoute`
1140
- ([OpenAPI](#openapi)); what a client does call, your `/api`, is what the
1141
- OpenAPI document declares.
1142
-
1143
- What React Router answers comes back as it sent it: documents,
1144
- single-fetch data (`/_.data`, `/login.data`), lazy route discovery
1145
- (`/__manifest`), redirects with every `Set-Cookie` they carry, and the
1146
- error pages, a 404 or a 500 rendered by the app's `ErrorBoundary`.
58
+ Moved to [Routes beside the pages](guide/beside-the-pages.md#routes-beside-the-pages).
1147
59
 
1148
60
  ## WebSockets
1149
61
 
1150
- A `ws` route declared in `configure` (or `beforeAll`) is a socket under
1151
- `react-router dev`, under `vite preview` and from
1152
- `bun build/server/index.js` alike. There is
1153
- nothing to add: no option, no package, no second server to start.
1154
-
1155
- ```ts
1156
- // app/server.ts
1157
- import { createServer } from '@alxia/react-router';
1158
-
1159
- export default createServer({
1160
- configure: (app) =>
1161
- app
1162
- .derive(({ request }) => {
1163
- const name = request.headers.get('x-user');
1164
- return { user: name === null ? null : { name } };
1165
- })
1166
- .ws('/api/echo', {}, {
1167
- open: (socket) => socket.send({ hello: socket.data.user?.name ?? 'anonymous' }),
1168
- message: (socket, message) => socket.send({ echo: String(message) }),
1169
- })
1170
- .group((guarded) =>
1171
- guarded
1172
- .derive(({ user, reply }) =>
1173
- user === null ? reply(401, { error: 'unauthenticated' as const }) : { user },
1174
- )
1175
- .ws('/api/rooms/:room', {}, {
1176
- open: (socket) => socket.subscribe(socket.data.params.room),
1177
- message: (socket, message) =>
1178
- socket.publish(socket.data.params.room, {
1179
- from: socket.data.user.name,
1180
- text: String(message),
1181
- }),
1182
- }),
1183
- ),
1184
- });
1185
- ```
1186
-
1187
- The upgrade runs the middlewares declared before the route, and the route's
1188
- validation, as any request does. A middleware's reply refuses it: the client
1189
- gets the 401 and its body, and no socket opens. `socket.data` holds the
1190
- validated request and what each middleware derived, typed. See
1191
- [`@alxia/core`'s WebSockets](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/websockets.md)
1192
- for the schemas and the rest of the socket.
1193
-
1194
- The guard sits in a `group` so it refuses the sockets alone: declared on
1195
- `app` itself, it would apply to every route after it, the pages'
1196
- catch-all included.
1197
-
1198
- ### Under `react-router dev`
1199
-
1200
- Vite's dev server is a `node:http` server, and an alxia socket is
1201
- `Bun.serve`'s upgrade. The plugin listens for Vite's `upgrade` event and
1202
- leaves Vite's own socket alone: an upgrade asking for the `vite-hmr` or
1203
- `vite-ping` subprotocol is Vite's, and HMR works as before. Any other
1204
- upgrade, except one Vite's `server.proxy` relays itself (an entry with
1205
- `ws: true` or a `ws:` target, matched as Vite matches it), is relayed,
1206
- byte for byte, to a `Bun.serve` of the app on a loopback port of its
1207
- own, started on the first upgrade or the first request and given
1208
- `app.fetch` and `app.websocket`, as `listen` gives them. So in dev:
1209
-
1210
- - **The same middlewares, refusals and handlers run** as from the build:
1211
- `open`, `message`, `close`, `drain`, the `message` and `send` schemas,
1212
- `subscribe` and `publish`.
1213
- - **An edit to `app/server.ts`, or to a module it imports, is used from
1214
- the next connection.** The edit makes a new app, and the next upgrade
1215
- starts a new `Bun.serve` for it. A socket opened before the edit keeps
1216
- the handlers it opened with until it closes.
1217
- - **A `publish` reaches the sockets opened since the same edit**: the
1218
- sockets opened before it are on the previous server. Reload the pages
1219
- after an edit to reconnect them.
1220
- - **A loader's or an action's `alxiaOf(context).server` is that loopback
1221
- server**, the one the sockets are on, so its `publish` reaches them as
1222
- from the build: see [The server](#the-server-server).
1223
- - **A socket's `ctx.ip` is the loopback address**, the relay's. The
1224
- browser is local in dev, so that is usually its address anyway.
1225
- - **An upgrade to a path with no `ws` route** gets what the build answers
1226
- it too, never a socket: alxia's 404 where nothing matches, the page
1227
- where the catch-all does.
1228
- - **Another plugin that listens for upgrades** competes with the relay
1229
- on the paths it shares with it: give its socket a path under a
1230
- `server.proxy` entry with `ws: true` or a `ws:` target, or outside the
1231
- app's.
1232
- - **With Vite in middleware mode**, there is no `node:http` server of
1233
- Vite's to listen on: serve the app yourself, as in
1234
- [A server of your own](#a-server-of-your-own-without-the-plugin).
1235
-
1236
- Under `vite preview` the same relay runs, to a `Bun.serve` of the built
1237
- app, loaded once. Under `bun build/server/index.js`, nothing of this runs:
1238
- `listen` serves the sockets itself, as any alxia app's.
62
+ Moved to [WebSockets](guide/beside-the-pages.md#websockets).
1239
63
 
1240
64
  ## The client's files
1241
65
 
1242
- In a build, each top-level entry of `build/client` becomes a route, after
1243
- `beforeAll` and before `configure`:
1244
-
1245
- | entry | route | `Cache-Control` |
1246
- | --- | --- | --- |
1247
- | `assets/`, the hashed bundles | `static('/assets', …)` | `public, max-age=31536000, immutable` |
1248
- | another folder, such as `images/` from `public/images/` | `static('/images', …)` | `public, max-age=3600` |
1249
- | a file, such as `robots.txt` or `favicon.ico` | `file('/robots.txt', …)` | `public, max-age=3600` |
1250
-
1251
- They are the core's `static` and `file` routes, so ETags,
1252
- `Last-Modified`, 304s and ranges are included. A missing file under one
1253
- of those folders gets alxia's JSON 404, `{ "error": "not_found" }`, not a
1254
- page, so a folder of `public/` shadows any page path under the same name.
1255
- Dotfiles are skipped. In dev, Vite serves them.
1256
-
1257
- A folder that is not one, or a file whose name no route can carry, is
1258
- refused when the server starts: see
1259
- [the troubleshooting entries](troubleshooting.md#typeerror-reactrouter-client-is--which-is-not-a-directory-).
66
+ Moved to [The client's files](guide/beside-the-pages.md#the-clients-files).
1260
67
 
1261
68
  ## OpenAPI
1262
69
 
1263
- alxia is OpenAPI spec first: the document is written by hand, the API's
1264
- routes are bound to the operations generated from it, and
1265
- [`@alxia/openapi`](https://www.npmjs.com/package/@alxia/openapi)'s
1266
- `matchesSpec` checks, in a test, that every operation has its route; with
1267
- `strict: true`, that `app.routes` and the operations match both ways. The
1268
- catch-all and the client's files are routes of `app.routes`, and no
1269
- operation of the document. Under `strict`, leave them out with
1270
- `isReactRouterRoute`, which is true for each route this package declared:
1271
-
1272
- ```ts
1273
- // app/server.ts
1274
- import { createServer } from '@alxia/react-router';
1275
- import { operations } from './generated/alxia';
1276
-
1277
- export default createServer({
1278
- configure: (app) =>
1279
- app
1280
- .get('/api/health', ({ reply }) => reply.ok({ ok: true }))
1281
- .route(operations.listOrders, ({ reply }) => reply.ok([])),
1282
- });
1283
- ```
1284
-
1285
- ```ts
1286
- // app/server.test.ts
1287
- import { test } from 'bun:test';
1288
- import { matchesSpec } from '@alxia/openapi';
1289
- import { isReactRouterRoute } from '@alxia/react-router';
1290
- import type { ServerBuild } from 'react-router';
1291
- import { operations } from './generated/alxia';
1292
- import server from './server';
1293
-
1294
- test('the API routes every operation of openapi.yaml, and nothing else', async () => {
1295
- const build: ServerBuild = await import(new URL('../build/server/index.js', import.meta.url).href);
1296
- const app = server.create({ build });
1297
- matchesSpec(app, operations, {
1298
- strict: true,
1299
- exclude: (route) => isReactRouterRoute(route) || route.path === '/api/health',
1300
- });
1301
- });
1302
- ```
1303
-
1304
- When the app puts the operations under a prefix the document's paths
1305
- leave out, `app.group('/api', (api) => api.route(…))`, give `matchesSpec`
1306
- the same `prefix: '/api'`. See
1307
- [`@alxia/openapi`'s matching](https://github.com/softistx/alxia/blob/develop/packages/openapi/docs/guide/matching.md#under-a-prefix).
70
+ Moved to [OpenAPI](guide/openapi.md).
1308
71
 
1309
72
  ## Testing
1310
73
 
1311
- `server.create(wiring)` makes the app in process, from a build: import
1312
- the server, and pass it React Router's build, which
1313
- `build/server/index.js` holds. The test needs Bun's types:
1314
- `bun add -d @types/bun`, and `"bun"` in the tsconfig's `types`.
1315
-
1316
- ```ts
1317
- // app/server.test.ts
1318
- import { expect, test } from 'bun:test';
1319
- import type { ServerBuild } from 'react-router';
1320
- import server from './server';
1321
-
1322
- const BROWSER = 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36';
1323
-
1324
- test('the home page renders', async () => {
1325
- // After `bun run build`: React Router's build is inside the server build.
1326
- const build: ServerBuild = await import(new URL('../build/server/index.js', import.meta.url).href);
1327
- const app = server.create({ build, client: new URL('../build/client', import.meta.url) });
1328
- const response = await app.request('/', { headers: { 'user-agent': BROWSER } });
1329
- expect(response.status).toBe(200);
1330
- });
1331
- ```
1332
-
1333
- Or drive the built app itself, the default export of
1334
- `build/server/index.js`: importing it starts no server. Or run it, as
1335
- [the example's spec](https://github.com/softistx/alxia/blob/develop/examples/react-router/app/server.spec.ts)
1336
- does: `bun build/server/index.js` with `PORT=0`, then read the URL from
1337
- its `alxia listening on <url>` line.
1338
-
1339
- Send a browser's user agent. `isbot('Bun/1.4.2')` is true, and React
1340
- Router's entry waits for the whole page before it answers a bot, so a test
1341
- with Bun's own user agent never sees a page stream. An index route's action
1342
- is `POST /?index`, not `POST /`.
1343
-
1344
- `app.request` runs without a server, so a loader's `alxiaOf(context).server`
1345
- is `undefined` there, and a `publish` it guards with `?.` sends nothing.
1346
- To test one, listen on a free port, `app.listen({ port: 0 })`, open the
1347
- socket, then post to the action (`/todos.data`, the single-fetch URL, for
1348
- its data rather than the page).
74
+ Moved to [Testing](guide/testing.md).
1349
75
 
1350
76
  ## Deploying
1351
77
 
1352
- Run `bun run build`, then `bun build/server/index.js`. The build is
1353
- [self-contained](#self-contained): `build/` is all the server needs, with
1354
- no `node_modules`, unless the app keeps a package external with
1355
- `ssr.external`.
1356
-
1357
- ### Docker
1358
-
1359
- The `react-router` template of
1360
- [`@alxia/create`](https://github.com/softistx/alxia/tree/develop/packages/create)
1361
- and the [example](https://github.com/softistx/alxia/tree/develop/examples/react-router)
1362
- ship this `Dockerfile`, on Bun's official images: built on `oven/bun:1`,
1363
- Debian's, and run on `oven/bun:1-alpine`, about 130 MB. For an app started from
1364
- `create-react-router`, it replaces the template's, which builds and runs
1365
- on Node:
1366
-
1367
- ```dockerfile
1368
- # Bun builds the app, and the image holds build/ alone: alxia's plugin
1369
- # bundles every dependency into build/server/index.js, alxia's server, which
1370
- # runs on Bun with no node_modules. Pinned to Bun 1, the major alxia
1371
- # supports.
1372
-
1373
- # Every dependency, then react-router build: build/client and
1374
- # build/server/index.js.
1375
- FROM oven/bun:1 AS build
1376
- WORKDIR /app
1377
- COPY package.json bun.lock* bunfig.toml* ./
1378
- RUN bun install --frozen-lockfile
1379
- COPY . .
1380
- RUN bun run build
1381
-
1382
- # The build, run as Bun's own non-root user with the start script's
1383
- # command, and --no-install: with no node_modules, Bun would otherwise
1384
- # fetch a package the build left out from the registry at startup,
1385
- # where this fails.
1386
- # The server listens on PORT (3000) and HOST (0.0.0.0). On Alpine, about
1387
- # 200 MB lighter: build/ is JavaScript, which runs the same on musl. A
1388
- # native addon built for glibc alone needs oven/bun:1 here.
1389
- FROM oven/bun:1-alpine
1390
- WORKDIR /app
1391
- ENV NODE_ENV=production
1392
- COPY --from=build /app/build ./build
1393
- USER bun
1394
- EXPOSE 3000
1395
- CMD ["bun", "--no-install", "build/server/index.js"]
1396
- ```
1397
-
1398
- ```sh
1399
- docker build -t my-app .
1400
- docker run -p 3000:3000 my-app
1401
- ```
1402
-
1403
- Keep the template's `.dockerignore` beside it: `node_modules`, `build` and
1404
- `.react-router` stay out of the context, so the image installs and builds
1405
- its own. The `bunfig.toml` goes in, and `bun run build` runs React
1406
- Router's CLI on Bun, as it does outside Docker. The image's command is
1407
- `start`'s, run directly, so the platform's `SIGTERM` reaches the server,
1408
- with `--no-install`: a package missing from `build/` then fails at
1409
- startup, where Bun would otherwise fetch it from npm.
1410
-
1411
- - **Commit `bun.lock`.** The installs are `--frozen-lockfile`: the image
1412
- gets the versions you tested, and a `package.json` changed without a
1413
- `bun install` fails the build
1414
- ([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#error-lockfile-had-changes-but-lockfile-is-frozen)).
1415
- - **The final stage is Alpine's**, musl where the build stage has glibc.
1416
- `build/` is JavaScript, which runs the same on both. A native addon
1417
- kept external and built for glibc alone does not load there: put the
1418
- final stage back on `oven/bun:1`
1419
- ([`@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)).
1420
- - **The server runs as `bun`**, a user with no write access to `/app`.
1421
- Write files to a volume the user owns
1422
- ([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#eacces-permission-denied-open-app)).
1423
- - **The image holds `build/` alone**, no `node_modules`: the build is
1424
- [self-contained](#self-contained). An app that keeps a package external
1425
- with `ssr.external` copies the production dependencies too, from a
1426
- stage of their own:
1427
-
1428
- ```dockerfile
1429
- # Before the build stage: the production dependencies alone.
1430
- FROM oven/bun:1 AS production-dependencies
1431
- WORKDIR /app
1432
- COPY package.json bun.lock* bunfig.toml* ./
1433
- RUN bun install --frozen-lockfile --production
1434
-
1435
- # In the final stage, beside build/.
1436
- COPY --from=production-dependencies /app/node_modules ./node_modules
1437
- ```
78
+ Moved to [Deploying](guide/deploying.md).
1438
79
 
1439
- Without it, the container stops at startup with
1440
- `Cannot find package '…'`
1441
- ([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#error-cannot-find-package--from-appbuildserverindexjs)).
80
+ ## Docker
1442
81
 
1443
- `PORT` and `HOST` set where the server listens. The platform's `SIGTERM`
1444
- stops it once the requests in flight are answered, within
1445
- `shutdownTimeout`, and runs the app's `onStop` hooks. Give the platform a
1446
- readiness probe with `@alxia/core`'s `health()`, mounted in `beforeAll`.
82
+ Moved to [Docker](guide/deploying.md#docker).