@alxia/react-router 0.3.0 → 0.5.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 +82 -43
- package/dist/cli/index.js +3 -3
- package/dist/cli/index.js.map +4 -4
- package/dist/cli/template.d.ts +1 -1
- package/dist/cli/template.d.ts.map +1 -1
- package/dist/context.d.ts +23 -12
- package/dist/context.d.ts.map +1 -1
- package/dist/index.js.map +4 -4
- package/dist/react-router.d.ts +13 -13
- package/dist/react-router.d.ts.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/vite/bundle.d.ts +33 -0
- package/dist/vite/bundle.d.ts.map +1 -0
- package/dist/vite/index.d.ts +4 -0
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +16 -2
- package/dist/vite/index.js.map +5 -4
- package/docs/README.md +1 -1
- package/docs/guide.md +259 -74
- package/docs/roadmap.md +29 -9
- package/docs/troubleshooting.md +103 -5
- package/package.json +5 -5
package/docs/guide.md
CHANGED
|
@@ -16,7 +16,7 @@ deploying.
|
|
|
16
16
|
- [The app's own context keys](#the-apps-own-context-keys)
|
|
17
17
|
- [A CSP nonce](#a-csp-nonce)
|
|
18
18
|
- [Escape hatches](#escape-hatches)
|
|
19
|
-
- [
|
|
19
|
+
- [Middlewares around the pages](#middlewares-around-the-pages)
|
|
20
20
|
- [Routes beside the pages](#routes-beside-the-pages)
|
|
21
21
|
- [WebSockets](#websockets)
|
|
22
22
|
- [The client's files](#the-clients-files)
|
|
@@ -26,7 +26,35 @@ deploying.
|
|
|
26
26
|
|
|
27
27
|
## Setup
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
### A new app: `bun create @alxia`
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
bun create @alxia my-app --template react-router
|
|
33
|
+
cd my-app
|
|
34
|
+
bun dev
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
[`@alxia/create`](https://www.npmjs.com/package/@alxia/create) copies React
|
|
38
|
+
Router's official template with alxia's layer already applied: the steps
|
|
39
|
+
of the next section, done.
|
|
40
|
+
|
|
41
|
+
- `@alxia/core` and `@alxia/react-router` installed, and every dependency
|
|
42
|
+
moved to the newest release alxia accepts (see
|
|
43
|
+
[`@alxia/create`'s Versions](https://github.com/softistx/alxia/blob/develop/packages/create/docs/guide.md#versions));
|
|
44
|
+
- `alxia()` in `vite.config.ts`, the `bunfig.toml`, and `start` running
|
|
45
|
+
`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:
|
|
30
58
|
|
|
31
59
|
```sh
|
|
32
60
|
bunx create-react-router@latest my-app
|
|
@@ -96,9 +124,10 @@ What the template keeps:
|
|
|
96
124
|
- `@react-router/node`, which React Router's default `entry.server`
|
|
97
125
|
renders with: it streams under Bun.
|
|
98
126
|
- Vite 7 or 8 (the template ships 8).
|
|
99
|
-
-
|
|
100
|
-
warns, but the template's code
|
|
101
|
-
`bun add -d typescript@^6` silences the warning
|
|
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.
|
|
102
131
|
|
|
103
132
|
React Router 8 is required: its loaders receive a `RouterContextProvider`,
|
|
104
133
|
and alxia's context is set on it.
|
|
@@ -154,6 +183,10 @@ server build inside it, into one file:
|
|
|
154
183
|
- **The client folder is resolved against the built file**: `../client`,
|
|
155
184
|
from React Router's `buildDirectory`, so it is found wherever the
|
|
156
185
|
process starts.
|
|
186
|
+
- **It is self-contained**: every package the server imports, React,
|
|
187
|
+
React Router and alxia included, is bundled into it
|
|
188
|
+
([Self-contained](#self-contained)), so `build/` runs with no
|
|
189
|
+
`node_modules`.
|
|
157
190
|
|
|
158
191
|
```sh
|
|
159
192
|
bun run build
|
|
@@ -184,7 +217,7 @@ The plugin loads `build/server/index.js` on the first request and hands
|
|
|
184
217
|
every request to its default export, the alxia app, before Vite's own
|
|
185
218
|
files. So the preview answers as `bun run start` does: the pages and their
|
|
186
219
|
data, `/api`, `build/client` with the cache headers of
|
|
187
|
-
[the client's files](#the-clients-files), and every
|
|
220
|
+
[the client's files](#the-clients-files), and every middleware of `beforeAll`
|
|
188
221
|
and `configure`. Requests, their bodies and every `Set-Cookie` pass
|
|
189
222
|
through whole, and pages stream.
|
|
190
223
|
|
|
@@ -208,20 +241,22 @@ React Router prerenders the `prerender` paths of `react-router.config.ts`
|
|
|
208
241
|
through the same preview server, during `react-router build`. A
|
|
209
242
|
prerendered page is therefore rendered by the built server too: its loader
|
|
210
243
|
reads `alxiaOf(context)` and `getLoadContext`'s keys, and `beforeAll`'s
|
|
211
|
-
and `configure`'s
|
|
244
|
+
and `configure`'s middlewares run around it.
|
|
212
245
|
|
|
213
246
|
## Built for Bun
|
|
214
247
|
|
|
215
248
|
`build/server/index.js` runs on Bun, and the plugin builds it for Bun,
|
|
216
249
|
with nothing to configure. It adds to Vite's `ssr` environment, under
|
|
217
|
-
`react-router dev` and `react-router build` alike
|
|
250
|
+
`react-router dev` and `react-router build` alike but for the last row,
|
|
251
|
+
the build's alone:
|
|
218
252
|
|
|
219
253
|
| option | what the plugin adds | why |
|
|
220
254
|
| --- | --- | --- |
|
|
221
255
|
| `resolve.conditions` | `bun` | a package bundled into the server whose `exports` has a `bun` condition is bundled as its Bun variant |
|
|
222
|
-
| `resolve.externalConditions` | `bun` | a dependency left external is loaded as its Bun variant
|
|
256
|
+
| `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 |
|
|
223
257
|
| `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 |
|
|
224
258
|
| `build.target` | `esnext` | the newest syntax is left as written: Bun runs it, and Vite's default targets browsers |
|
|
259
|
+
| `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)) |
|
|
225
260
|
|
|
226
261
|
A package written for Bun, then, gives the build the variant Bun would
|
|
227
262
|
load, and Bun's own modules are used as they are:
|
|
@@ -293,6 +328,60 @@ export default defineConfig({
|
|
|
293
328
|
`ssr.resolve.conditions` at the top level is the same option, and is kept
|
|
294
329
|
the same way.
|
|
295
330
|
|
|
331
|
+
### Self-contained
|
|
332
|
+
|
|
333
|
+
Under `react-router build`, the plugin sets `resolve.noExternal: true` on
|
|
334
|
+
the `ssr` environment: Vite bundles every package the server imports into
|
|
335
|
+
`build/server/index.js`, where it would otherwise leave each one under
|
|
336
|
+
`node_modules` as an `import`. What the file still imports is Node's and
|
|
337
|
+
Bun's own modules:
|
|
338
|
+
|
|
339
|
+
```js
|
|
340
|
+
// build/server/index.js, its imports
|
|
341
|
+
import { createRequire } from "node:module";
|
|
342
|
+
import { PassThrough } from "node:stream";
|
|
343
|
+
// … and no "react", "react-router" or "@alxia/core"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
So `build/` is all a server needs, beside Bun: a Docker image's last
|
|
347
|
+
stage copies it alone ([Docker](#docker)). React and `react-dom/server`,
|
|
348
|
+
React Router, alxia and its plugins bundle as they are, CommonJS included;
|
|
349
|
+
the `bun` condition above picks each package's Bun variant. One copy of
|
|
350
|
+
each package is in the file, so `alxiaContext` and the app's own keys are
|
|
351
|
+
one object for the server and the routes. React Router's prerendering and
|
|
352
|
+
`vite preview` load the same file.
|
|
353
|
+
|
|
354
|
+
`react-router dev` is left as it was: Vite's SSR runner loads the
|
|
355
|
+
packages from `node_modules`.
|
|
356
|
+
|
|
357
|
+
What the app sets wins. Vite reads `external` before `noExternal`:
|
|
358
|
+
|
|
359
|
+
- **`ssr.external: ['sharp']`**, or the same on
|
|
360
|
+
`environments.ssr.resolve.external`, keeps those packages external,
|
|
361
|
+
imported from `node_modules` at runtime. Use it for a package that
|
|
362
|
+
cannot be bundled: a native addon (a `.node` file), or one that reads
|
|
363
|
+
files of its own folder at runtime. The image must then hold that
|
|
364
|
+
package ([Docker](#docker)).
|
|
365
|
+
- **`ssr.external: true`** keeps every package external, Vite's own
|
|
366
|
+
behaviour: the plugin adds no `noExternal` then, and the server needs
|
|
367
|
+
the production `node_modules` beside `build/`.
|
|
368
|
+
- **`ssr.noExternal`**, a list, changes nothing: everything is bundled
|
|
369
|
+
already.
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
// vite.config.ts
|
|
373
|
+
export default defineConfig({
|
|
374
|
+
// Bundled but for sharp, a native addon.
|
|
375
|
+
ssr: { external: ['sharp'] },
|
|
376
|
+
plugins: [reactRouter(), alxia()],
|
|
377
|
+
});
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
A package the build could not bundle and was not told to leave external
|
|
381
|
+
fails at runtime, from `build/` alone, with Bun's
|
|
382
|
+
`Cannot find package '…'`: see
|
|
383
|
+
[the troubleshooting entry](troubleshooting.md#error-cannot-find-package--from-appbuildserverindexjs).
|
|
384
|
+
|
|
296
385
|
## Customising the server
|
|
297
386
|
|
|
298
387
|
Write `app/server.ts`, with `createServer()` as its default export, or let
|
|
@@ -308,7 +397,7 @@ bunx alxia-react-router reveal
|
|
|
308
397
|
|
|
309
398
|
```text
|
|
310
399
|
alxia-react-router: wrote app/server.ts, the server alxia() runs by default.
|
|
311
|
-
Next: uncomment configure in app/server.ts to add the app's
|
|
400
|
+
Next: uncomment configure in app/server.ts to add the app's middlewares and /api; bun run dev picks it up.
|
|
312
401
|
```
|
|
313
402
|
|
|
314
403
|
The file is the server the plugin runs without one, `createServer()`, so
|
|
@@ -367,7 +456,7 @@ React Router's own rendering entries, `app/entry.server.tsx` and
|
|
|
367
456
|
### By hand
|
|
368
457
|
|
|
369
458
|
The example uses `@alxia/logger` and `@alxia/compress`
|
|
370
|
-
(`bun add @alxia/logger @alxia/compress`); any
|
|
459
|
+
(`bun add @alxia/logger @alxia/compress`); any middleware works the same way.
|
|
371
460
|
|
|
372
461
|
```ts
|
|
373
462
|
// app/server.ts
|
|
@@ -400,7 +489,7 @@ Every option is optional:
|
|
|
400
489
|
|
|
401
490
|
| option | what it does |
|
|
402
491
|
| --- | --- |
|
|
403
|
-
| `configure(app)` | the app the pages run behind: its
|
|
492
|
+
| `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 |
|
|
404
493
|
| `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 |
|
|
405
494
|
| `getLoadContext(ctx, context)` | sets the app's own keys on React Router's provider, `ctx` typed by `configure`'s app |
|
|
406
495
|
| `build`, `mode`, `client` | override what the plugin wires; see [Escape hatches](#escape-hatches) |
|
|
@@ -414,11 +503,16 @@ A request goes through four layers, in order:
|
|
|
414
503
|
3. what `configure` declared;
|
|
415
504
|
4. the pages: `GET`, `POST`, `PUT`, `PATCH` and `DELETE` at `/*`.
|
|
416
505
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
506
|
+
A middleware given to `use` runs on every route declared after it, and on a
|
|
507
|
+
request no route matches. So a session or a guard in `configure` runs around
|
|
508
|
+
every page and its data, and not around what is declared before it: the
|
|
509
|
+
client's files, which sit between `beforeAll` and `configure`. What must see
|
|
510
|
+
every request, the client's files included, goes in `beforeAll`: the logger,
|
|
511
|
+
`@alxia/cors`, `@alxia/compress`, `@alxia/secure-headers`. A guard on the app
|
|
512
|
+
answers a request no route matches too: an anonymous request to a missing path
|
|
513
|
+
gets the 401, not the 404. Scope it with a path, `use('/app', guard)`
|
|
514
|
+
(a guard that adds nothing to the context: `bearer` and `session` add `user`),
|
|
515
|
+
to guard only some pages.
|
|
422
516
|
|
|
423
517
|
```ts
|
|
424
518
|
// app/server.ts: a guard on everything, the assets included
|
|
@@ -435,13 +529,13 @@ export default createServer({
|
|
|
435
529
|
```
|
|
436
530
|
|
|
437
531
|
`configure` and `beforeAll` must return the app; one that returns nothing
|
|
438
|
-
is a compile error. A
|
|
532
|
+
is a compile error. A middleware that needs something an earlier one
|
|
439
533
|
derived is typed by the app it is given, as anywhere in alxia.
|
|
440
534
|
|
|
441
535
|
## Typing the loaders
|
|
442
536
|
|
|
443
537
|
Every request through the catch-all sets `alxiaContext`, this package's
|
|
444
|
-
key, on React Router's context provider, to what alxia's
|
|
538
|
+
key, on React Router's context provider, to what alxia's middlewares built for
|
|
445
539
|
that request. `alxiaOf(context)` reads it.
|
|
446
540
|
|
|
447
541
|
### With `Register`: no type argument
|
|
@@ -486,7 +580,7 @@ shows. The checks it gives:
|
|
|
486
580
|
|
|
487
581
|
```ts
|
|
488
582
|
const ctx = alxiaOf(context);
|
|
489
|
-
ctx.tenant; // error: no
|
|
583
|
+
ctx.tenant; // error: no middleware derives `tenant`
|
|
490
584
|
ctx.user.name; // error: `user` may be null
|
|
491
585
|
```
|
|
492
586
|
|
|
@@ -517,19 +611,57 @@ export function loader({ context }: Route.LoaderArgs) {
|
|
|
517
611
|
|
|
518
612
|
The type argument may also be an alxia app, the app *before* the
|
|
519
613
|
catch-all, for a server of your own. Anything else is a compile error.
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
614
|
+
|
|
615
|
+
### With `@alxia/core`'s `Register`
|
|
616
|
+
|
|
617
|
+
`@alxia/core` has a `Register` of its own, which names the chain that
|
|
618
|
+
builds an app's context, for its route files
|
|
619
|
+
([The app's type](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/types.md#register-and-appcontext)).
|
|
620
|
+
`alxiaOf(context)` reads it when this package's `Register` names no
|
|
621
|
+
server:
|
|
622
|
+
|
|
623
|
+
```ts
|
|
624
|
+
// app/context.ts
|
|
625
|
+
import { alxia } from '@alxia/core';
|
|
626
|
+
|
|
627
|
+
export const base = alxia().derive(({ request }) => ({ user: request.headers.get('x-user') }));
|
|
628
|
+
|
|
629
|
+
declare module '@alxia/core' {
|
|
630
|
+
interface Register {
|
|
631
|
+
context: typeof base;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// app/server.ts
|
|
636
|
+
const server = createServer({ configure: (app) => app.plugin(base) });
|
|
637
|
+
export default server;
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
`alxiaOf(context).user` is then typed with no declaration in
|
|
641
|
+
`@alxia/react-router`. Which one is read, in order:
|
|
642
|
+
|
|
643
|
+
1. a type argument, `alxiaOf<typeof server>(context)`;
|
|
644
|
+
2. this package's `Register`, `server: typeof server`: the whole app the
|
|
645
|
+
pages run behind, `base` and everything `configure` adds after it;
|
|
646
|
+
3. `@alxia/core`'s `Register`, `context: typeof base`: only what `base`
|
|
647
|
+
builds;
|
|
648
|
+
4. neither: `BaseContext`, the request, its URL, `reply` and the rest of
|
|
649
|
+
what every middleware reads.
|
|
650
|
+
|
|
651
|
+
Declare this package's `Register` when `configure` adds to the context
|
|
652
|
+
after the base, and the loaders read it.
|
|
523
653
|
|
|
524
654
|
### Why a global augmentation is right here
|
|
525
655
|
|
|
526
|
-
alxia's core refuses global augmentation of
|
|
527
|
-
added `user` to every route, declared before it or after, would
|
|
528
|
-
`user` on routes that run before the plugin. That is the lie "order
|
|
529
|
-
meaning" forbids. `Register`
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
656
|
+
alxia's core refuses a global augmentation of a context key: a plugin
|
|
657
|
+
that added `user` to every route, declared before it or after, would
|
|
658
|
+
type `user` on routes that run before the plugin. That is the lie "order
|
|
659
|
+
is meaning" forbids. Core's own `Register` names the base that builds
|
|
660
|
+
the context instead, and what reads it requires it. `Register` here does
|
|
661
|
+
something else: it names the **one** server of the React Router build,
|
|
662
|
+
at the point of its catch-all, which is exactly what every loader runs
|
|
663
|
+
behind. One build has one server entry, so there is no second app for a
|
|
664
|
+
module to be confused with.
|
|
533
665
|
|
|
534
666
|
If two React Router apps share one TypeScript program (one tsconfig over
|
|
535
667
|
both folders of a monorepo), their two declarations conflict, and `tsc`
|
|
@@ -579,7 +711,7 @@ export function loader({ context }: Route.LoaderArgs) {
|
|
|
579
711
|
|
|
580
712
|
`getLoadContext` runs before React Router, on every request, with
|
|
581
713
|
`alxiaContext` already set. Its `ctx` is typed by `configure`'s app:
|
|
582
|
-
reading something no
|
|
714
|
+
reading something no middleware derives is a compile error. Keep the keys in a
|
|
583
715
|
module of their own, such as `app/context.ts`, rather than in
|
|
584
716
|
`app/server.ts`: a route imports them, and the server file then stays out
|
|
585
717
|
of the client's module graph.
|
|
@@ -669,7 +801,7 @@ scripts included.
|
|
|
669
801
|
### How it stays loose
|
|
670
802
|
|
|
671
803
|
`nonceOf` reads `nonce` from alxia's context if it is a string, and
|
|
672
|
-
returns `undefined` otherwise: no
|
|
804
|
+
returns `undefined` otherwise: no middleware set one, or the request did not come
|
|
673
805
|
through alxia at all. `ServerRouter` and React then render no `nonce`
|
|
674
806
|
attribute. So:
|
|
675
807
|
|
|
@@ -756,7 +888,7 @@ export const base = alxia()
|
|
|
756
888
|
/** What the loaders read: alxiaOf<Base>(context). */
|
|
757
889
|
export type Base = typeof base;
|
|
758
890
|
|
|
759
|
-
const app = base.
|
|
891
|
+
const app = base.plugin((app) =>
|
|
760
892
|
reactRouter(app, {
|
|
761
893
|
build: () =>
|
|
762
894
|
import(new URL('./build/server/index.js', import.meta.url).href) as Promise<ServerBuild>,
|
|
@@ -778,7 +910,7 @@ bun run build && bun server.ts
|
|
|
778
910
|
| `getLoadContext(ctx, context)` | as `createServer`'s |
|
|
779
911
|
| `client` | the client build's folder, a path or a `file:` URL, served before the catch-all in `production` |
|
|
780
912
|
|
|
781
|
-
`reactRouter()` goes through `
|
|
913
|
+
`reactRouter()` goes through `app.plugin`, as `@alxia/graphql`'s `graphql(app, …)`
|
|
782
914
|
does: that is how it knows the app's context type. A `HEAD` is handed to
|
|
783
915
|
React Router as a `GET`, since React Router answers a `HEAD` of its own
|
|
784
916
|
with no headers at all; the core then drops the body. In a monorepo where
|
|
@@ -786,14 +918,15 @@ this package is linked rather than installed, add
|
|
|
786
918
|
`ssr: { external: ['@alxia/react-router'] }` to `vite.config.ts`: see
|
|
787
919
|
[the troubleshooting entry](troubleshooting.md#alxiaof-this-request-has-no-alxia-context-).
|
|
788
920
|
|
|
789
|
-
##
|
|
921
|
+
## Middlewares around the pages
|
|
790
922
|
|
|
791
|
-
Every
|
|
792
|
-
any route:
|
|
923
|
+
Every middleware declared before the catch-all runs around each page, as around
|
|
924
|
+
any route, and `use` observers go first so they wrap everything:
|
|
793
925
|
|
|
794
926
|
- **`@alxia/logger`** writes one entry per request and sets
|
|
795
927
|
`x-request-id`. It times a streamed page by its first byte, since
|
|
796
|
-
|
|
928
|
+
the response is settled when its headers leave. Every request is logged,
|
|
929
|
+
404s and errors included.
|
|
797
930
|
- **`@alxia/compress`** compresses documents and data, and flushes a
|
|
798
931
|
streamed page as React writes it: the shell and its `<Suspense>`
|
|
799
932
|
fallback still arrive first, in zstd, Brotli or gzip. See [the troubleshooting entry](troubleshooting.md#a-streamed-page-arrives-in-one-piece).
|
|
@@ -805,7 +938,8 @@ any route:
|
|
|
805
938
|
[A CSP nonce](#a-csp-nonce).
|
|
806
939
|
- **A guard** — `@alxia/jwt`'s `bearer`, `@alxia/janus`' session — in
|
|
807
940
|
`configure` guards every page and its data alike; in `beforeAll`, the
|
|
808
|
-
client's files too.
|
|
941
|
+
client's files too. On the app, a guard also answers a request no route
|
|
942
|
+
matches with its 401.
|
|
809
943
|
|
|
810
944
|
## Routes beside the pages
|
|
811
945
|
|
|
@@ -823,7 +957,7 @@ export default createServer({
|
|
|
823
957
|
});
|
|
824
958
|
```
|
|
825
959
|
|
|
826
|
-
Each answers with its own
|
|
960
|
+
Each answers with its own middlewares and replies. The core ranks
|
|
827
961
|
paths as `Bun.serve` does, through `listen`, `app.fetch` and
|
|
828
962
|
`app.request` alike: segment by segment, a literal beats a parameter,
|
|
829
963
|
which beats the catch-all's wildcard. Two consequences:
|
|
@@ -834,10 +968,10 @@ which beats the catch-all's wildcard. Two consequences:
|
|
|
834
968
|
every one-segment path, `/about` included, before React Router sees it.
|
|
835
969
|
Put alxia's routes under a prefix of their own, `/api`.
|
|
836
970
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
971
|
+
Pages and single-fetch data are not something a generated client calls:
|
|
972
|
+
leave the catch-all out of `matchesSpec`'s check with `isReactRouterRoute`
|
|
973
|
+
([OpenAPI](#openapi)); what a client does call, your `/api`, is what the
|
|
974
|
+
OpenAPI document declares.
|
|
841
975
|
|
|
842
976
|
What React Router answers comes back as it sent it: documents,
|
|
843
977
|
single-fetch data (`/_.data`, `/login.data`), lazy route discovery
|
|
@@ -883,10 +1017,10 @@ export default createServer({
|
|
|
883
1017
|
});
|
|
884
1018
|
```
|
|
885
1019
|
|
|
886
|
-
The upgrade runs the
|
|
887
|
-
validation, as any request does. A
|
|
1020
|
+
The upgrade runs the middlewares declared before the route, and the route's
|
|
1021
|
+
validation, as any request does. A middleware's reply refuses it: the client
|
|
888
1022
|
gets the 401 and its body, and no socket opens. `socket.data` holds the
|
|
889
|
-
validated request and what each
|
|
1023
|
+
validated request and what each middleware derived, typed. See
|
|
890
1024
|
[`@alxia/core`'s WebSockets](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/websockets.md)
|
|
891
1025
|
for the schemas and the rest of the socket.
|
|
892
1026
|
|
|
@@ -906,7 +1040,7 @@ byte for byte, to a `Bun.serve` of the app on a loopback port of its
|
|
|
906
1040
|
own, started on the first upgrade and given
|
|
907
1041
|
`app.fetch` and `app.websocket`, as `listen` gives them. So in dev:
|
|
908
1042
|
|
|
909
|
-
- **The same
|
|
1043
|
+
- **The same middlewares, refusals and handlers run** as from the build:
|
|
910
1044
|
`open`, `message`, `close`, `drain`, the `message` and `send` schemas,
|
|
911
1045
|
`subscribe` and `publish`.
|
|
912
1046
|
- **An edit to `app/server.ts`, or to a module it imports, is used from
|
|
@@ -956,25 +1090,49 @@ refused when the server starts: see
|
|
|
956
1090
|
|
|
957
1091
|
## OpenAPI
|
|
958
1092
|
|
|
959
|
-
|
|
960
|
-
|
|
1093
|
+
alxia is OpenAPI spec first: the document is written by hand, the API's
|
|
1094
|
+
routes are bound to the operations generated from it, and
|
|
1095
|
+
[`@alxia/openapi`](https://www.npmjs.com/package/@alxia/openapi)'s
|
|
1096
|
+
`matchesSpec` checks, in a test, that `app.routes` and the operations
|
|
1097
|
+
match both ways. The catch-all and the client's files are routes of
|
|
1098
|
+
`app.routes`, and no operation of the document. Leave them out with
|
|
961
1099
|
`isReactRouterRoute`, which is true for each route this package declared:
|
|
962
1100
|
|
|
963
1101
|
```ts
|
|
964
1102
|
// app/server.ts
|
|
965
|
-
import {
|
|
966
|
-
import {
|
|
1103
|
+
import { createServer } from '@alxia/react-router';
|
|
1104
|
+
import { operations } from './generated/alxia';
|
|
967
1105
|
|
|
968
1106
|
export default createServer({
|
|
969
1107
|
configure: (app) =>
|
|
970
1108
|
app
|
|
971
1109
|
.get('/api/health', ({ reply }) => reply.ok({ ok: true }))
|
|
972
|
-
.
|
|
1110
|
+
.route(operations.listOrders, ({ reply }) => reply.ok([])),
|
|
973
1111
|
});
|
|
974
1112
|
```
|
|
975
1113
|
|
|
976
|
-
|
|
977
|
-
|
|
1114
|
+
```ts
|
|
1115
|
+
// app/server.test.ts
|
|
1116
|
+
import { test } from 'bun:test';
|
|
1117
|
+
import { matchesSpec } from '@alxia/openapi';
|
|
1118
|
+
import { isReactRouterRoute } from '@alxia/react-router';
|
|
1119
|
+
import type { ServerBuild } from 'react-router';
|
|
1120
|
+
import { operations } from './generated/alxia';
|
|
1121
|
+
import server from './server';
|
|
1122
|
+
|
|
1123
|
+
test('the API routes every operation of openapi.yaml, and nothing else', async () => {
|
|
1124
|
+
const build: ServerBuild = await import(new URL('../build/server/index.js', import.meta.url).href);
|
|
1125
|
+
const app = server.create({ build });
|
|
1126
|
+
matchesSpec(app, operations, {
|
|
1127
|
+
exclude: (route) => isReactRouterRoute(route) || route.path === '/api/health',
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
```
|
|
1131
|
+
|
|
1132
|
+
When the app puts the operations under a prefix the document's paths
|
|
1133
|
+
leave out, `app.group('/api', (api) => api.route(…))`, give `matchesSpec`
|
|
1134
|
+
the same `prefix: '/api'`. See
|
|
1135
|
+
[`@alxia/openapi`'s checks](https://github.com/softistx/alxia/blob/develop/packages/openapi/docs/guide/checks.md).
|
|
978
1136
|
|
|
979
1137
|
## Testing
|
|
980
1138
|
|
|
@@ -1013,28 +1171,26 @@ is `POST /?index`, not `POST /`.
|
|
|
1013
1171
|
|
|
1014
1172
|
## Deploying
|
|
1015
1173
|
|
|
1016
|
-
Run `bun run build`, then `bun build/server/index.js
|
|
1017
|
-
|
|
1018
|
-
|
|
1174
|
+
Run `bun run build`, then `bun build/server/index.js`. The build is
|
|
1175
|
+
[self-contained](#self-contained): `build/` is all the server needs, with
|
|
1176
|
+
no `node_modules`, unless the app keeps a package external with
|
|
1177
|
+
`ssr.external`.
|
|
1019
1178
|
|
|
1020
1179
|
### Docker
|
|
1021
1180
|
|
|
1022
1181
|
The `react-router` template of
|
|
1023
1182
|
[`@alxia/create`](https://github.com/softistx/alxia/tree/develop/packages/create)
|
|
1024
1183
|
and the [example](https://github.com/softistx/alxia/tree/develop/examples/react-router)
|
|
1025
|
-
ship this `Dockerfile`, on Bun's official
|
|
1184
|
+
ship this `Dockerfile`, on Bun's official images: built on `oven/bun:1`,
|
|
1185
|
+
Debian's, and run on `oven/bun:1-alpine`, about 130 MB. For an app started from
|
|
1026
1186
|
`create-react-router`, it replaces the template's, which builds and runs
|
|
1027
1187
|
on Node:
|
|
1028
1188
|
|
|
1029
1189
|
```dockerfile
|
|
1030
|
-
# Bun builds the app and
|
|
1031
|
-
#
|
|
1032
|
-
|
|
1033
|
-
#
|
|
1034
|
-
FROM oven/bun:1 AS production-dependencies
|
|
1035
|
-
WORKDIR /app
|
|
1036
|
-
COPY package.json bun.lock* bunfig.toml* ./
|
|
1037
|
-
RUN bun install --frozen-lockfile --production
|
|
1190
|
+
# Bun builds the app, and the image holds build/ alone: alxia's plugin
|
|
1191
|
+
# bundles every dependency into build/server/index.js, alxia's server, which
|
|
1192
|
+
# runs on Bun with no node_modules. Pinned to Bun 1, the major alxia
|
|
1193
|
+
# supports.
|
|
1038
1194
|
|
|
1039
1195
|
# Every dependency, then react-router build: build/client and
|
|
1040
1196
|
# build/server/index.js.
|
|
@@ -1045,17 +1201,20 @@ RUN bun install --frozen-lockfile
|
|
|
1045
1201
|
COPY . .
|
|
1046
1202
|
RUN bun run build
|
|
1047
1203
|
|
|
1048
|
-
# The build
|
|
1049
|
-
#
|
|
1050
|
-
|
|
1204
|
+
# The build, run as Bun's own non-root user with the start script's
|
|
1205
|
+
# command, and --no-install: with no node_modules, Bun would otherwise
|
|
1206
|
+
# fetch a package the build left out from the registry at startup,
|
|
1207
|
+
# where this fails.
|
|
1208
|
+
# The server listens on PORT (3000) and HOST (0.0.0.0). On Alpine, about
|
|
1209
|
+
# 200 MB lighter: build/ is JavaScript, which runs the same on musl. A
|
|
1210
|
+
# native addon built for glibc alone needs oven/bun:1 here.
|
|
1211
|
+
FROM oven/bun:1-alpine
|
|
1051
1212
|
WORKDIR /app
|
|
1052
1213
|
ENV NODE_ENV=production
|
|
1053
|
-
COPY package.json ./
|
|
1054
|
-
COPY --from=production-dependencies /app/node_modules ./node_modules
|
|
1055
1214
|
COPY --from=build /app/build ./build
|
|
1056
1215
|
USER bun
|
|
1057
1216
|
EXPOSE 3000
|
|
1058
|
-
CMD ["bun", "build/server/index.js"]
|
|
1217
|
+
CMD ["bun", "--no-install", "build/server/index.js"]
|
|
1059
1218
|
```
|
|
1060
1219
|
|
|
1061
1220
|
```sh
|
|
@@ -1067,15 +1226,41 @@ Keep the template's `.dockerignore` beside it: `node_modules`, `build` and
|
|
|
1067
1226
|
`.react-router` stay out of the context, so the image installs and builds
|
|
1068
1227
|
its own. The `bunfig.toml` goes in, and `bun run build` runs React
|
|
1069
1228
|
Router's CLI on Bun, as it does outside Docker. The image's command is
|
|
1070
|
-
`start`'s, run directly, so the platform's `SIGTERM` reaches the server
|
|
1229
|
+
`start`'s, run directly, so the platform's `SIGTERM` reaches the server,
|
|
1230
|
+
with `--no-install`: a package missing from `build/` then fails at
|
|
1231
|
+
startup, where Bun would otherwise fetch it from npm.
|
|
1071
1232
|
|
|
1072
1233
|
- **Commit `bun.lock`.** The installs are `--frozen-lockfile`: the image
|
|
1073
1234
|
gets the versions you tested, and a `package.json` changed without a
|
|
1074
1235
|
`bun install` fails the build
|
|
1075
1236
|
([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#error-lockfile-had-changes-but-lockfile-is-frozen)).
|
|
1237
|
+
- **The final stage is Alpine's**, musl where the build stage has glibc.
|
|
1238
|
+
`build/` is JavaScript, which runs the same on both. A native addon
|
|
1239
|
+
kept external and built for glibc alone does not load there: put the
|
|
1240
|
+
final stage back on `oven/bun:1`
|
|
1241
|
+
([`@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)).
|
|
1076
1242
|
- **The server runs as `bun`**, a user with no write access to `/app`.
|
|
1077
1243
|
Write files to a volume the user owns
|
|
1078
1244
|
([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#eacces-permission-denied-open-app)).
|
|
1245
|
+
- **The image holds `build/` alone**, no `node_modules`: the build is
|
|
1246
|
+
[self-contained](#self-contained). An app that keeps a package external
|
|
1247
|
+
with `ssr.external` copies the production dependencies too, from a
|
|
1248
|
+
stage of their own:
|
|
1249
|
+
|
|
1250
|
+
```dockerfile
|
|
1251
|
+
# Before the build stage: the production dependencies alone.
|
|
1252
|
+
FROM oven/bun:1 AS production-dependencies
|
|
1253
|
+
WORKDIR /app
|
|
1254
|
+
COPY package.json bun.lock* bunfig.toml* ./
|
|
1255
|
+
RUN bun install --frozen-lockfile --production
|
|
1256
|
+
|
|
1257
|
+
# In the final stage, beside build/.
|
|
1258
|
+
COPY --from=production-dependencies /app/node_modules ./node_modules
|
|
1259
|
+
```
|
|
1260
|
+
|
|
1261
|
+
Without it, the container stops at startup with
|
|
1262
|
+
`Cannot find package '…'`
|
|
1263
|
+
([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#error-cannot-find-package--from-appbuildserverindexjs)).
|
|
1079
1264
|
|
|
1080
1265
|
`PORT` and `HOST` set where the server listens. The platform's `SIGTERM`
|
|
1081
1266
|
stops it once the requests in flight are answered, and runs the app's
|
package/docs/roadmap.md
CHANGED
|
@@ -7,7 +7,15 @@ number on it. Every release, with each change it made, is in
|
|
|
7
7
|
|
|
8
8
|
## Now
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
- **Middlewares around the pages.** `beforeAll` and `configure` take
|
|
11
|
+
`use(logger())`, `use(secureHeaders())` and the other middlewares of
|
|
12
|
+
alxia 0.4, which run on the pages, the client's files declared after
|
|
13
|
+
them, and every request no route matches; the hooks they replace still
|
|
14
|
+
work, deprecated.
|
|
15
|
+
- **Loaders typed by core's `Register`.** `alxiaOf(context)` reads the base
|
|
16
|
+
`@alxia/core`'s `Register` names when this package's names no server, so
|
|
17
|
+
an app that registers its context once types its loaders too. This
|
|
18
|
+
package's `Register` still wins when both are declared.
|
|
11
19
|
|
|
12
20
|
## Next
|
|
13
21
|
|
|
@@ -29,9 +37,9 @@ Nothing scheduled yet.
|
|
|
29
37
|
only behind `future.v8_middleware`, and the line is in maintenance.
|
|
30
38
|
- **Node or Cloudflare.** alxia is a Bun framework; React Router's own
|
|
31
39
|
adapters serve those runtimes.
|
|
32
|
-
- **The pages in the
|
|
33
|
-
something
|
|
34
|
-
`
|
|
40
|
+
- **The pages in the OpenAPI document.** Documents and single-fetch data
|
|
41
|
+
are not something a generated client calls, so `isReactRouterRoute`
|
|
42
|
+
leaves the catch-all out of `matchesSpec`'s check.
|
|
35
43
|
- **A runtime dependency.** The package declares none: `@alxia/core` and
|
|
36
44
|
`react-router` are peers.
|
|
37
45
|
- **A `react-router-serve` of its own.** `bun build/server/index.js` is the
|
|
@@ -45,7 +53,15 @@ Nothing scheduled yet.
|
|
|
45
53
|
|
|
46
54
|
## Shipped
|
|
47
55
|
|
|
48
|
-
###
|
|
56
|
+
### 0.4.0
|
|
57
|
+
|
|
58
|
+
- **A self-contained build.** `react-router build` bundles every package
|
|
59
|
+
into `build/server/index.js`, React, React Router and alxia included,
|
|
60
|
+
so `build/` runs with no `node_modules` and a Docker image copies it
|
|
61
|
+
alone. Never in dev; `ssr.external` keeps a package external, and
|
|
62
|
+
`ssr.external: true` every package.
|
|
63
|
+
|
|
64
|
+
### 0.3.0
|
|
49
65
|
|
|
50
66
|
- **The server is built for Bun.** `alxia()` adds the `bun` export
|
|
51
67
|
condition to the `ssr` environment's conditions and external
|
|
@@ -53,9 +69,12 @@ Nothing scheduled yet.
|
|
|
53
69
|
Vite, and targets `esnext`, in dev and in the build, with nothing to
|
|
54
70
|
configure. A package's `bun` variant is the one bundled, and what the
|
|
55
71
|
app sets is kept.
|
|
56
|
-
- **A Docker image on Bun.** The guide's Deploying has
|
|
57
|
-
`Dockerfile` on `oven/bun:1`
|
|
58
|
-
|
|
72
|
+
- **A Docker image on Bun.** The guide's Deploying has a multi-stage
|
|
73
|
+
`Dockerfile` on `oven/bun:1`, running `bun build/server/index.js` as a
|
|
74
|
+
non-root user.
|
|
75
|
+
|
|
76
|
+
### 0.2.0
|
|
77
|
+
|
|
59
78
|
- **A per-request CSP nonce.** `nonceOf(loadContext)` reads the nonce that
|
|
60
79
|
`@alxia/secure-headers`' `nonce: true`, or a `derive` of the app's own,
|
|
61
80
|
put on the context, so `entry.server.tsx` hands it to `<ServerRouter
|
|
@@ -79,7 +98,8 @@ Nothing scheduled yet.
|
|
|
79
98
|
- **`HEAD` with headers.** A `HEAD` is answered as its `GET`, less the
|
|
80
99
|
body.
|
|
81
100
|
- **OpenAPI.** `isReactRouterRoute` leaves the catch-all and the client's
|
|
82
|
-
files out of
|
|
101
|
+
files out of a check of `app.routes` against the document, such as
|
|
102
|
+
`@alxia/openapi`'s `matchesSpec`.
|
|
83
103
|
- **Zero config, with Vite.** `@alxia/react-router/vite`'s `alxia()`,
|
|
84
104
|
anywhere in `plugins`, is all an app from the official template needs.
|
|
85
105
|
Without `app/server.ts` a default server serves the pages; with it,
|