@alxia/react-router 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +289 -2
- package/dist/assets.d.ts +12 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/chunks/index-kfekhvgr.js +31 -0
- package/dist/chunks/index-kfekhvgr.js.map +10 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +160 -0
- package/dist/cli/index.js.map +13 -0
- package/dist/cli/main.d.ts +9 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/reveal.d.ts +18 -0
- package/dist/cli/reveal.d.ts.map +1 -0
- package/dist/cli/template.d.ts +8 -0
- package/dist/cli/template.d.ts.map +1 -0
- package/dist/context.d.ts +70 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +166 -0
- package/dist/index.js.map +13 -0
- package/dist/react-router.d.ts +61 -0
- package/dist/react-router.d.ts.map +1 -0
- package/dist/server.d.ts +99 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/vite/config.d.ts +33 -0
- package/dist/vite/config.d.ts.map +1 -0
- package/dist/vite/dev.d.ts +23 -0
- package/dist/vite/dev.d.ts.map +1 -0
- package/dist/vite/entry.d.ts +20 -0
- package/dist/vite/entry.d.ts.map +1 -0
- package/dist/vite/index.d.ts +33 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +391 -0
- package/dist/vite/index.js.map +16 -0
- package/dist/vite/node.d.ts +16 -0
- package/dist/vite/node.d.ts.map +1 -0
- package/dist/vite/preview.d.ts +8 -0
- package/dist/vite/preview.d.ts.map +1 -0
- package/dist/vite/runtime.d.ts +8 -0
- package/dist/vite/runtime.d.ts.map +1 -0
- package/dist/vite/socket.d.ts +68 -0
- package/dist/vite/socket.d.ts.map +1 -0
- package/docs/README.md +12 -0
- package/docs/guide.md +843 -0
- package/docs/roadmap.md +96 -0
- package/docs/troubleshooting.md +832 -0
- package/package.json +77 -5
package/docs/guide.md
ADDED
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
# Guide
|
|
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`,
|
|
7
|
+
customising the server, typing the loaders, the app's own context keys,
|
|
8
|
+
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
|
+
- [Customising the server](#customising-the-server)
|
|
14
|
+
- [Typing the loaders](#typing-the-loaders)
|
|
15
|
+
- [The app's own context keys](#the-apps-own-context-keys)
|
|
16
|
+
- [Escape hatches](#escape-hatches)
|
|
17
|
+
- [Hooks around the pages](#hooks-around-the-pages)
|
|
18
|
+
- [Routes beside the pages](#routes-beside-the-pages)
|
|
19
|
+
- [WebSockets](#websockets)
|
|
20
|
+
- [The client's files](#the-clients-files)
|
|
21
|
+
- [OpenAPI](#openapi)
|
|
22
|
+
- [Testing](#testing)
|
|
23
|
+
- [Deploying](#deploying)
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
Start from React Router's official template:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
bunx create-react-router@latest my-app
|
|
31
|
+
cd my-app
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then make three changes.
|
|
35
|
+
|
|
36
|
+
**1. Install** alxia and this package:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
bun add @alxia/core @alxia/react-router
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**2. Add `alxia()`** to the plugins in `vite.config.ts`:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// vite.config.ts
|
|
46
|
+
import { alxia } from '@alxia/react-router/vite';
|
|
47
|
+
import { reactRouter } from '@react-router/dev/vite';
|
|
48
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
49
|
+
import { defineConfig } from 'vite';
|
|
50
|
+
|
|
51
|
+
export default defineConfig({
|
|
52
|
+
plugins: [tailwindcss(), reactRouter(), alxia()],
|
|
53
|
+
resolve: {
|
|
54
|
+
tsconfigPaths: true,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The order does not matter. The plugin always runs before React Router's:
|
|
60
|
+
React Router's build reads its server input from it, and its dev
|
|
61
|
+
middleware must come after alxia's.
|
|
62
|
+
|
|
63
|
+
**3. Start with Bun**, in `package.json`:
|
|
64
|
+
|
|
65
|
+
```jsonc
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "react-router build",
|
|
68
|
+
"dev": "react-router dev",
|
|
69
|
+
"start": "bun build/server/index.js",
|
|
70
|
+
"typecheck": "react-router typegen && tsc"
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Only `start` changes. The `react-router` and `vite` CLIs start with
|
|
75
|
+
`#!/usr/bin/env node`, so where a node is installed `bun run` would run
|
|
76
|
+
them on Node, and alxia's server needs Bun. A `bunfig.toml` beside
|
|
77
|
+
`package.json` makes `bun run` start them on Bun, for every script:
|
|
78
|
+
|
|
79
|
+
```toml
|
|
80
|
+
# bunfig.toml, beside package.json
|
|
81
|
+
[run]
|
|
82
|
+
bun = true
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Then `bun run dev`, `bun run build` and `bun run typecheck` work as they
|
|
86
|
+
are, with or without a node installed. Without that file, run them as
|
|
87
|
+
`bun --bun react-router dev`. The plugin refuses a dev or preview server
|
|
88
|
+
running on Node at startup, rather than failing on the first request:
|
|
89
|
+
see [the troubleshooting entry](troubleshooting.md#alxia-react-router--is-running-on-node-and-alxias-server-runs-on-bun-). The
|
|
90
|
+
template's `@react-router/serve` is no longer used, and you can remove it.
|
|
91
|
+
|
|
92
|
+
What the template keeps:
|
|
93
|
+
|
|
94
|
+
- `@react-router/node`, which React Router's default `entry.server`
|
|
95
|
+
renders with: it streams under Bun.
|
|
96
|
+
- Vite 7 or 8 (the template ships 8).
|
|
97
|
+
- Its `typescript` 5.9. This package's peer asks for 6 or 7, so `bun add`
|
|
98
|
+
warns, but the template's code typechecks with 5.9.
|
|
99
|
+
`bun add -d typescript@^6` silences the warning.
|
|
100
|
+
|
|
101
|
+
React Router 8 is required: its loaders receive a `RouterContextProvider`,
|
|
102
|
+
and alxia's context is set on it.
|
|
103
|
+
|
|
104
|
+
## How it works
|
|
105
|
+
|
|
106
|
+
With no `app/server.ts`, the plugin serves the app with
|
|
107
|
+
`createServer()`, from this package, with no options: a fresh alxia app,
|
|
108
|
+
the client's files, and the pages as a catch-all behind it.
|
|
109
|
+
|
|
110
|
+
### In dev
|
|
111
|
+
|
|
112
|
+
`react-router dev` starts Vite's server. Vite answers its own requests
|
|
113
|
+
first: modules, `/@fs/…`, `public/`, the HMR socket. The plugin hands
|
|
114
|
+
every other request to the server: pages, single-fetch data, and alxia's
|
|
115
|
+
routes. The server is loaded through Vite's SSR runner, so:
|
|
116
|
+
|
|
117
|
+
- **HMR** works as in any React Router app: a component edit reaches the
|
|
118
|
+
browser over Vite's socket.
|
|
119
|
+
- **An edit to `app/server.ts`, or to a module it imports, is live on the
|
|
120
|
+
next request**, with no restart: the runner reloads what changed.
|
|
121
|
+
- **Creating or deleting `app/server.ts`** takes effect on the next
|
|
122
|
+
request.
|
|
123
|
+
- **The app's own context keys work**: the server and the routes are one
|
|
124
|
+
module graph. See [The app's own context keys](#the-apps-own-context-keys).
|
|
125
|
+
- **Errors** go to Vite's error page, with the stack mapped to the source.
|
|
126
|
+
- **alxia's `ws` routes connect**: an upgrade Vite's HMR does not claim
|
|
127
|
+
goes to the app, as from the build. See [WebSockets](#websockets).
|
|
128
|
+
|
|
129
|
+
HTTP requests reach the app through `app.fetch`, as in a test, not
|
|
130
|
+
through `listen`. So `page()` and an HTTP request's `ctx.server` are
|
|
131
|
+
absent in dev; see [the troubleshooting entry](troubleshooting.md#ctxserver-is-undefined-under-react-router-dev).
|
|
132
|
+
|
|
133
|
+
### In a build
|
|
134
|
+
|
|
135
|
+
`react-router build` builds the client as usual. It builds the server
|
|
136
|
+
from `app/server.ts`, or from the default server, with React Router's
|
|
137
|
+
server build inside it, into one file:
|
|
138
|
+
|
|
139
|
+
- **`build/server/index.js`'s default export is the alxia app.** Beside it
|
|
140
|
+
are React Router's own exports, so the file is a server build too, and
|
|
141
|
+
React Router's `prerender` reads it. The server file's other exports
|
|
142
|
+
stay out of it.
|
|
143
|
+
- **Run, it listens**: `bun build/server/index.js` listens on `PORT`
|
|
144
|
+
(3000 by default) and `HOST` (`0.0.0.0`). It prints
|
|
145
|
+
`alxia listening on <url>`. On `SIGINT` or `SIGTERM` it stops the app,
|
|
146
|
+
runs its `onStop` hooks, and exits.
|
|
147
|
+
- **Imported, it starts nothing**: prerendering, a test or another server
|
|
148
|
+
can import it safely, since it listens only when it is the process's
|
|
149
|
+
entry point (`import.meta.main`).
|
|
150
|
+
- **The mode follows the command**: `development` under `react-router dev`,
|
|
151
|
+
`production` in a build, whatever `NODE_ENV` says.
|
|
152
|
+
- **The client folder is resolved against the built file**: `../client`,
|
|
153
|
+
from React Router's `buildDirectory`, so it is found wherever the
|
|
154
|
+
process starts.
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
bun run build
|
|
158
|
+
PORT=8080 bun run start
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
React Router's `serverBundles` split the server build in several, and
|
|
162
|
+
alxia serves one: the plugin refuses them. With `ssr: false`, a
|
|
163
|
+
single-page app, the plugin does nothing.
|
|
164
|
+
|
|
165
|
+
### Under `vite preview`
|
|
166
|
+
|
|
167
|
+
After a build, Vite's preview server serves the built server:
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
bun run build
|
|
171
|
+
bunx --bun vite preview
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`--bun` runs Vite under Bun even where Node is installed, since Vite's bin
|
|
175
|
+
asks for Node: the built server runs inside Vite's process, and it needs
|
|
176
|
+
Bun's APIs. With the [`bunfig.toml`](#setup) (`[run]`,
|
|
177
|
+
`bun = true`), or with no Node installed, a `"preview": "vite preview"`
|
|
178
|
+
script run with `bun run preview` works too, as the template's other
|
|
179
|
+
scripts do.
|
|
180
|
+
|
|
181
|
+
The plugin loads `build/server/index.js` on the first request and hands
|
|
182
|
+
every request to its default export, the alxia app, before Vite's own
|
|
183
|
+
files. So the preview answers as `bun run start` does: the pages and their
|
|
184
|
+
data, `/api`, `build/client` with the cache headers of
|
|
185
|
+
[the client's files](#the-clients-files), and every hook of `beforeAll`
|
|
186
|
+
and `configure`. Requests, their bodies and every `Set-Cookie` pass
|
|
187
|
+
through whole, and pages stream.
|
|
188
|
+
|
|
189
|
+
What differs from `bun run start`:
|
|
190
|
+
|
|
191
|
+
- **Vite listens**, on `preview.port` (4173), `preview.host` and
|
|
192
|
+
`preview.https`. `listen`, `onListen`, `PORT` and `HOST` are not read.
|
|
193
|
+
- **Requests arrive through `app.fetch`**, as under `react-router dev`:
|
|
194
|
+
`page()` and an HTTP request's `ctx.server` are absent. alxia's `ws`
|
|
195
|
+
routes connect, relayed to a `Bun.serve` of the built app as in dev
|
|
196
|
+
([WebSockets](#websockets)); an upgrade `preview.proxy` relays stays
|
|
197
|
+
Vite's.
|
|
198
|
+
- **Vite's preview options that come after the plugin never run**:
|
|
199
|
+
`preview.proxy` for HTTP requests, `preview.headers` and Vite's file
|
|
200
|
+
serving.
|
|
201
|
+
`preview.cors` and `preview.allowedHosts` still apply.
|
|
202
|
+
- **The build is loaded once**: after `bun run build` again, restart the
|
|
203
|
+
preview.
|
|
204
|
+
|
|
205
|
+
React Router prerenders the `prerender` paths of `react-router.config.ts`
|
|
206
|
+
through the same preview server, during `react-router build`. A
|
|
207
|
+
prerendered page is therefore rendered by the built server too: its loader
|
|
208
|
+
reads `alxiaOf(context)` and `getLoadContext`'s keys, and `beforeAll`'s
|
|
209
|
+
and `configure`'s hooks run around it.
|
|
210
|
+
|
|
211
|
+
## Customising the server
|
|
212
|
+
|
|
213
|
+
Write `app/server.ts`, with `createServer()` as its default export, or let
|
|
214
|
+
the package's bin write it for you.
|
|
215
|
+
|
|
216
|
+
### Revealing the default server
|
|
217
|
+
|
|
218
|
+
From the app's root, once `@alxia/react-router` is installed:
|
|
219
|
+
|
|
220
|
+
```sh
|
|
221
|
+
bunx alxia-react-router reveal
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```text
|
|
225
|
+
alxia-react-router: wrote app/server.ts, the server alxia() runs by default.
|
|
226
|
+
Next: uncomment configure in app/server.ts to add the app's hooks and /api; bun run dev picks it up.
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The file is the server the plugin runs without one, `createServer()`, so
|
|
230
|
+
the app answers as before. It holds `beforeAll`, `configure` and
|
|
231
|
+
`getLoadContext`, commented, each of which compiles once uncommented
|
|
232
|
+
(`getLoadContext`'s key is yours to make, below), and the `Register`
|
|
233
|
+
declaration that types the loaders:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
// app/server.ts, as reveal writes it, less its comments
|
|
237
|
+
import { createServer } from '@alxia/react-router';
|
|
238
|
+
// import { userAgentContext } from './context';
|
|
239
|
+
|
|
240
|
+
const server = createServer({
|
|
241
|
+
// beforeAll: (app) => app,
|
|
242
|
+
// configure: (app) => app.get('/api/health', ({ reply }) => reply.ok({ ok: true })),
|
|
243
|
+
// getLoadContext: (ctx, context) => {
|
|
244
|
+
// context.set(userAgentContext, ctx.request.headers.get('user-agent'));
|
|
245
|
+
// },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
export default server;
|
|
249
|
+
|
|
250
|
+
declare module '@alxia/react-router' {
|
|
251
|
+
interface Register {
|
|
252
|
+
server: typeof server;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`getLoadContext`'s example sets a key of the app's own, imported from
|
|
258
|
+
`app/context.ts`. Make it there before uncommenting both lines:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
// app/context.ts
|
|
262
|
+
import { createContext } from 'react-router';
|
|
263
|
+
|
|
264
|
+
export const userAgentContext = createContext<string | null>(null);
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
- **Where it writes**: the `entry` that `alxia({ entry: '…' })` names in
|
|
268
|
+
`vite.config.ts`, or `server.ts` in React Router's `appDirectory`
|
|
269
|
+
(`app/` unless `react-router.config.ts` names another). It reads both as
|
|
270
|
+
string literals, past comments. A computed one is refused, since
|
|
271
|
+
reveal would write a file the plugin does not load: give it as a
|
|
272
|
+
literal.
|
|
273
|
+
- **It never overwrites**: a file already there is left as it is, and the
|
|
274
|
+
command exits 1. `bunx alxia-react-router reveal --force` overwrites it.
|
|
275
|
+
- **Bun only**: the bin starts with `#!/usr/bin/env bun`, so no Node is
|
|
276
|
+
needed.
|
|
277
|
+
|
|
278
|
+
React Router's own rendering entries, `app/entry.server.tsx` and
|
|
279
|
+
`app/entry.client.tsx`, are revealed by React Router:
|
|
280
|
+
`bunx react-router reveal`.
|
|
281
|
+
|
|
282
|
+
### By hand
|
|
283
|
+
|
|
284
|
+
The example uses `@alxia/logger` and `@alxia/compress`
|
|
285
|
+
(`bun add @alxia/logger @alxia/compress`); any plugin works the same way.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
// app/server.ts
|
|
289
|
+
import { compress } from '@alxia/compress';
|
|
290
|
+
import { logger } from '@alxia/logger';
|
|
291
|
+
import { createServer } from '@alxia/react-router';
|
|
292
|
+
|
|
293
|
+
const server = createServer({
|
|
294
|
+
configure: (app) =>
|
|
295
|
+
app
|
|
296
|
+
.use(logger())
|
|
297
|
+
.use(compress())
|
|
298
|
+
.get('/api/health', ({ reply }) => reply.ok({ ok: true }))
|
|
299
|
+
.derive(({ request }) => {
|
|
300
|
+
const name = request.headers.get('x-user');
|
|
301
|
+
return { user: name === null ? null : { name } };
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
export default server;
|
|
306
|
+
|
|
307
|
+
declare module '@alxia/react-router' {
|
|
308
|
+
interface Register {
|
|
309
|
+
server: typeof server;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Every option is optional:
|
|
315
|
+
|
|
316
|
+
| option | what it does |
|
|
317
|
+
| --- | --- |
|
|
318
|
+
| `configure(app)` | the app the pages run behind: its plugins, hooks and `/api`. It returns the app, and what it builds is what the loaders read |
|
|
319
|
+
| `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 |
|
|
320
|
+
| `getLoadContext(ctx, context)` | sets the app's own keys on React Router's provider, `ctx` typed by `configure`'s app |
|
|
321
|
+
| `build`, `mode`, `client` | override what the plugin wires; see [Escape hatches](#escape-hatches) |
|
|
322
|
+
| `listen` | `listen`'s options for `bun build/server/index.js`: `port`, `hostname`, `idleTimeout`, `maxRequestBodySize`, `tls`. A `port` or `hostname` given here wins over `PORT` and `HOST` |
|
|
323
|
+
| `onListen(server)` | called once the built server listens, in place of the `alxia listening on …` line |
|
|
324
|
+
|
|
325
|
+
A request goes through four layers, in order:
|
|
326
|
+
|
|
327
|
+
1. what `beforeAll` declared;
|
|
328
|
+
2. the client's files, `build/client`, in a build;
|
|
329
|
+
3. what `configure` declared;
|
|
330
|
+
4. the pages: `GET`, `POST`, `PUT`, `PATCH` and `DELETE` at `/*`.
|
|
331
|
+
|
|
332
|
+
Hooks apply to the routes declared after them, so a session or a guard in
|
|
333
|
+
`configure` runs around every page and its data, but not around the
|
|
334
|
+
JavaScript of the login page. Global hooks (`onRequest`, `@alxia/cors`,
|
|
335
|
+
`@alxia/compress`, `@alxia/secure-headers`) apply everywhere, wherever
|
|
336
|
+
they are declared.
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
// app/server.ts: a guard on everything, the assets included
|
|
340
|
+
import { createServer } from '@alxia/react-router';
|
|
341
|
+
|
|
342
|
+
export default createServer({
|
|
343
|
+
beforeAll: (app) =>
|
|
344
|
+
app.derive(({ request, reply }) =>
|
|
345
|
+
request.headers.get('authorization') === `Bearer ${process.env['TOKEN']}`
|
|
346
|
+
? {}
|
|
347
|
+
: reply(401, { error: 'unauthorized' }),
|
|
348
|
+
),
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`configure` and `beforeAll` must return the app; one that returns nothing
|
|
353
|
+
is a compile error. A plugin that needs something an earlier one
|
|
354
|
+
derived is typed by the app it is given, as anywhere in alxia.
|
|
355
|
+
|
|
356
|
+
## Typing the loaders
|
|
357
|
+
|
|
358
|
+
Every request through the catch-all sets `alxiaContext`, this package's
|
|
359
|
+
key, on React Router's context provider, to what alxia's hooks built for
|
|
360
|
+
that request. `alxiaOf(context)` reads it.
|
|
361
|
+
|
|
362
|
+
### With `Register`: no type argument
|
|
363
|
+
|
|
364
|
+
The `declare module` block in `app/server.ts` names the server once:
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
declare module '@alxia/react-router' {
|
|
368
|
+
interface Register {
|
|
369
|
+
server: typeof server;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Every loader, action and middleware then reads it typed, with no import:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
// app/routes/account.tsx
|
|
378
|
+
import { alxiaOf } from '@alxia/react-router';
|
|
379
|
+
import { data, redirect } from 'react-router';
|
|
380
|
+
import type { Route } from './+types/account';
|
|
381
|
+
|
|
382
|
+
export function loader({ context }: Route.LoaderArgs) {
|
|
383
|
+
const { user, log, requestId } = alxiaOf(context);
|
|
384
|
+
if (user === null) throw redirect('/login');
|
|
385
|
+
log.info('account');
|
|
386
|
+
return { name: user.name, requestId };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function action({ request, context }: Route.ActionArgs) {
|
|
390
|
+
const { user } = alxiaOf(context);
|
|
391
|
+
const form = await request.formData();
|
|
392
|
+
if (user === null) return data({ error: 'signed out' }, { status: 401 });
|
|
393
|
+
return { saved: form.get('name'), by: user.name };
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Name `typeof server`, the default export's type, not the module's
|
|
398
|
+
(`typeof import('./server')`): a wrong registration makes every read a
|
|
399
|
+
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-)
|
|
400
|
+
shows. The checks it gives:
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
const ctx = alxiaOf(context);
|
|
404
|
+
ctx.tenant; // error: no hook derives `tenant`
|
|
405
|
+
ctx.user.name; // error: `user` may be null
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### Without `Register`: the type argument
|
|
409
|
+
|
|
410
|
+
Pass the server's type, imported with `import type`, so nothing is
|
|
411
|
+
bundled and the route never imports the server at runtime:
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
// app/server.ts
|
|
415
|
+
const server = createServer({ configure: (app) => app.use(logger()) });
|
|
416
|
+
export default server;
|
|
417
|
+
export type Server = typeof server;
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
// app/routes/home.tsx
|
|
422
|
+
import { alxiaOf } from '@alxia/react-router';
|
|
423
|
+
import type { Server } from '../server';
|
|
424
|
+
import type { Route } from './+types/home';
|
|
425
|
+
|
|
426
|
+
export function loader({ context }: Route.LoaderArgs) {
|
|
427
|
+
const { log } = alxiaOf<Server>(context);
|
|
428
|
+
log.info('home');
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
The type argument may also be an alxia app, the app *before* the
|
|
434
|
+
catch-all, for a server of your own. Anything else is a compile error.
|
|
435
|
+
With neither `Register` nor a type argument, `alxiaOf(context)` is
|
|
436
|
+
`BaseContext`: the request, its URL, `reply` and the rest of what every
|
|
437
|
+
hook reads.
|
|
438
|
+
|
|
439
|
+
### Why a global augmentation is right here
|
|
440
|
+
|
|
441
|
+
alxia's core refuses global augmentation of its context: a plugin that
|
|
442
|
+
added `user` to every route, declared before it or after, would type
|
|
443
|
+
`user` on routes that run before the plugin. That is the lie "order is
|
|
444
|
+
meaning" forbids. `Register` here does something else: it names the
|
|
445
|
+
**one** server of the React Router build, at the point of its catch-all,
|
|
446
|
+
which is exactly what every loader runs behind. One build has one server
|
|
447
|
+
entry, so there is no second app for a module to be confused with.
|
|
448
|
+
|
|
449
|
+
If two React Router apps share one TypeScript program (one tsconfig over
|
|
450
|
+
both folders of a monorepo), their two declarations conflict, and `tsc`
|
|
451
|
+
says so (`Subsequent property declarations must have the same type`).
|
|
452
|
+
Give each app its own tsconfig, or use the type argument in both.
|
|
453
|
+
|
|
454
|
+
### Outside the catch-all
|
|
455
|
+
|
|
456
|
+
Without the plugin, under a plain `react-router dev`, or in a unit test
|
|
457
|
+
that calls a loader with a bare provider, there is no alxia context, and
|
|
458
|
+
`alxiaOf` throws, saying so: see
|
|
459
|
+
[the troubleshooting entry](troubleshooting.md#alxiaof-this-request-has-no-alxia-context-).
|
|
460
|
+
|
|
461
|
+
## The app's own context keys
|
|
462
|
+
|
|
463
|
+
React Router 8 passes data to loaders through keys made by
|
|
464
|
+
`createContext<T>()`, and matches a key by object identity. Under the
|
|
465
|
+
plugin, the server is built and loaded with the routes, so a key in
|
|
466
|
+
`app/` is one object for both:
|
|
467
|
+
|
|
468
|
+
```ts
|
|
469
|
+
// app/context.ts
|
|
470
|
+
import { createContext } from 'react-router';
|
|
471
|
+
|
|
472
|
+
export const greetingContext = createContext<string>('unset');
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
// app/server.ts
|
|
477
|
+
import { createServer } from '@alxia/react-router';
|
|
478
|
+
import { greetingContext } from './context';
|
|
479
|
+
|
|
480
|
+
export default createServer({
|
|
481
|
+
getLoadContext: (_ctx, context) => context.set(greetingContext, 'hello'),
|
|
482
|
+
});
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
```ts
|
|
486
|
+
// app/routes/home.tsx
|
|
487
|
+
import { greetingContext } from '../context';
|
|
488
|
+
import type { Route } from './+types/home';
|
|
489
|
+
|
|
490
|
+
export function loader({ context }: Route.LoaderArgs) {
|
|
491
|
+
return { greeting: context.get(greetingContext) };
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
`getLoadContext` runs before React Router, on every request, with
|
|
496
|
+
`alxiaContext` already set. Its `ctx` is typed by `configure`'s app:
|
|
497
|
+
reading something no hook derives is a compile error. Keep the keys in a
|
|
498
|
+
module of their own, such as `app/context.ts`, rather than in
|
|
499
|
+
`app/server.ts`: a route imports them, and the server file then stays out
|
|
500
|
+
of the client's module graph.
|
|
501
|
+
|
|
502
|
+
A server of your own, without the plugin, holds another copy of every
|
|
503
|
+
key under `app/`: see
|
|
504
|
+
[the troubleshooting entry](troubleshooting.md#error-no-value-found-for-context).
|
|
505
|
+
|
|
506
|
+
## Escape hatches
|
|
507
|
+
|
|
508
|
+
### Another server file
|
|
509
|
+
|
|
510
|
+
`alxia({ entry })` names the server file, relative to Vite's root, in
|
|
511
|
+
place of `app/server.ts`:
|
|
512
|
+
|
|
513
|
+
```ts
|
|
514
|
+
// vite.config.ts
|
|
515
|
+
import { alxia } from '@alxia/react-router/vite';
|
|
516
|
+
import { reactRouter } from '@react-router/dev/vite';
|
|
517
|
+
import { defineConfig } from 'vite';
|
|
518
|
+
|
|
519
|
+
export default defineConfig({
|
|
520
|
+
plugins: [reactRouter(), alxia({ entry: 'server/main.ts' })],
|
|
521
|
+
});
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
A missing `entry` is an error, where a missing `app/server.ts` falls back
|
|
525
|
+
to the default server.
|
|
526
|
+
|
|
527
|
+
### Overriding the wiring
|
|
528
|
+
|
|
529
|
+
`build`, `mode` and `client` override what the plugin passes:
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
// app/server.ts: the client's files with headers of your own
|
|
533
|
+
import { createServer } from '@alxia/react-router';
|
|
534
|
+
|
|
535
|
+
export default createServer({
|
|
536
|
+
client: false,
|
|
537
|
+
beforeAll: (app) =>
|
|
538
|
+
app.static('/assets', 'build/client/assets', {
|
|
539
|
+
cacheControl: 'public, max-age=31536000, immutable',
|
|
540
|
+
precompressed: ['br'],
|
|
541
|
+
}),
|
|
542
|
+
});
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
- **`client: false`** serves none of `build/client`: declare the files
|
|
546
|
+
yourself, in `beforeAll` or `configure`. A relative path there is
|
|
547
|
+
resolved against the process's working directory, the project's when it
|
|
548
|
+
runs `bun run start`.
|
|
549
|
+
- **`client`**, a path or a `file:` URL, serves another folder in its
|
|
550
|
+
place.
|
|
551
|
+
- **`mode`** forces React Router's server mode. `development` makes React
|
|
552
|
+
Router send its errors' stacks, and asks `build` for the build on every
|
|
553
|
+
request.
|
|
554
|
+
- **`build`**, a `ServerBuild` or a function returning one, replaces React
|
|
555
|
+
Router's build: rarely wanted.
|
|
556
|
+
|
|
557
|
+
### A server of your own, without the plugin
|
|
558
|
+
|
|
559
|
+
`reactRouter(app, options)` is the catch-all itself, for a server that
|
|
560
|
+
imports the build. Without the plugin, the dev server is React Router's
|
|
561
|
+
own, and the loaders get no alxia context in dev.
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
// server.ts, beside build/
|
|
565
|
+
import { alxia } from '@alxia/core';
|
|
566
|
+
import { logger } from '@alxia/logger';
|
|
567
|
+
import { reactRouter } from '@alxia/react-router';
|
|
568
|
+
import type { ServerBuild } from 'react-router';
|
|
569
|
+
|
|
570
|
+
export const base = alxia()
|
|
571
|
+
.use(logger())
|
|
572
|
+
.get('/api/health', ({ reply }) => reply.ok({ ok: true }));
|
|
573
|
+
|
|
574
|
+
/** What the loaders read: alxiaOf<Base>(context). */
|
|
575
|
+
export type Base = typeof base;
|
|
576
|
+
|
|
577
|
+
const app = base.use((app) =>
|
|
578
|
+
reactRouter(app, {
|
|
579
|
+
build: () =>
|
|
580
|
+
import(new URL('./build/server/index.js', import.meta.url).href) as Promise<ServerBuild>,
|
|
581
|
+
client: new URL('./build/client', import.meta.url),
|
|
582
|
+
}),
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
app.listen({ port: Number(process.env['PORT'] ?? 3000) });
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
```sh
|
|
589
|
+
bun run build && bun server.ts
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
| option | |
|
|
593
|
+
| --- | --- |
|
|
594
|
+
| `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` |
|
|
595
|
+
| `mode` | React Router's server mode, `production` by default |
|
|
596
|
+
| `getLoadContext(ctx, context)` | as `createServer`'s |
|
|
597
|
+
| `client` | the client build's folder, a path or a `file:` URL, served before the catch-all in `production` |
|
|
598
|
+
|
|
599
|
+
`reactRouter()` goes through `use`, as `@alxia/graphql`'s `graphql(app, …)`
|
|
600
|
+
does: that is how it knows the app's context type. A `HEAD` is handed to
|
|
601
|
+
React Router as a `GET`, since React Router answers a `HEAD` of its own
|
|
602
|
+
with no headers at all; the core then drops the body. In a monorepo where
|
|
603
|
+
this package is linked rather than installed, add
|
|
604
|
+
`ssr: { external: ['@alxia/react-router'] }` to `vite.config.ts`: see
|
|
605
|
+
[the troubleshooting entry](troubleshooting.md#alxiaof-this-request-has-no-alxia-context-).
|
|
606
|
+
|
|
607
|
+
## Hooks around the pages
|
|
608
|
+
|
|
609
|
+
Every hook declared before the catch-all runs around each page, as around
|
|
610
|
+
any route:
|
|
611
|
+
|
|
612
|
+
- **`@alxia/logger`** writes one entry per request and sets
|
|
613
|
+
`x-request-id`. It times a streamed page by its first byte, since
|
|
614
|
+
`onResponse` runs when the headers leave.
|
|
615
|
+
- **`@alxia/compress`** compresses documents and data, and flushes a
|
|
616
|
+
streamed page as React writes it: the shell and its `<Suspense>`
|
|
617
|
+
fallback still arrive first, in zstd, Brotli or gzip. See [the troubleshooting entry](troubleshooting.md#a-streamed-page-arrives-in-one-piece).
|
|
618
|
+
- **`@alxia/secure-headers`**' default policy, `default-src 'none'`, blocks
|
|
619
|
+
every script of the page, React Router's inline ones included, and its
|
|
620
|
+
`form-action 'none'` blocks a `<Form>`'s post. Give the pages a policy of
|
|
621
|
+
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).
|
|
622
|
+
- **A guard** — `@alxia/jwt`'s `bearer`, `@alxia/janus`' session — in
|
|
623
|
+
`configure` guards every page and its data alike; in `beforeAll`, the
|
|
624
|
+
client's files too.
|
|
625
|
+
|
|
626
|
+
## Routes beside the pages
|
|
627
|
+
|
|
628
|
+
alxia's own routes answer their paths wherever they are declared:
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
// app/server.ts
|
|
632
|
+
import { createServer } from '@alxia/react-router';
|
|
633
|
+
|
|
634
|
+
export default createServer({
|
|
635
|
+
configure: (app) =>
|
|
636
|
+
app
|
|
637
|
+
.get('/api/health', ({ reply }) => reply.ok({ ok: true }))
|
|
638
|
+
.get('/api/orders/:id', ({ params, reply }) => reply.ok({ id: params.id })),
|
|
639
|
+
});
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Each answers with its own schemas, replies and typed client. The core ranks
|
|
643
|
+
paths as `Bun.serve` does, through `listen`, `app.fetch` and
|
|
644
|
+
`app.request` alike: segment by segment, a literal beats a parameter,
|
|
645
|
+
which beats the catch-all's wildcard. Two consequences:
|
|
646
|
+
|
|
647
|
+
- **The path is chosen before the method.** With only `POST /api/orders`,
|
|
648
|
+
a `GET /api/orders` is alxia's 405, not a page.
|
|
649
|
+
- **A route whose path covers pages takes them.** `GET /:slug` answers
|
|
650
|
+
every one-segment path, `/about` included, before React Router sees it.
|
|
651
|
+
Put alxia's routes under a prefix of their own, `/api`.
|
|
652
|
+
|
|
653
|
+
The catch-all adds nothing to the app's route table: `RoutesOf` and the
|
|
654
|
+
typed client never show `/*`. Pages and single-fetch data are not
|
|
655
|
+
something a typed client calls; what it does call, your `/api`, is typed
|
|
656
|
+
as always.
|
|
657
|
+
|
|
658
|
+
What React Router answers comes back as it sent it: documents,
|
|
659
|
+
single-fetch data (`/_.data`, `/login.data`), lazy route discovery
|
|
660
|
+
(`/__manifest`), redirects with every `Set-Cookie` they carry, and the
|
|
661
|
+
error pages, a 404 or a 500 rendered by the app's `ErrorBoundary`.
|
|
662
|
+
|
|
663
|
+
## WebSockets
|
|
664
|
+
|
|
665
|
+
A `ws` route declared in `configure` (or `beforeAll`) is a socket under
|
|
666
|
+
`react-router dev`, under `vite preview` and from
|
|
667
|
+
`bun build/server/index.js` alike. There is
|
|
668
|
+
nothing to add: no option, no package, no second server to start.
|
|
669
|
+
|
|
670
|
+
```ts
|
|
671
|
+
// app/server.ts
|
|
672
|
+
import { createServer } from '@alxia/react-router';
|
|
673
|
+
|
|
674
|
+
export default createServer({
|
|
675
|
+
configure: (app) =>
|
|
676
|
+
app
|
|
677
|
+
.derive(({ request }) => {
|
|
678
|
+
const name = request.headers.get('x-user');
|
|
679
|
+
return { user: name === null ? null : { name } };
|
|
680
|
+
})
|
|
681
|
+
.ws('/api/echo', {}, {
|
|
682
|
+
open: (socket) => socket.send({ hello: socket.data.user?.name ?? 'anonymous' }),
|
|
683
|
+
message: (socket, message) => socket.send({ echo: String(message) }),
|
|
684
|
+
})
|
|
685
|
+
.group((guarded) =>
|
|
686
|
+
guarded
|
|
687
|
+
.derive(({ user, reply }) =>
|
|
688
|
+
user === null ? reply(401, { error: 'unauthenticated' as const }) : { user },
|
|
689
|
+
)
|
|
690
|
+
.ws('/api/rooms/:room', {}, {
|
|
691
|
+
open: (socket) => socket.subscribe(socket.data.params.room),
|
|
692
|
+
message: (socket, message) =>
|
|
693
|
+
socket.publish(socket.data.params.room, {
|
|
694
|
+
from: socket.data.user.name,
|
|
695
|
+
text: String(message),
|
|
696
|
+
}),
|
|
697
|
+
}),
|
|
698
|
+
),
|
|
699
|
+
});
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
The upgrade runs the hooks declared before the route, and the route's
|
|
703
|
+
validation, as any request does. A hook's reply refuses it: the client
|
|
704
|
+
gets the 401 and its body, and no socket opens. `socket.data` holds the
|
|
705
|
+
validated request and what each hook derived, typed. See
|
|
706
|
+
[`@alxia/core`'s WebSockets](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/websockets.md)
|
|
707
|
+
for the schemas and the rest of the socket.
|
|
708
|
+
|
|
709
|
+
The guard sits in a `group` so it refuses the sockets alone: declared on
|
|
710
|
+
`app` itself, it would apply to every route after it, the pages'
|
|
711
|
+
catch-all included.
|
|
712
|
+
|
|
713
|
+
### Under `react-router dev`
|
|
714
|
+
|
|
715
|
+
Vite's dev server is a `node:http` server, and an alxia socket is
|
|
716
|
+
`Bun.serve`'s upgrade. The plugin listens for Vite's `upgrade` event and
|
|
717
|
+
leaves Vite's own socket alone: an upgrade asking for the `vite-hmr` or
|
|
718
|
+
`vite-ping` subprotocol is Vite's, and HMR works as before. Any other
|
|
719
|
+
upgrade, except one Vite's `server.proxy` relays itself (an entry with
|
|
720
|
+
`ws: true` or a `ws:` target, matched as Vite matches it), is relayed,
|
|
721
|
+
byte for byte, to a `Bun.serve` of the app on a loopback port of its
|
|
722
|
+
own, started on the first upgrade and given
|
|
723
|
+
`app.fetch` and `app.websocket`, as `listen` gives them. So in dev:
|
|
724
|
+
|
|
725
|
+
- **The same hooks, refusals and handlers run** as from the build:
|
|
726
|
+
`open`, `message`, `close`, `drain`, the `message` and `send` schemas,
|
|
727
|
+
`subscribe` and `publish`.
|
|
728
|
+
- **An edit to `app/server.ts`, or to a module it imports, is used from
|
|
729
|
+
the next connection.** The edit makes a new app, and the next upgrade
|
|
730
|
+
starts a new `Bun.serve` for it. A socket opened before the edit keeps
|
|
731
|
+
the handlers it opened with until it closes.
|
|
732
|
+
- **A `publish` reaches the sockets opened since the same edit**: the
|
|
733
|
+
sockets opened before it are on the previous server. Reload the pages
|
|
734
|
+
after an edit to reconnect them.
|
|
735
|
+
- **A socket's `ctx.ip` is the loopback address**, the relay's. The
|
|
736
|
+
browser is local in dev, so that is usually its address anyway.
|
|
737
|
+
- **An upgrade to a path with no `ws` route** gets what the build answers
|
|
738
|
+
it too, never a socket: alxia's 404 where nothing matches, the page
|
|
739
|
+
where the catch-all does.
|
|
740
|
+
- **Another plugin that listens for upgrades** competes with the relay
|
|
741
|
+
on the paths it shares with it: give its socket a path under a
|
|
742
|
+
`server.proxy` entry with `ws: true` or a `ws:` target, or outside the
|
|
743
|
+
app's.
|
|
744
|
+
- **With Vite in middleware mode**, there is no `node:http` server of
|
|
745
|
+
Vite's to listen on: serve the app yourself, as in
|
|
746
|
+
[A server of your own](#a-server-of-your-own-without-the-plugin).
|
|
747
|
+
|
|
748
|
+
Under `vite preview` the same relay runs, to a `Bun.serve` of the built
|
|
749
|
+
app, loaded once. Under `bun build/server/index.js`, nothing of this runs:
|
|
750
|
+
`listen` serves the sockets itself, as any alxia app's.
|
|
751
|
+
|
|
752
|
+
## The client's files
|
|
753
|
+
|
|
754
|
+
In a build, each top-level entry of `build/client` becomes a route, after
|
|
755
|
+
`beforeAll` and before `configure`:
|
|
756
|
+
|
|
757
|
+
| entry | route | `Cache-Control` |
|
|
758
|
+
| --- | --- | --- |
|
|
759
|
+
| `assets/`, the hashed bundles | `static('/assets', …)` | `public, max-age=31536000, immutable` |
|
|
760
|
+
| another folder, such as `images/` from `public/images/` | `static('/images', …)` | `public, max-age=3600` |
|
|
761
|
+
| a file, such as `robots.txt` or `favicon.ico` | `file('/robots.txt', …)` | `public, max-age=3600` |
|
|
762
|
+
|
|
763
|
+
They are the core's `static` and `file` routes, so ETags,
|
|
764
|
+
`Last-Modified`, 304s and ranges are included. A missing file under one
|
|
765
|
+
of those folders gets alxia's JSON 404, `{ "error": "not_found" }`, not a
|
|
766
|
+
page, so a folder of `public/` shadows any page path under the same name.
|
|
767
|
+
Dotfiles are skipped. In dev, Vite serves them.
|
|
768
|
+
|
|
769
|
+
A folder that is not one, or a file whose name no route can carry, is
|
|
770
|
+
refused when the server starts: see
|
|
771
|
+
[the troubleshooting entries](troubleshooting.md#typeerror-reactrouter-client-is--which-is-not-a-directory-).
|
|
772
|
+
|
|
773
|
+
## OpenAPI
|
|
774
|
+
|
|
775
|
+
`@alxia/openapi` documents every route of `app.routes`, and the catch-all
|
|
776
|
+
and the client's files are routes. Leave them out with
|
|
777
|
+
`isReactRouterRoute`, which is true for each route this package declared:
|
|
778
|
+
|
|
779
|
+
```ts
|
|
780
|
+
// app/server.ts
|
|
781
|
+
import { docs } from '@alxia/openapi';
|
|
782
|
+
import { createServer, isReactRouterRoute } from '@alxia/react-router';
|
|
783
|
+
|
|
784
|
+
export default createServer({
|
|
785
|
+
configure: (app) =>
|
|
786
|
+
app
|
|
787
|
+
.get('/api/health', ({ reply }) => reply.ok({ ok: true }))
|
|
788
|
+
.use(docs(app, { info: { title: 'Shop', version: '1.0.0' }, exclude: isReactRouterRoute })),
|
|
789
|
+
});
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
Combine it with your own: `exclude: (route) => isReactRouterRoute(route) ||
|
|
793
|
+
route.path === '/api/health'`.
|
|
794
|
+
|
|
795
|
+
## Testing
|
|
796
|
+
|
|
797
|
+
`server.create(wiring)` makes the app in process, from a build: import
|
|
798
|
+
the server, and pass it React Router's build, which
|
|
799
|
+
`build/server/index.js` holds. The test needs Bun's types:
|
|
800
|
+
`bun add -d @types/bun`, and `"bun"` in the tsconfig's `types`.
|
|
801
|
+
|
|
802
|
+
```ts
|
|
803
|
+
// app/server.test.ts
|
|
804
|
+
import { expect, test } from 'bun:test';
|
|
805
|
+
import type { ServerBuild } from 'react-router';
|
|
806
|
+
import server from './server';
|
|
807
|
+
|
|
808
|
+
const BROWSER = 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36';
|
|
809
|
+
|
|
810
|
+
test('the home page renders', async () => {
|
|
811
|
+
// After `bun run build`: React Router's build is inside the server build.
|
|
812
|
+
const build: ServerBuild = await import(new URL('../build/server/index.js', import.meta.url).href);
|
|
813
|
+
const app = server.create({ build, client: new URL('../build/client', import.meta.url) });
|
|
814
|
+
const response = await app.request('/', { headers: { 'user-agent': BROWSER } });
|
|
815
|
+
expect(response.status).toBe(200);
|
|
816
|
+
});
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
Or drive the built app itself, the default export of
|
|
820
|
+
`build/server/index.js`: importing it starts no server. Or run it, as
|
|
821
|
+
[the example's spec](https://github.com/softistx/alxia/blob/develop/examples/react-router/app/server.spec.ts)
|
|
822
|
+
does: `bun build/server/index.js` with `PORT=0`, then read the URL from
|
|
823
|
+
its `alxia listening on <url>` line.
|
|
824
|
+
|
|
825
|
+
Send a browser's user agent. `isbot('Bun/1.4.2')` is true, and React
|
|
826
|
+
Router's entry waits for the whole page before it answers a bot, so a test
|
|
827
|
+
with Bun's own user agent never sees a page stream. An index route's action
|
|
828
|
+
is `POST /?index`, not `POST /`.
|
|
829
|
+
|
|
830
|
+
## Deploying
|
|
831
|
+
|
|
832
|
+
Run `bun run build`, then `bun build/server/index.js` beside the project's
|
|
833
|
+
production `node_modules`: the build imports `react`, `react-router` and
|
|
834
|
+
alxia from them, as any React Router server build does. The template's
|
|
835
|
+
`Dockerfile` no longer works once `start` runs Bun: it is based on a Node
|
|
836
|
+
image with no Bun, and copies a `package-lock.json` a Bun app does not
|
|
837
|
+
have. Base it on an `oven/bun` image instead, install with
|
|
838
|
+
`bun install --production`, and make its command
|
|
839
|
+
`bun build/server/index.js`.
|
|
840
|
+
|
|
841
|
+
`PORT` and `HOST` set where the server listens. The platform's `SIGTERM`
|
|
842
|
+
stops it once the requests in flight are answered, and runs the app's
|
|
843
|
+
`onStop` hooks.
|