@alxia/react-router 0.8.1 → 0.9.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.
- package/README.md +107 -8
- package/dist/chunks/index-zqa30gr7.js +139 -0
- package/dist/chunks/index-zqa30gr7.js.map +12 -0
- package/dist/pwa/index.d.ts +14 -0
- package/dist/pwa/index.d.ts.map +1 -0
- package/dist/pwa/index.js +165 -0
- package/dist/pwa/index.js.map +14 -0
- package/dist/pwa/model/caching.d.ts +46 -0
- package/dist/pwa/model/caching.d.ts.map +1 -0
- package/dist/pwa/model/clear-app-caches.d.ts +9 -0
- package/dist/pwa/model/clear-app-caches.d.ts.map +1 -0
- package/dist/pwa/model/manifest.d.ts +80 -0
- package/dist/pwa/model/manifest.d.ts.map +1 -0
- package/dist/pwa/model/precache-list.d.ts +40 -0
- package/dist/pwa/model/precache-list.d.ts.map +1 -0
- package/dist/pwa/model/registration.d.ts +39 -0
- package/dist/pwa/model/registration.d.ts.map +1 -0
- package/dist/pwa/model/start-service-worker.d.ts +24 -0
- package/dist/pwa/model/start-service-worker.d.ts.map +1 -0
- package/dist/pwa/model/storable.d.ts +8 -0
- package/dist/pwa/model/storable.d.ts.map +1 -0
- package/dist/pwa/model/update-notice.d.ts +38 -0
- package/dist/pwa/model/update-notice.d.ts.map +1 -0
- package/dist/pwa/model/worker-contract.d.ts +61 -0
- package/dist/pwa/model/worker-contract.d.ts.map +1 -0
- package/dist/pwa/react/index.d.ts +9 -0
- package/dist/pwa/react/index.d.ts.map +1 -0
- package/dist/pwa/react/index.js +75 -0
- package/dist/pwa/react/index.js.map +13 -0
- package/dist/pwa/react/use-online.d.ts +6 -0
- package/dist/pwa/react/use-online.d.ts.map +1 -0
- package/dist/pwa/react/use-service-worker.d.ts +33 -0
- package/dist/pwa/react/use-service-worker.d.ts.map +1 -0
- package/dist/pwa/react/use-update-notice.d.ts +14 -0
- package/dist/pwa/react/use-update-notice.d.ts.map +1 -0
- package/dist/pwa/server/index.d.ts +7 -0
- package/dist/pwa/server/index.d.ts.map +1 -0
- package/dist/pwa/server/index.js +18 -0
- package/dist/pwa/server/index.js.map +10 -0
- package/dist/pwa/server/no-cache.d.ts +22 -0
- package/dist/pwa/server/no-cache.d.ts.map +1 -0
- package/docs/README.md +12 -3
- package/docs/guide/beside-the-pages.md +178 -0
- package/docs/guide/csp-nonce.md +100 -0
- package/docs/guide/customising-the-server.md +292 -0
- package/docs/guide/deploying.md +99 -0
- package/docs/guide/getting-started.md +112 -0
- package/docs/guide/how-it-works.md +262 -0
- package/docs/guide/openapi.md +51 -0
- package/docs/guide/pwa.md +475 -0
- package/docs/guide/testing.md +42 -0
- package/docs/guide/typing-the-loaders.md +311 -0
- package/docs/guide.md +27 -1391
- package/docs/roadmap.md +25 -1
- package/docs/troubleshooting.md +194 -8
- package/package.json +26 -4
package/docs/guide.md
CHANGED
|
@@ -1,1446 +1,82 @@
|
|
|
1
1
|
# Guide
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
+
## The server: `server`
|
|
685
33
|
|
|
686
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
Moved to [Escape hatches](guide/customising-the-server.md#escape-hatches).
|
|
990
47
|
|
|
991
|
-
|
|
992
|
-
place of `app/server.ts`:
|
|
48
|
+
## A server of your own, without the plugin
|
|
993
49
|
|
|
994
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|