@alxia/react-router 0.4.0 → 0.6.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 +70 -34
- 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 +15 -16
- package/dist/index.js.map +6 -6
- package/dist/nonce.d.ts +2 -2
- package/dist/nonce.d.ts.map +1 -1
- package/dist/react-router.d.ts +14 -14
- package/dist/react-router.d.ts.map +1 -1
- package/dist/server.d.ts +10 -7
- package/dist/server.d.ts.map +1 -1
- package/dist/vite/index.js.map +2 -2
- package/dist/vite/preview.d.ts +1 -1
- package/dist/vite/socket.d.ts +1 -1
- package/docs/README.md +8 -1
- package/docs/guide.md +179 -64
- package/docs/roadmap.md +34 -15
- package/docs/troubleshooting.md +51 -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
|
+
`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:
|
|
30
58
|
|
|
31
59
|
```sh
|
|
32
60
|
bunx create-react-router@latest my-app
|
|
@@ -68,7 +96,7 @@ middleware must come after alxia's.
|
|
|
68
96
|
"scripts": {
|
|
69
97
|
"build": "react-router build",
|
|
70
98
|
"dev": "react-router dev",
|
|
71
|
-
"start": "bun build/server/index.js",
|
|
99
|
+
"start": "NODE_ENV=production bun build/server/index.js",
|
|
72
100
|
"typecheck": "react-router typegen && tsc"
|
|
73
101
|
}
|
|
74
102
|
```
|
|
@@ -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.
|
|
@@ -144,13 +173,21 @@ server build inside it, into one file:
|
|
|
144
173
|
stay out of it.
|
|
145
174
|
- **Run, it listens**: `bun build/server/index.js` listens on `PORT`
|
|
146
175
|
(3000 by default) and `HOST` (`0.0.0.0`). It prints
|
|
147
|
-
`alxia listening on <url
|
|
148
|
-
|
|
176
|
+
`alxia listening on <url>`, and in dev — under `react-router dev`; a
|
|
177
|
+
production build never is, whatever `NODE_ENV` says — the route table
|
|
178
|
+
([`@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
|
|
179
|
+
down as `@alxia/core`'s `listen` does: readiness turns 503, new
|
|
180
|
+
connections are refused, the requests in flight finish within
|
|
181
|
+
`shutdownTimeout` (10 s; `listen: { shutdownTimeout }` sets it), the
|
|
182
|
+
`onStop` hooks run, and the process exits
|
|
183
|
+
([core's guide](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/health-and-shutdown.md)).
|
|
149
184
|
- **Imported, it starts nothing**: prerendering, a test or another server
|
|
150
185
|
can import it safely, since it listens only when it is the process's
|
|
151
186
|
entry point (`import.meta.main`).
|
|
152
187
|
- **The mode follows the command**: `development` under `react-router dev`,
|
|
153
|
-
`production` in a build, whatever `NODE_ENV` says.
|
|
188
|
+
`production` in a build, whatever `NODE_ENV` says. The app's dev switch
|
|
189
|
+
follows it: `alxia({ dev: mode === 'development' })`, so a 500 of the
|
|
190
|
+
build never shows its stack.
|
|
154
191
|
- **The client folder is resolved against the built file**: `../client`,
|
|
155
192
|
from React Router's `buildDirectory`, so it is found wherever the
|
|
156
193
|
process starts.
|
|
@@ -188,7 +225,7 @@ The plugin loads `build/server/index.js` on the first request and hands
|
|
|
188
225
|
every request to its default export, the alxia app, before Vite's own
|
|
189
226
|
files. So the preview answers as `bun run start` does: the pages and their
|
|
190
227
|
data, `/api`, `build/client` with the cache headers of
|
|
191
|
-
[the client's files](#the-clients-files), and every
|
|
228
|
+
[the client's files](#the-clients-files), and every middleware of `beforeAll`
|
|
192
229
|
and `configure`. Requests, their bodies and every `Set-Cookie` pass
|
|
193
230
|
through whole, and pages stream.
|
|
194
231
|
|
|
@@ -212,7 +249,7 @@ React Router prerenders the `prerender` paths of `react-router.config.ts`
|
|
|
212
249
|
through the same preview server, during `react-router build`. A
|
|
213
250
|
prerendered page is therefore rendered by the built server too: its loader
|
|
214
251
|
reads `alxiaOf(context)` and `getLoadContext`'s keys, and `beforeAll`'s
|
|
215
|
-
and `configure`'s
|
|
252
|
+
and `configure`'s middlewares run around it.
|
|
216
253
|
|
|
217
254
|
## Built for Bun
|
|
218
255
|
|
|
@@ -368,7 +405,7 @@ bunx alxia-react-router reveal
|
|
|
368
405
|
|
|
369
406
|
```text
|
|
370
407
|
alxia-react-router: wrote app/server.ts, the server alxia() runs by default.
|
|
371
|
-
Next: uncomment configure in app/server.ts to add the app's
|
|
408
|
+
Next: uncomment configure in app/server.ts to add the app's middlewares and /api; bun run dev picks it up.
|
|
372
409
|
```
|
|
373
410
|
|
|
374
411
|
The file is the server the plugin runs without one, `createServer()`, so
|
|
@@ -427,7 +464,7 @@ React Router's own rendering entries, `app/entry.server.tsx` and
|
|
|
427
464
|
### By hand
|
|
428
465
|
|
|
429
466
|
The example uses `@alxia/logger` and `@alxia/compress`
|
|
430
|
-
(`bun add @alxia/logger @alxia/compress`); any
|
|
467
|
+
(`bun add @alxia/logger @alxia/compress`); any middleware works the same way.
|
|
431
468
|
|
|
432
469
|
```ts
|
|
433
470
|
// app/server.ts
|
|
@@ -460,12 +497,12 @@ Every option is optional:
|
|
|
460
497
|
|
|
461
498
|
| option | what it does |
|
|
462
499
|
| --- | --- |
|
|
463
|
-
| `configure(app)` | the app the pages run behind: its
|
|
500
|
+
| `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 |
|
|
464
501
|
| `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 |
|
|
465
502
|
| `getLoadContext(ctx, context)` | sets the app's own keys on React Router's provider, `ctx` typed by `configure`'s app |
|
|
466
503
|
| `build`, `mode`, `client` | override what the plugin wires; see [Escape hatches](#escape-hatches) |
|
|
467
|
-
| `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` |
|
|
468
|
-
| `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; a signal sent from then on runs the `onStop` hooks |
|
|
504
|
+
| `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 |
|
|
505
|
+
| `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 |
|
|
469
506
|
|
|
470
507
|
A request goes through four layers, in order:
|
|
471
508
|
|
|
@@ -474,11 +511,16 @@ A request goes through four layers, in order:
|
|
|
474
511
|
3. what `configure` declared;
|
|
475
512
|
4. the pages: `GET`, `POST`, `PUT`, `PATCH` and `DELETE` at `/*`.
|
|
476
513
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
514
|
+
A middleware given to `use` runs on every route declared after it, and on a
|
|
515
|
+
request no route matches. So a session or a guard in `configure` runs around
|
|
516
|
+
every page and its data, and not around what is declared before it: the
|
|
517
|
+
client's files, which sit between `beforeAll` and `configure`. What must see
|
|
518
|
+
every request, the client's files included, goes in `beforeAll`: the logger,
|
|
519
|
+
`@alxia/cors`, `@alxia/compress`, `@alxia/secure-headers`. A guard on the app
|
|
520
|
+
answers a request no route matches too: an anonymous request to a missing path
|
|
521
|
+
gets the 401, not the 404. Scope it with a path, `use('/app', guard)`
|
|
522
|
+
(a guard that adds nothing to the context: `bearer` and `session` add `user`),
|
|
523
|
+
to guard only some pages.
|
|
482
524
|
|
|
483
525
|
```ts
|
|
484
526
|
// app/server.ts: a guard on everything, the assets included
|
|
@@ -495,13 +537,13 @@ export default createServer({
|
|
|
495
537
|
```
|
|
496
538
|
|
|
497
539
|
`configure` and `beforeAll` must return the app; one that returns nothing
|
|
498
|
-
is a compile error. A
|
|
540
|
+
is a compile error. A middleware that needs something an earlier one
|
|
499
541
|
derived is typed by the app it is given, as anywhere in alxia.
|
|
500
542
|
|
|
501
543
|
## Typing the loaders
|
|
502
544
|
|
|
503
545
|
Every request through the catch-all sets `alxiaContext`, this package's
|
|
504
|
-
key, on React Router's context provider, to what alxia's
|
|
546
|
+
key, on React Router's context provider, to what alxia's middlewares built for
|
|
505
547
|
that request. `alxiaOf(context)` reads it.
|
|
506
548
|
|
|
507
549
|
### With `Register`: no type argument
|
|
@@ -546,7 +588,7 @@ shows. The checks it gives:
|
|
|
546
588
|
|
|
547
589
|
```ts
|
|
548
590
|
const ctx = alxiaOf(context);
|
|
549
|
-
ctx.tenant; // error: no
|
|
591
|
+
ctx.tenant; // error: no middleware derives `tenant`
|
|
550
592
|
ctx.user.name; // error: `user` may be null
|
|
551
593
|
```
|
|
552
594
|
|
|
@@ -577,19 +619,57 @@ export function loader({ context }: Route.LoaderArgs) {
|
|
|
577
619
|
|
|
578
620
|
The type argument may also be an alxia app, the app *before* the
|
|
579
621
|
catch-all, for a server of your own. Anything else is a compile error.
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
622
|
+
|
|
623
|
+
### With `@alxia/core`'s `Register`
|
|
624
|
+
|
|
625
|
+
`@alxia/core` has a `Register` of its own, which names the chain that
|
|
626
|
+
builds an app's context, for its route files
|
|
627
|
+
([The app's type](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/types.md#register-and-appcontext)).
|
|
628
|
+
`alxiaOf(context)` reads it when this package's `Register` names no
|
|
629
|
+
server:
|
|
630
|
+
|
|
631
|
+
```ts
|
|
632
|
+
// app/context.ts
|
|
633
|
+
import { alxia } from '@alxia/core';
|
|
634
|
+
|
|
635
|
+
export const base = alxia().derive(({ request }) => ({ user: request.headers.get('x-user') }));
|
|
636
|
+
|
|
637
|
+
declare module '@alxia/core' {
|
|
638
|
+
interface Register {
|
|
639
|
+
context: typeof base;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// app/server.ts
|
|
644
|
+
const server = createServer({ configure: (app) => app.plugin(base) });
|
|
645
|
+
export default server;
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
`alxiaOf(context).user` is then typed with no declaration in
|
|
649
|
+
`@alxia/react-router`. Which one is read, in order:
|
|
650
|
+
|
|
651
|
+
1. a type argument, `alxiaOf<typeof server>(context)`;
|
|
652
|
+
2. this package's `Register`, `server: typeof server`: the whole app the
|
|
653
|
+
pages run behind, `base` and everything `configure` adds after it;
|
|
654
|
+
3. `@alxia/core`'s `Register`, `context: typeof base`: only what `base`
|
|
655
|
+
builds;
|
|
656
|
+
4. neither: `BaseContext`, the request, its URL, `reply` and the rest of
|
|
657
|
+
what every middleware reads.
|
|
658
|
+
|
|
659
|
+
Declare this package's `Register` when `configure` adds to the context
|
|
660
|
+
after the base, and the loaders read it.
|
|
583
661
|
|
|
584
662
|
### Why a global augmentation is right here
|
|
585
663
|
|
|
586
|
-
alxia's core refuses global augmentation of
|
|
587
|
-
added `user` to every route, declared before it or after, would
|
|
588
|
-
`user` on routes that run before the plugin. That is the lie "order
|
|
589
|
-
meaning" forbids. `Register`
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
664
|
+
alxia's core refuses a global augmentation of a context key: a plugin
|
|
665
|
+
that added `user` to every route, declared before it or after, would
|
|
666
|
+
type `user` on routes that run before the plugin. That is the lie "order
|
|
667
|
+
is meaning" forbids. Core's own `Register` names the base that builds
|
|
668
|
+
the context instead, and what reads it requires it. `Register` here does
|
|
669
|
+
something else: it names the **one** server of the React Router build,
|
|
670
|
+
at the point of its catch-all, which is exactly what every loader runs
|
|
671
|
+
behind. One build has one server entry, so there is no second app for a
|
|
672
|
+
module to be confused with.
|
|
593
673
|
|
|
594
674
|
If two React Router apps share one TypeScript program (one tsconfig over
|
|
595
675
|
both folders of a monorepo), their two declarations conflict, and `tsc`
|
|
@@ -639,7 +719,7 @@ export function loader({ context }: Route.LoaderArgs) {
|
|
|
639
719
|
|
|
640
720
|
`getLoadContext` runs before React Router, on every request, with
|
|
641
721
|
`alxiaContext` already set. Its `ctx` is typed by `configure`'s app:
|
|
642
|
-
reading something no
|
|
722
|
+
reading something no middleware derives is a compile error. Keep the keys in a
|
|
643
723
|
module of their own, such as `app/context.ts`, rather than in
|
|
644
724
|
`app/server.ts`: a route imports them, and the server file then stays out
|
|
645
725
|
of the client's module graph.
|
|
@@ -729,7 +809,7 @@ scripts included.
|
|
|
729
809
|
### How it stays loose
|
|
730
810
|
|
|
731
811
|
`nonceOf` reads `nonce` from alxia's context if it is a string, and
|
|
732
|
-
returns `undefined` otherwise: no
|
|
812
|
+
returns `undefined` otherwise: no middleware set one, or the request did not come
|
|
733
813
|
through alxia at all. `ServerRouter` and React then render no `nonce`
|
|
734
814
|
attribute. So:
|
|
735
815
|
|
|
@@ -816,7 +896,7 @@ export const base = alxia()
|
|
|
816
896
|
/** What the loaders read: alxiaOf<Base>(context). */
|
|
817
897
|
export type Base = typeof base;
|
|
818
898
|
|
|
819
|
-
const app = base.
|
|
899
|
+
const app = base.plugin((app) =>
|
|
820
900
|
reactRouter(app, {
|
|
821
901
|
build: () =>
|
|
822
902
|
import(new URL('./build/server/index.js', import.meta.url).href) as Promise<ServerBuild>,
|
|
@@ -838,7 +918,7 @@ bun run build && bun server.ts
|
|
|
838
918
|
| `getLoadContext(ctx, context)` | as `createServer`'s |
|
|
839
919
|
| `client` | the client build's folder, a path or a `file:` URL, served before the catch-all in `production` |
|
|
840
920
|
|
|
841
|
-
`reactRouter()` goes through `
|
|
921
|
+
`reactRouter()` goes through `app.plugin`, as `@alxia/graphql`'s `graphql(app, …)`
|
|
842
922
|
does: that is how it knows the app's context type. A `HEAD` is handed to
|
|
843
923
|
React Router as a `GET`, since React Router answers a `HEAD` of its own
|
|
844
924
|
with no headers at all; the core then drops the body. In a monorepo where
|
|
@@ -846,14 +926,15 @@ this package is linked rather than installed, add
|
|
|
846
926
|
`ssr: { external: ['@alxia/react-router'] }` to `vite.config.ts`: see
|
|
847
927
|
[the troubleshooting entry](troubleshooting.md#alxiaof-this-request-has-no-alxia-context-).
|
|
848
928
|
|
|
849
|
-
##
|
|
929
|
+
## Middlewares around the pages
|
|
850
930
|
|
|
851
|
-
Every
|
|
852
|
-
any route:
|
|
931
|
+
Every middleware declared before the catch-all runs around each page, as around
|
|
932
|
+
any route, and `use` observers go first so they wrap everything:
|
|
853
933
|
|
|
854
934
|
- **`@alxia/logger`** writes one entry per request and sets
|
|
855
935
|
`x-request-id`. It times a streamed page by its first byte, since
|
|
856
|
-
|
|
936
|
+
the response is settled when its headers leave. Every request is logged,
|
|
937
|
+
404s and errors included.
|
|
857
938
|
- **`@alxia/compress`** compresses documents and data, and flushes a
|
|
858
939
|
streamed page as React writes it: the shell and its `<Suspense>`
|
|
859
940
|
fallback still arrive first, in zstd, Brotli or gzip. See [the troubleshooting entry](troubleshooting.md#a-streamed-page-arrives-in-one-piece).
|
|
@@ -865,7 +946,8 @@ any route:
|
|
|
865
946
|
[A CSP nonce](#a-csp-nonce).
|
|
866
947
|
- **A guard** — `@alxia/jwt`'s `bearer`, `@alxia/janus`' session — in
|
|
867
948
|
`configure` guards every page and its data alike; in `beforeAll`, the
|
|
868
|
-
client's files too.
|
|
949
|
+
client's files too. On the app, a guard also answers a request no route
|
|
950
|
+
matches with its 401.
|
|
869
951
|
|
|
870
952
|
## Routes beside the pages
|
|
871
953
|
|
|
@@ -883,7 +965,7 @@ export default createServer({
|
|
|
883
965
|
});
|
|
884
966
|
```
|
|
885
967
|
|
|
886
|
-
Each answers with its own
|
|
968
|
+
Each answers with its own middlewares and replies. The core ranks
|
|
887
969
|
paths as `Bun.serve` does, through `listen`, `app.fetch` and
|
|
888
970
|
`app.request` alike: segment by segment, a literal beats a parameter,
|
|
889
971
|
which beats the catch-all's wildcard. Two consequences:
|
|
@@ -894,10 +976,10 @@ which beats the catch-all's wildcard. Two consequences:
|
|
|
894
976
|
every one-segment path, `/about` included, before React Router sees it.
|
|
895
977
|
Put alxia's routes under a prefix of their own, `/api`.
|
|
896
978
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
979
|
+
Pages and single-fetch data are not something a generated client calls:
|
|
980
|
+
leave the catch-all out of `matchesSpec`'s check with `isReactRouterRoute`
|
|
981
|
+
([OpenAPI](#openapi)); what a client does call, your `/api`, is what the
|
|
982
|
+
OpenAPI document declares.
|
|
901
983
|
|
|
902
984
|
What React Router answers comes back as it sent it: documents,
|
|
903
985
|
single-fetch data (`/_.data`, `/login.data`), lazy route discovery
|
|
@@ -943,10 +1025,10 @@ export default createServer({
|
|
|
943
1025
|
});
|
|
944
1026
|
```
|
|
945
1027
|
|
|
946
|
-
The upgrade runs the
|
|
947
|
-
validation, as any request does. A
|
|
1028
|
+
The upgrade runs the middlewares declared before the route, and the route's
|
|
1029
|
+
validation, as any request does. A middleware's reply refuses it: the client
|
|
948
1030
|
gets the 401 and its body, and no socket opens. `socket.data` holds the
|
|
949
|
-
validated request and what each
|
|
1031
|
+
validated request and what each middleware derived, typed. See
|
|
950
1032
|
[`@alxia/core`'s WebSockets](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/websockets.md)
|
|
951
1033
|
for the schemas and the rest of the socket.
|
|
952
1034
|
|
|
@@ -966,7 +1048,7 @@ byte for byte, to a `Bun.serve` of the app on a loopback port of its
|
|
|
966
1048
|
own, started on the first upgrade and given
|
|
967
1049
|
`app.fetch` and `app.websocket`, as `listen` gives them. So in dev:
|
|
968
1050
|
|
|
969
|
-
- **The same
|
|
1051
|
+
- **The same middlewares, refusals and handlers run** as from the build:
|
|
970
1052
|
`open`, `message`, `close`, `drain`, the `message` and `send` schemas,
|
|
971
1053
|
`subscribe` and `publish`.
|
|
972
1054
|
- **An edit to `app/server.ts`, or to a module it imports, is used from
|
|
@@ -1016,25 +1098,49 @@ refused when the server starts: see
|
|
|
1016
1098
|
|
|
1017
1099
|
## OpenAPI
|
|
1018
1100
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1101
|
+
alxia is OpenAPI spec first: the document is written by hand, the API's
|
|
1102
|
+
routes are bound to the operations generated from it, and
|
|
1103
|
+
[`@alxia/openapi`](https://www.npmjs.com/package/@alxia/openapi)'s
|
|
1104
|
+
`matchesSpec` checks, in a test, that `app.routes` and the operations
|
|
1105
|
+
match both ways. The catch-all and the client's files are routes of
|
|
1106
|
+
`app.routes`, and no operation of the document. Leave them out with
|
|
1021
1107
|
`isReactRouterRoute`, which is true for each route this package declared:
|
|
1022
1108
|
|
|
1023
1109
|
```ts
|
|
1024
1110
|
// app/server.ts
|
|
1025
|
-
import {
|
|
1026
|
-
import {
|
|
1111
|
+
import { createServer } from '@alxia/react-router';
|
|
1112
|
+
import { operations } from './generated/alxia';
|
|
1027
1113
|
|
|
1028
1114
|
export default createServer({
|
|
1029
1115
|
configure: (app) =>
|
|
1030
1116
|
app
|
|
1031
1117
|
.get('/api/health', ({ reply }) => reply.ok({ ok: true }))
|
|
1032
|
-
.
|
|
1118
|
+
.route(operations.listOrders, ({ reply }) => reply.ok([])),
|
|
1119
|
+
});
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
```ts
|
|
1123
|
+
// app/server.test.ts
|
|
1124
|
+
import { test } from 'bun:test';
|
|
1125
|
+
import { matchesSpec } from '@alxia/openapi';
|
|
1126
|
+
import { isReactRouterRoute } from '@alxia/react-router';
|
|
1127
|
+
import type { ServerBuild } from 'react-router';
|
|
1128
|
+
import { operations } from './generated/alxia';
|
|
1129
|
+
import server from './server';
|
|
1130
|
+
|
|
1131
|
+
test('the API routes every operation of openapi.yaml, and nothing else', async () => {
|
|
1132
|
+
const build: ServerBuild = await import(new URL('../build/server/index.js', import.meta.url).href);
|
|
1133
|
+
const app = server.create({ build });
|
|
1134
|
+
matchesSpec(app, operations, {
|
|
1135
|
+
exclude: (route) => isReactRouterRoute(route) || route.path === '/api/health',
|
|
1136
|
+
});
|
|
1033
1137
|
});
|
|
1034
1138
|
```
|
|
1035
1139
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1140
|
+
When the app puts the operations under a prefix the document's paths
|
|
1141
|
+
leave out, `app.group('/api', (api) => api.route(…))`, give `matchesSpec`
|
|
1142
|
+
the same `prefix: '/api'`. See
|
|
1143
|
+
[`@alxia/openapi`'s checks](https://github.com/softistx/alxia/blob/develop/packages/openapi/docs/guide/checks.md).
|
|
1038
1144
|
|
|
1039
1145
|
## Testing
|
|
1040
1146
|
|
|
@@ -1083,7 +1189,8 @@ no `node_modules`, unless the app keeps a package external with
|
|
|
1083
1189
|
The `react-router` template of
|
|
1084
1190
|
[`@alxia/create`](https://github.com/softistx/alxia/tree/develop/packages/create)
|
|
1085
1191
|
and the [example](https://github.com/softistx/alxia/tree/develop/examples/react-router)
|
|
1086
|
-
ship this `Dockerfile`, on Bun's official
|
|
1192
|
+
ship this `Dockerfile`, on Bun's official images: built on `oven/bun:1`,
|
|
1193
|
+
Debian's, and run on `oven/bun:1-alpine`, about 130 MB. For an app started from
|
|
1087
1194
|
`create-react-router`, it replaces the template's, which builds and runs
|
|
1088
1195
|
on Node:
|
|
1089
1196
|
|
|
@@ -1106,8 +1213,10 @@ RUN bun run build
|
|
|
1106
1213
|
# command, and --no-install: with no node_modules, Bun would otherwise
|
|
1107
1214
|
# fetch a package the build left out from the registry at startup,
|
|
1108
1215
|
# where this fails.
|
|
1109
|
-
# The server listens on PORT (3000) and HOST (0.0.0.0).
|
|
1110
|
-
|
|
1216
|
+
# The server listens on PORT (3000) and HOST (0.0.0.0). On Alpine, about
|
|
1217
|
+
# 200 MB lighter: build/ is JavaScript, which runs the same on musl. A
|
|
1218
|
+
# native addon built for glibc alone needs oven/bun:1 here.
|
|
1219
|
+
FROM oven/bun:1-alpine
|
|
1111
1220
|
WORKDIR /app
|
|
1112
1221
|
ENV NODE_ENV=production
|
|
1113
1222
|
COPY --from=build /app/build ./build
|
|
@@ -1133,6 +1242,11 @@ startup, where Bun would otherwise fetch it from npm.
|
|
|
1133
1242
|
gets the versions you tested, and a `package.json` changed without a
|
|
1134
1243
|
`bun install` fails the build
|
|
1135
1244
|
([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#error-lockfile-had-changes-but-lockfile-is-frozen)).
|
|
1245
|
+
- **The final stage is Alpine's**, musl where the build stage has glibc.
|
|
1246
|
+
`build/` is JavaScript, which runs the same on both. A native addon
|
|
1247
|
+
kept external and built for glibc alone does not load there: put the
|
|
1248
|
+
final stage back on `oven/bun:1`
|
|
1249
|
+
([`@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)).
|
|
1136
1250
|
- **The server runs as `bun`**, a user with no write access to `/app`.
|
|
1137
1251
|
Write files to a volume the user owns
|
|
1138
1252
|
([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#eacces-permission-denied-open-app)).
|
|
@@ -1157,5 +1271,6 @@ startup, where Bun would otherwise fetch it from npm.
|
|
|
1157
1271
|
([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#error-cannot-find-package--from-appbuildserverindexjs)).
|
|
1158
1272
|
|
|
1159
1273
|
`PORT` and `HOST` set where the server listens. The platform's `SIGTERM`
|
|
1160
|
-
stops it once the requests in flight are answered,
|
|
1161
|
-
`onStop` hooks.
|
|
1274
|
+
stops it once the requests in flight are answered, within
|
|
1275
|
+
`shutdownTimeout`, and runs the app's `onStop` hooks. Give the platform a
|
|
1276
|
+
readiness probe with `@alxia/core`'s `health()`, mounted in `beforeAll`.
|
package/docs/roadmap.md
CHANGED
|
@@ -7,7 +7,20 @@ 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 request hooks they replaced,
|
|
14
|
+
deprecated in 0.4, were removed in 0.5.
|
|
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.
|
|
19
|
+
- **One graceful shutdown.** `start` relies on `@alxia/core`'s `listen`,
|
|
20
|
+
which handles `SIGINT` and `SIGTERM` itself, before `onListen` as
|
|
21
|
+
before: the requests in flight drain within `shutdownTimeout`
|
|
22
|
+
(`listen: { shutdownTimeout }`), readiness turns 503, the `onStop`
|
|
23
|
+
hooks run, then the process exits.
|
|
11
24
|
|
|
12
25
|
## Next
|
|
13
26
|
|
|
@@ -29,9 +42,9 @@ Nothing scheduled yet.
|
|
|
29
42
|
only behind `future.v8_middleware`, and the line is in maintenance.
|
|
30
43
|
- **Node or Cloudflare.** alxia is a Bun framework; React Router's own
|
|
31
44
|
adapters serve those runtimes.
|
|
32
|
-
- **The pages in the
|
|
33
|
-
something
|
|
34
|
-
`
|
|
45
|
+
- **The pages in the OpenAPI document.** Documents and single-fetch data
|
|
46
|
+
are not something a generated client calls, so `isReactRouterRoute`
|
|
47
|
+
leaves the catch-all out of `matchesSpec`'s check.
|
|
35
48
|
- **A runtime dependency.** The package declares none: `@alxia/core` and
|
|
36
49
|
`react-router` are peers.
|
|
37
50
|
- **A `react-router-serve` of its own.** `bun build/server/index.js` is the
|
|
@@ -45,23 +58,28 @@ Nothing scheduled yet.
|
|
|
45
58
|
|
|
46
59
|
## Shipped
|
|
47
60
|
|
|
48
|
-
###
|
|
61
|
+
### 0.4.0
|
|
49
62
|
|
|
50
63
|
- **A self-contained build.** `react-router build` bundles every package
|
|
51
64
|
into `build/server/index.js`, React, React Router and alxia included,
|
|
52
65
|
so `build/` runs with no `node_modules` and a Docker image copies it
|
|
53
66
|
alone. Never in dev; `ssr.external` keeps a package external, and
|
|
54
67
|
`ssr.external: true` every package.
|
|
68
|
+
|
|
69
|
+
### 0.3.0
|
|
70
|
+
|
|
55
71
|
- **The server is built for Bun.** `alxia()` adds the `bun` export
|
|
56
72
|
condition to the `ssr` environment's conditions and external
|
|
57
73
|
conditions, keeps `bun` and `bun:*` external whichever runtime runs
|
|
58
74
|
Vite, and targets `esnext`, in dev and in the build, with nothing to
|
|
59
75
|
configure. A package's `bun` variant is the one bundled, and what the
|
|
60
76
|
app sets is kept.
|
|
61
|
-
- **A Docker image on Bun.** The guide's Deploying has
|
|
62
|
-
`Dockerfile` on `oven/bun:1`
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
- **A Docker image on Bun.** The guide's Deploying has a multi-stage
|
|
78
|
+
`Dockerfile` on `oven/bun:1`, running `bun build/server/index.js` as a
|
|
79
|
+
non-root user.
|
|
80
|
+
|
|
81
|
+
### 0.2.0
|
|
82
|
+
|
|
65
83
|
- **A per-request CSP nonce.** `nonceOf(loadContext)` reads the nonce that
|
|
66
84
|
`@alxia/secure-headers`' `nonce: true`, or a `derive` of the app's own,
|
|
67
85
|
put on the context, so `entry.server.tsx` hands it to `<ServerRouter
|
|
@@ -73,9 +91,9 @@ Nothing scheduled yet.
|
|
|
73
91
|
|
|
74
92
|
- **React Router as a catch-all.** `reactRouter(app, { build })` serves
|
|
75
93
|
documents, single-fetch data, actions, redirects with every cookie, lazy
|
|
76
|
-
route discovery and the error pages, behind every
|
|
77
|
-
it, streamed as React renders them.
|
|
78
|
-
- **The context, typed.** `alxiaOf<App>(context)` reads what alxia's
|
|
94
|
+
route discovery and the error pages, behind every middleware declared
|
|
95
|
+
before it, streamed as React renders them.
|
|
96
|
+
- **The context, typed.** `alxiaOf<App>(context)` reads what alxia's middlewares
|
|
79
97
|
built in any loader, action or middleware, through a key that is one
|
|
80
98
|
object however the server was built; `getLoadContext` sets the app's own
|
|
81
99
|
keys.
|
|
@@ -85,7 +103,8 @@ Nothing scheduled yet.
|
|
|
85
103
|
- **`HEAD` with headers.** A `HEAD` is answered as its `GET`, less the
|
|
86
104
|
body.
|
|
87
105
|
- **OpenAPI.** `isReactRouterRoute` leaves the catch-all and the client's
|
|
88
|
-
files out of
|
|
106
|
+
files out of a check of `app.routes` against the document, such as
|
|
107
|
+
`@alxia/openapi`'s `matchesSpec`.
|
|
89
108
|
- **Zero config, with Vite.** `@alxia/react-router/vite`'s `alxia()`,
|
|
90
109
|
anywhere in `plugins`, is all an app from the official template needs.
|
|
91
110
|
Without `app/server.ts` a default server serves the pages; with it,
|
|
@@ -99,7 +118,7 @@ Nothing scheduled yet.
|
|
|
99
118
|
- **`vite preview` serves the built server.** After `react-router build`,
|
|
100
119
|
`bunx --bun vite preview` hands every request to `build/server/index.js`,
|
|
101
120
|
as `bun run start` would answer it: the pages, `/api`, the client's files
|
|
102
|
-
and the app's
|
|
121
|
+
and the app's middlewares. React Router's prerendering runs on the same
|
|
103
122
|
server, so a prerendered page's loader reads `alxiaOf`.
|
|
104
123
|
- **`bunx alxia-react-router reveal`.** The package's bin writes the
|
|
105
124
|
default server into the app, `app/server.ts` or `alxia({ entry })`'s
|
|
@@ -110,7 +129,7 @@ Nothing scheduled yet.
|
|
|
110
129
|
- **WebSockets in dev and preview.** An alxia `ws` route connects under
|
|
111
130
|
`react-router dev` and `vite preview` as from the build, with nothing to
|
|
112
131
|
configure: the plugin relays each upgrade Vite does not claim (HMR, its
|
|
113
|
-
proxy) to a `Bun.serve` of the app, so its
|
|
132
|
+
proxy) to a `Bun.serve` of the app, so its middlewares, refusals,
|
|
114
133
|
`socket.data` and handlers run as `listen` runs them, and in dev an edit
|
|
115
134
|
to the server is used from the next connection.
|
|
116
135
|
- **Loaders typed with no type argument.** A `Register` declaration beside
|