@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/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
- - [Hooks around the pages](#hooks-around-the-pages)
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
- Start from React Router's official template:
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
- - Its `typescript` 5.9. This package's peer asks for 6 or 7, so `bun add`
100
- warns, but the template's code typechecks with 5.9.
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 hook of `beforeAll`
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 hooks run around it.
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 in dev too, as Bun loads it from the build |
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 hooks and /api; bun run dev picks it up.
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 plugin works the same way.
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 plugins, hooks and `/api`. It returns the app, and what it builds is what the loaders read |
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
- Hooks apply to the routes declared after them, so a session or a guard in
418
- `configure` runs around every page and its data, but not around the
419
- JavaScript of the login page. Global hooks (`onRequest`, `@alxia/cors`,
420
- `@alxia/compress`, `@alxia/secure-headers`) apply everywhere, wherever
421
- they are declared.
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 plugin that needs something an earlier one
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 hooks built for
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 hook derives `tenant`
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
- With neither `Register` nor a type argument, `alxiaOf(context)` is
521
- `BaseContext`: the request, its URL, `reply` and the rest of what every
522
- hook reads.
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 its context: a plugin that
527
- added `user` to every route, declared before it or after, would type
528
- `user` on routes that run before the plugin. That is the lie "order is
529
- meaning" forbids. `Register` here does something else: it names the
530
- **one** server of the React Router build, at the point of its catch-all,
531
- which is exactly what every loader runs behind. One build has one server
532
- entry, so there is no second app for a module to be confused with.
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 hook derives is a compile error. Keep the keys in a
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 hook set one, or the request did not come
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.use((app) =>
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 `use`, as `@alxia/graphql`'s `graphql(app, …)`
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
- ## Hooks around the pages
921
+ ## Middlewares around the pages
790
922
 
791
- Every hook declared before the catch-all runs around each page, as around
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
- `onResponse` runs when the headers leave.
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 schemas, replies and typed client. The core ranks
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
- The catch-all adds nothing to the app's route table: `RoutesOf` and the
838
- typed client never show `/*`. Pages and single-fetch data are not
839
- something a typed client calls; what it does call, your `/api`, is typed
840
- as always.
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 hooks declared before the route, and the route's
887
- validation, as any request does. A hook's reply refuses it: the client
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 hook derived, typed. See
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 hooks, refusals and handlers run** as from the build:
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
- `@alxia/openapi` documents every route of `app.routes`, and the catch-all
960
- and the client's files are routes. Leave them out with
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 { docs } from '@alxia/openapi';
966
- import { createServer, isReactRouterRoute } from '@alxia/react-router';
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
- .use(docs(app, { info: { title: 'Shop', version: '1.0.0' }, exclude: isReactRouterRoute })),
1110
+ .route(operations.listOrders, ({ reply }) => reply.ok([])),
973
1111
  });
974
1112
  ```
975
1113
 
976
- Combine it with your own: `exclude: (route) => isReactRouterRoute(route) ||
977
- route.path === '/api/health'`.
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` beside the project's
1017
- production `node_modules`: the build imports `react`, `react-router` and
1018
- alxia from them, as any React Router server build does.
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 image. For an app started from
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 runs it: build/server/index.js is alxia's server,
1031
- # which runs on Bun. Pinned to Bun 1, the major alxia supports.
1032
-
1033
- # The production dependencies alone, for the final image.
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 and the production dependencies, run as Bun's own non-root
1049
- # user. The server listens on PORT (3000) and HOST (0.0.0.0).
1050
- FROM oven/bun:1
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
- Nothing scheduled yet.
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 route table.** Documents and single-fetch data are not
33
- something the typed client calls, so the catch-all stays out of
34
- `RoutesOf`.
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
- ### Next release
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 the multi-stage
57
- `Dockerfile` on `oven/bun:1` that `@alxia/create`'s template and the
58
- example ship, running `bun build/server/index.js` as a non-root user.
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 `@alxia/openapi`'s document.
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,