@alxia/react-router 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -8
- package/dist/chunks/index-zqa30gr7.js +139 -0
- package/dist/chunks/index-zqa30gr7.js.map +12 -0
- package/dist/pwa/index.d.ts +14 -0
- package/dist/pwa/index.d.ts.map +1 -0
- package/dist/pwa/index.js +165 -0
- package/dist/pwa/index.js.map +14 -0
- package/dist/pwa/model/caching.d.ts +46 -0
- package/dist/pwa/model/caching.d.ts.map +1 -0
- package/dist/pwa/model/clear-app-caches.d.ts +9 -0
- package/dist/pwa/model/clear-app-caches.d.ts.map +1 -0
- package/dist/pwa/model/manifest.d.ts +80 -0
- package/dist/pwa/model/manifest.d.ts.map +1 -0
- package/dist/pwa/model/precache-list.d.ts +40 -0
- package/dist/pwa/model/precache-list.d.ts.map +1 -0
- package/dist/pwa/model/registration.d.ts +39 -0
- package/dist/pwa/model/registration.d.ts.map +1 -0
- package/dist/pwa/model/start-service-worker.d.ts +24 -0
- package/dist/pwa/model/start-service-worker.d.ts.map +1 -0
- package/dist/pwa/model/storable.d.ts +8 -0
- package/dist/pwa/model/storable.d.ts.map +1 -0
- package/dist/pwa/model/update-notice.d.ts +38 -0
- package/dist/pwa/model/update-notice.d.ts.map +1 -0
- package/dist/pwa/model/worker-contract.d.ts +61 -0
- package/dist/pwa/model/worker-contract.d.ts.map +1 -0
- package/dist/pwa/react/index.d.ts +9 -0
- package/dist/pwa/react/index.d.ts.map +1 -0
- package/dist/pwa/react/index.js +75 -0
- package/dist/pwa/react/index.js.map +13 -0
- package/dist/pwa/react/use-online.d.ts +6 -0
- package/dist/pwa/react/use-online.d.ts.map +1 -0
- package/dist/pwa/react/use-service-worker.d.ts +33 -0
- package/dist/pwa/react/use-service-worker.d.ts.map +1 -0
- package/dist/pwa/react/use-update-notice.d.ts +14 -0
- package/dist/pwa/react/use-update-notice.d.ts.map +1 -0
- package/dist/pwa/server/index.d.ts +7 -0
- package/dist/pwa/server/index.d.ts.map +1 -0
- package/dist/pwa/server/index.js +18 -0
- package/dist/pwa/server/index.js.map +10 -0
- package/dist/pwa/server/no-cache.d.ts +22 -0
- package/dist/pwa/server/no-cache.d.ts.map +1 -0
- package/docs/README.md +12 -3
- package/docs/guide/beside-the-pages.md +178 -0
- package/docs/guide/csp-nonce.md +100 -0
- package/docs/guide/customising-the-server.md +292 -0
- package/docs/guide/deploying.md +99 -0
- package/docs/guide/getting-started.md +112 -0
- package/docs/guide/how-it-works.md +262 -0
- package/docs/guide/openapi.md +51 -0
- package/docs/guide/pwa.md +475 -0
- package/docs/guide/testing.md +42 -0
- package/docs/guide/typing-the-loaders.md +311 -0
- package/docs/guide.md +27 -1391
- package/docs/roadmap.md +25 -1
- package/docs/troubleshooting.md +195 -9
- package/package.json +32 -10
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The service worker's own policy: a worker takes the policy its script
|
|
3
|
+
* came with, not the pages'. It runs no other script and fetches this site
|
|
4
|
+
* only (the precache, navigations); its routes never fetch another origin.
|
|
5
|
+
*/
|
|
6
|
+
export declare const WORKER_POLICY = "default-src 'none'; connect-src 'self'";
|
|
7
|
+
/**
|
|
8
|
+
* The worker's script with `Cache-Control: no-cache`: the client's
|
|
9
|
+
* top-level files are served for an hour, and the worker's script must be
|
|
10
|
+
* revalidated on every update check, or an update waits that long. And with
|
|
11
|
+
* the worker's policy (`WORKER_POLICY`, or your own `policy`), which no
|
|
12
|
+
* other static file needs: none of them is a document but an offline page,
|
|
13
|
+
* which carries its own in a `<meta>`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function revalidated(response: Response, policy?: string): Response;
|
|
16
|
+
/**
|
|
17
|
+
* For `use(SERVICE_WORKER_URL, noCache)` in `createServer({ beforeAll })`,
|
|
18
|
+
* before the client's files: they answer without calling `next` for a
|
|
19
|
+
* middleware declared after them.
|
|
20
|
+
*/
|
|
21
|
+
export declare const noCache: NoInfer<import("@alxia/core").Middleware<import("@alxia/core").Empty, Promise<Response>>>;
|
|
22
|
+
//# sourceMappingURL=no-cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"no-cache.d.ts","sourceRoot":"","sources":["../../../src/pwa/server/no-cache.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,eAAO,MAAM,aAAa,2CAA2C,CAAC;AAEtE;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAC1B,QAAQ,EAAE,QAAQ,EAClB,MAAM,GAAE,MAAsB,GAC5B,QAAQ,CAKV;AAED;;;;GAIG;AACH,eAAO,MAAM,OAAO,2FAEnB,CAAC"}
|
package/docs/README.md
CHANGED
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
The [package README](../README.md) is the short version. This folder is
|
|
4
4
|
the long one: the setup, the Vite plugin and its default server, the build for Bun,
|
|
5
|
-
customising the server, the typed context, the client's files,
|
|
6
|
-
do with each message.
|
|
5
|
+
customising the server, the typed context, the client's files, making the app
|
|
6
|
+
installable and usable offline, and what to do with each message.
|
|
7
7
|
|
|
8
8
|
| Page | Read it when |
|
|
9
9
|
| --- | --- |
|
|
10
|
-
| [
|
|
10
|
+
| [Getting started](guide/getting-started.md) | starting a new app with `bun create @alxia --template react-router`, adding alxia to the official template or an existing app, or revealing the default server with `bunx alxia-react-router reveal` |
|
|
11
|
+
| [How it works](guide/how-it-works.md) | what happens in dev, in a build and under `vite preview` (and what prerendering serves), and what the plugin sets so the server is built for Bun |
|
|
12
|
+
| [Customising the server](guide/customising-the-server.md) | changing the default server with `createServer()`, or leaving it: another entry, overriding the wiring, a server of your own |
|
|
13
|
+
| [Typing the loaders](guide/typing-the-loaders.md) | typing the loaders with `Register` or a type argument, and the app's own context keys |
|
|
14
|
+
| [A CSP nonce](guide/csp-nonce.md) | a Content-Security-Policy without `'unsafe-inline'`: giving the inline scripts React Router renders a per-request nonce |
|
|
15
|
+
| [Beside the pages](guide/beside-the-pages.md) | the middlewares that run around the pages, routes beside them, WebSockets in dev, in preview and in the build, and the client's files |
|
|
16
|
+
| [OpenAPI](guide/openapi.md) | writing the API of an app that serves pages spec first: binding its routes to the generated operations, and checking them with `matchesSpec` |
|
|
17
|
+
| [Testing](guide/testing.md) | making the app in process with `server.create(wiring)` from a build, and testing it without a server |
|
|
18
|
+
| [Deploying](guide/deploying.md) | running the build with `bun build/server/index.js`, and the Docker image |
|
|
19
|
+
| [Installable and offline](guide/pwa.md) | making the app installable and usable offline: the subpaths of `@alxia/react-router/pwa`, the service worker's contract and cache names, which requests the worker caches and which responses it stores, the precache list read from a build, or clearing the caches at sign-out |
|
|
11
20
|
| [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a page streams, hydrates or reads its context unlike you expected |
|
|
12
21
|
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
|
13
22
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Beside the pages
|
|
2
|
+
|
|
3
|
+
This page covers what runs around and beside the pages: the middlewares
|
|
4
|
+
before the catch-all, routes of the app's own, WebSockets in dev, in preview
|
|
5
|
+
and in the build, and the client's files.
|
|
6
|
+
|
|
7
|
+
## Middlewares around the pages
|
|
8
|
+
|
|
9
|
+
Every middleware declared before the catch-all runs around each page, as around
|
|
10
|
+
any route, and `use` observers go first so they wrap everything:
|
|
11
|
+
|
|
12
|
+
- **`@alxia/logger`** writes one entry per request and sets
|
|
13
|
+
`x-request-id`. It times a streamed page by its first byte, since
|
|
14
|
+
the response is settled when its headers leave. Every request is logged,
|
|
15
|
+
404s and errors included.
|
|
16
|
+
- **`@alxia/compress`** compresses documents and data, and flushes a
|
|
17
|
+
streamed page as React writes it: the shell and its `<Suspense>`
|
|
18
|
+
fallback still arrive first, in zstd, Brotli or gzip. See [the troubleshooting entry](../troubleshooting.md#a-streamed-page-arrives-in-one-piece).
|
|
19
|
+
- **`@alxia/secure-headers`**' default policy, `default-src 'none'`, blocks
|
|
20
|
+
every script of the page, React Router's inline ones included, and its
|
|
21
|
+
`form-action 'none'` blocks a `<Form>`'s post. Give the pages a policy of
|
|
22
|
+
their own: see [the troubleshooting entry](../troubleshooting.md#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-default-src-none).
|
|
23
|
+
With `nonce: true`, the scripts need no `'unsafe-inline'`:
|
|
24
|
+
[A CSP nonce](csp-nonce.md#a-csp-nonce).
|
|
25
|
+
- **A guard** — `@alxia/jwt`'s `bearer`, `@alxia/janus`' session — in
|
|
26
|
+
`configure` guards every page and its data alike; in `beforeAll`, the
|
|
27
|
+
client's files too. On the app, a guard also answers a request no route
|
|
28
|
+
matches with its 401.
|
|
29
|
+
|
|
30
|
+
## Routes beside the pages
|
|
31
|
+
|
|
32
|
+
alxia's own routes answer their paths wherever they are declared:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// app/server.ts
|
|
36
|
+
import { createServer } from '@alxia/react-router';
|
|
37
|
+
|
|
38
|
+
export default createServer({
|
|
39
|
+
configure: (app) =>
|
|
40
|
+
app
|
|
41
|
+
.get('/api/health', ({ reply }) => reply.ok({ ok: true }))
|
|
42
|
+
.get('/api/orders/:id', ({ params, reply }) => reply.ok({ id: params.id })),
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Each answers with its own middlewares and replies. The core ranks
|
|
47
|
+
paths as `Bun.serve` does, through `listen`, `app.fetch` and
|
|
48
|
+
`app.request` alike: segment by segment, a literal beats a parameter,
|
|
49
|
+
which beats the catch-all's wildcard. Two consequences:
|
|
50
|
+
|
|
51
|
+
- **The path is chosen before the method.** With only `POST /api/orders`,
|
|
52
|
+
a `GET /api/orders` is alxia's 405, not a page.
|
|
53
|
+
- **A route whose path covers pages takes them.** `GET /:slug` answers
|
|
54
|
+
every one-segment path, `/about` included, before React Router sees it.
|
|
55
|
+
Put alxia's routes under a prefix of their own, `/api`.
|
|
56
|
+
|
|
57
|
+
Pages and single-fetch data are not something a generated client calls:
|
|
58
|
+
leave the catch-all out of `matchesSpec`'s check with `isReactRouterRoute`
|
|
59
|
+
([OpenAPI](openapi.md#openapi)); what a client does call, your `/api`, is what the
|
|
60
|
+
OpenAPI document declares.
|
|
61
|
+
|
|
62
|
+
What React Router answers comes back as it sent it: documents,
|
|
63
|
+
single-fetch data (`/_.data`, `/login.data`), lazy route discovery
|
|
64
|
+
(`/__manifest`), redirects with every `Set-Cookie` they carry, and the
|
|
65
|
+
error pages, a 404 or a 500 rendered by the app's `ErrorBoundary`.
|
|
66
|
+
|
|
67
|
+
## WebSockets
|
|
68
|
+
|
|
69
|
+
A `ws` route declared in `configure` (or `beforeAll`) is a socket under
|
|
70
|
+
`react-router dev`, under `vite preview` and from
|
|
71
|
+
`bun build/server/index.js` alike. There is
|
|
72
|
+
nothing to add: no option, no package, no second server to start.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// app/server.ts
|
|
76
|
+
import { createServer } from '@alxia/react-router';
|
|
77
|
+
|
|
78
|
+
export default createServer({
|
|
79
|
+
configure: (app) =>
|
|
80
|
+
app
|
|
81
|
+
.derive(({ request }) => {
|
|
82
|
+
const name = request.headers.get('x-user');
|
|
83
|
+
return { user: name === null ? null : { name } };
|
|
84
|
+
})
|
|
85
|
+
.ws('/api/echo', {}, {
|
|
86
|
+
open: (socket) => socket.send({ hello: socket.data.user?.name ?? 'anonymous' }),
|
|
87
|
+
message: (socket, message) => socket.send({ echo: String(message) }),
|
|
88
|
+
})
|
|
89
|
+
.group((guarded) =>
|
|
90
|
+
guarded
|
|
91
|
+
.derive(({ user, reply }) =>
|
|
92
|
+
user === null ? reply(401, { error: 'unauthenticated' as const }) : { user },
|
|
93
|
+
)
|
|
94
|
+
.ws('/api/rooms/:room', {}, {
|
|
95
|
+
open: (socket) => socket.subscribe(socket.data.params.room),
|
|
96
|
+
message: (socket, message) =>
|
|
97
|
+
socket.publish(socket.data.params.room, {
|
|
98
|
+
from: socket.data.user.name,
|
|
99
|
+
text: String(message),
|
|
100
|
+
}),
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The upgrade runs the middlewares declared before the route, and the route's
|
|
107
|
+
validation, as any request does. A middleware's reply refuses it: the client
|
|
108
|
+
gets the 401 and its body, and no socket opens. `socket.data` holds the
|
|
109
|
+
validated request and what each middleware derived, typed. See
|
|
110
|
+
[`@alxia/core`'s WebSockets](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/websockets.md)
|
|
111
|
+
for the schemas and the rest of the socket.
|
|
112
|
+
|
|
113
|
+
The guard sits in a `group` so it refuses the sockets alone: declared on
|
|
114
|
+
`app` itself, it would apply to every route after it, the pages'
|
|
115
|
+
catch-all included.
|
|
116
|
+
|
|
117
|
+
### Under `react-router dev`
|
|
118
|
+
|
|
119
|
+
Vite's dev server is a `node:http` server, and an alxia socket is
|
|
120
|
+
`Bun.serve`'s upgrade. The plugin listens for Vite's `upgrade` event and
|
|
121
|
+
leaves Vite's own socket alone: an upgrade asking for the `vite-hmr` or
|
|
122
|
+
`vite-ping` subprotocol is Vite's, and HMR works as before. Any other
|
|
123
|
+
upgrade, except one Vite's `server.proxy` relays itself (an entry with
|
|
124
|
+
`ws: true` or a `ws:` target, matched as Vite matches it), is relayed,
|
|
125
|
+
byte for byte, to a `Bun.serve` of the app on a loopback port of its
|
|
126
|
+
own, started on the first upgrade or the first request and given
|
|
127
|
+
`app.fetch` and `app.websocket`, as `listen` gives them. So in dev:
|
|
128
|
+
|
|
129
|
+
- **The same middlewares, refusals and handlers run** as from the build:
|
|
130
|
+
`open`, `message`, `close`, `drain`, the `message` and `send` schemas,
|
|
131
|
+
`subscribe` and `publish`.
|
|
132
|
+
- **An edit to `app/server.ts`, or to a module it imports, is used from
|
|
133
|
+
the next connection.** The edit makes a new app, and the next upgrade
|
|
134
|
+
starts a new `Bun.serve` for it. A socket opened before the edit keeps
|
|
135
|
+
the handlers it opened with until it closes.
|
|
136
|
+
- **A `publish` reaches the sockets opened since the same edit**: the
|
|
137
|
+
sockets opened before it are on the previous server. Reload the pages
|
|
138
|
+
after an edit to reconnect them.
|
|
139
|
+
- **A loader's or an action's `alxiaOf(context).server` is that loopback
|
|
140
|
+
server**, the one the sockets are on, so its `publish` reaches them as
|
|
141
|
+
from the build: see [The server](typing-the-loaders.md#the-server-server).
|
|
142
|
+
- **A socket's `ctx.ip` is the loopback address**, the relay's. The
|
|
143
|
+
browser is local in dev, so that is usually its address anyway.
|
|
144
|
+
- **An upgrade to a path with no `ws` route** gets what the build answers
|
|
145
|
+
it too, never a socket: alxia's 404 where nothing matches, the page
|
|
146
|
+
where the catch-all does.
|
|
147
|
+
- **Another plugin that listens for upgrades** competes with the relay
|
|
148
|
+
on the paths it shares with it: give its socket a path under a
|
|
149
|
+
`server.proxy` entry with `ws: true` or a `ws:` target, or outside the
|
|
150
|
+
app's.
|
|
151
|
+
- **With Vite in middleware mode**, there is no `node:http` server of
|
|
152
|
+
Vite's to listen on: serve the app yourself, as in
|
|
153
|
+
[A server of your own](customising-the-server.md#a-server-of-your-own-without-the-plugin).
|
|
154
|
+
|
|
155
|
+
Under `vite preview` the same relay runs, to a `Bun.serve` of the built
|
|
156
|
+
app, loaded once. Under `bun build/server/index.js`, nothing of this runs:
|
|
157
|
+
`listen` serves the sockets itself, as any alxia app's.
|
|
158
|
+
|
|
159
|
+
## The client's files
|
|
160
|
+
|
|
161
|
+
In a build, each top-level entry of `build/client` becomes a route, after
|
|
162
|
+
`beforeAll` and before `configure`:
|
|
163
|
+
|
|
164
|
+
| entry | route | `Cache-Control` |
|
|
165
|
+
| --- | --- | --- |
|
|
166
|
+
| `assets/`, the hashed bundles | `static('/assets', …)` | `public, max-age=31536000, immutable` |
|
|
167
|
+
| another folder, such as `images/` from `public/images/` | `static('/images', …)` | `public, max-age=3600` |
|
|
168
|
+
| a file, such as `robots.txt` or `favicon.ico` | `file('/robots.txt', …)` | `public, max-age=3600` |
|
|
169
|
+
|
|
170
|
+
They are the core's `static` and `file` routes, so ETags,
|
|
171
|
+
`Last-Modified`, 304s and ranges are included. A missing file under one
|
|
172
|
+
of those folders gets alxia's JSON 404, `{ "error": "not_found" }`, not a
|
|
173
|
+
page, so a folder of `public/` shadows any page path under the same name.
|
|
174
|
+
Dotfiles are skipped. In dev, Vite serves them.
|
|
175
|
+
|
|
176
|
+
A folder that is not one, or a file whose name no route can carry, is
|
|
177
|
+
refused when the server starts: see
|
|
178
|
+
[the troubleshooting entries](../troubleshooting.md#typeerror-reactrouter-client-is--which-is-not-a-directory-).
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# A CSP nonce
|
|
2
|
+
|
|
3
|
+
This page covers a Content-Security-Policy without `'unsafe-inline'`: the
|
|
4
|
+
policy in `app/server.ts`, the nonce passed on in `app/entry.server.tsx`, and
|
|
5
|
+
why the nonce can stay loose.
|
|
6
|
+
|
|
7
|
+
React Router renders inline scripts: the hydration data, the module
|
|
8
|
+
loader, the scroll restoration, and with streaming one more per resolved
|
|
9
|
+
`<Await>`. A policy without `'unsafe-inline'` allows them only when each
|
|
10
|
+
carries the nonce the policy names. React Router writes it on every one of
|
|
11
|
+
them, and on its `modulepreload` links, when `entry.server.tsx` passes it
|
|
12
|
+
to `<ServerRouter nonce>` and to React's renderer
|
|
13
|
+
([React Router's security guide](https://reactrouter.com/how-to/security)).
|
|
14
|
+
|
|
15
|
+
`@alxia/secure-headers` makes the nonce: `secureHeaders({ nonce: true })`
|
|
16
|
+
draws a fresh one per request, adds it to the policy's `script-src`, and
|
|
17
|
+
puts it on the context of the routes after it, the catch-all included.
|
|
18
|
+
`nonceOf(loadContext)` reads it in the entry.
|
|
19
|
+
|
|
20
|
+
## 1. The policy, in `app/server.ts`
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { createServer } from '@alxia/react-router';
|
|
24
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
25
|
+
|
|
26
|
+
export default createServer({
|
|
27
|
+
configure: (app) =>
|
|
28
|
+
app.use(
|
|
29
|
+
secureHeaders({
|
|
30
|
+
nonce: true,
|
|
31
|
+
contentSecurityPolicy: [
|
|
32
|
+
"default-src 'self'",
|
|
33
|
+
"script-src 'self'",
|
|
34
|
+
"style-src 'self' 'unsafe-inline'",
|
|
35
|
+
"img-src 'self' data:",
|
|
36
|
+
"connect-src 'self'",
|
|
37
|
+
"form-action 'self'",
|
|
38
|
+
"base-uri 'self'",
|
|
39
|
+
"frame-ancestors 'none'",
|
|
40
|
+
].join('; '),
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`script-src 'self'` goes out as `script-src 'self' 'nonce-…'`, a new
|
|
47
|
+
value on every response. `'self'` still allows the bundles under
|
|
48
|
+
`/assets`, and `connect-src 'self'` the single-fetch data requests.
|
|
49
|
+
|
|
50
|
+
## 2. The nonce, in `app/entry.server.tsx`
|
|
51
|
+
|
|
52
|
+
React Router's template has no entry; reveal the default one, then add an
|
|
53
|
+
import and the nonce in two places:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
bunx react-router reveal entry.server
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```diff
|
|
60
|
+
// app/entry.server.tsx, as reveal writes it
|
|
61
|
+
import { PassThrough } from "node:stream";
|
|
62
|
+
|
|
63
|
+
+import { nonceOf } from "@alxia/react-router";
|
|
64
|
+
import type { EntryContext, RouterContextProvider } from "react-router";
|
|
65
|
+
…
|
|
66
|
+
const { pipe, abort } = renderToPipeableStream(
|
|
67
|
+
- <ServerRouter context={routerContext} url={request.url} />,
|
|
68
|
+
+ <ServerRouter context={routerContext} url={request.url} nonce={nonceOf(loadContext)} />,
|
|
69
|
+
{
|
|
70
|
+
+ nonce: nonceOf(loadContext),
|
|
71
|
+
[readyOption]() {
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`loadContext` is the entry's fifth parameter, the `RouterContextProvider`
|
|
75
|
+
alxia hands React Router; the revealed file already declares it. With
|
|
76
|
+
`renderToReadableStream`, the web entry, the option is the same: `{ nonce:
|
|
77
|
+
nonceOf(loadContext), … }`.
|
|
78
|
+
|
|
79
|
+
That is all: under `react-router dev` and from the build, every `<script>`
|
|
80
|
+
of a page carries the nonce of its own response's policy, Vite's dev
|
|
81
|
+
scripts included.
|
|
82
|
+
|
|
83
|
+
## How it stays loose
|
|
84
|
+
|
|
85
|
+
`nonceOf` reads `nonce` from alxia's context if it is a string, and
|
|
86
|
+
returns `undefined` otherwise: no middleware set one, or the request did not come
|
|
87
|
+
through alxia at all. `ServerRouter` and React then render no `nonce`
|
|
88
|
+
attribute. So:
|
|
89
|
+
|
|
90
|
+
- this package does not depend on `@alxia/secure-headers`, nor the reverse;
|
|
91
|
+
- the same `entry.server.tsx` serves an app with or without the policy;
|
|
92
|
+
- a nonce of your own works too, from any `derive` in `configure` that
|
|
93
|
+
returns `{ nonce: string }`; the policy is then yours to write with it.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
configure: (app) => app.derive(() => ({ nonce: myNonce() })),
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Under `exactOptionalPropertyTypes`, spread it in only when there is one:
|
|
100
|
+
see [the troubleshooting entry](../troubleshooting.md#type---nonce-string--undefined--is-not-assignable-to-type-serverrouterprops-with-exactoptionalpropertytypes-true).
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# Customising the server
|
|
2
|
+
|
|
3
|
+
This page covers changing the default server with `createServer()`, and the
|
|
4
|
+
escape hatches for leaving it: another entry, overriding the wiring, or a
|
|
5
|
+
server of your own.
|
|
6
|
+
|
|
7
|
+
Write `app/server.ts`, with `createServer()` as its default export, or let
|
|
8
|
+
the package's bin write it for you.
|
|
9
|
+
|
|
10
|
+
## Revealing the default server
|
|
11
|
+
|
|
12
|
+
From the app's root, once `@alxia/react-router` is installed:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
bunx alxia-react-router reveal
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
alxia-react-router: wrote app/server.ts, the server alxia() runs by default.
|
|
20
|
+
Next: uncomment configure in app/server.ts to add the app's middlewares and /api; bun run dev picks it up.
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The file is the server the plugin runs without one, `createServer()`, so
|
|
24
|
+
the app answers as before. It holds `beforeAll`, `configure` and
|
|
25
|
+
`getLoadContext`, commented, each of which compiles once uncommented
|
|
26
|
+
(`getLoadContext`'s key is yours to make, below), and the `Register`
|
|
27
|
+
declaration that types the loaders:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// app/server.ts, as reveal writes it, less its comments
|
|
31
|
+
import { createServer } from '@alxia/react-router';
|
|
32
|
+
// import { userAgentContext } from './context';
|
|
33
|
+
|
|
34
|
+
const server = createServer({
|
|
35
|
+
// beforeAll: (app) => app,
|
|
36
|
+
// configure: (app) => app.get('/api/health', ({ reply }) => reply.ok({ ok: true })),
|
|
37
|
+
// getLoadContext: (ctx, context) => {
|
|
38
|
+
// context.set(userAgentContext, ctx.request.headers.get('user-agent'));
|
|
39
|
+
// },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export default server;
|
|
43
|
+
|
|
44
|
+
declare module '@alxia/react-router' {
|
|
45
|
+
interface Register {
|
|
46
|
+
server: typeof server;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`getLoadContext`'s example sets a key of the app's own, imported from
|
|
52
|
+
`app/context.ts`. Make it there before uncommenting both lines:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// app/context.ts
|
|
56
|
+
import { createContext } from 'react-router';
|
|
57
|
+
|
|
58
|
+
export const userAgentContext = createContext<string | null>(null);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- **Where it writes**: the `entry` that `alxia({ entry: '…' })` names in
|
|
62
|
+
`vite.config.ts`, or `server.ts` in React Router's `appDirectory`
|
|
63
|
+
(`app/` unless `react-router.config.ts` names another). It reads both as
|
|
64
|
+
string literals, past comments. A computed one is refused, since
|
|
65
|
+
reveal would write a file the plugin does not load: give it as a
|
|
66
|
+
literal.
|
|
67
|
+
- **It never overwrites**: a file already there is left as it is, and the
|
|
68
|
+
command exits 1. `bunx alxia-react-router reveal --force` overwrites it.
|
|
69
|
+
- **Bun only**: the bin starts with `#!/usr/bin/env bun`, so no Node is
|
|
70
|
+
needed.
|
|
71
|
+
|
|
72
|
+
React Router's own rendering entries, `app/entry.server.tsx` and
|
|
73
|
+
`app/entry.client.tsx`, are revealed by React Router:
|
|
74
|
+
`bunx react-router reveal`.
|
|
75
|
+
|
|
76
|
+
## By hand
|
|
77
|
+
|
|
78
|
+
The example uses `@alxia/logger` and `@alxia/compress`
|
|
79
|
+
(`bun add @alxia/logger @alxia/compress`); any middleware works the same way.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// app/server.ts
|
|
83
|
+
import { compress } from '@alxia/compress';
|
|
84
|
+
import { logger } from '@alxia/logger';
|
|
85
|
+
import { createServer } from '@alxia/react-router';
|
|
86
|
+
|
|
87
|
+
const server = createServer({
|
|
88
|
+
configure: (app) =>
|
|
89
|
+
app
|
|
90
|
+
.use(logger())
|
|
91
|
+
.use(compress())
|
|
92
|
+
.get('/api/health', ({ reply }) => reply.ok({ ok: true }))
|
|
93
|
+
.derive(({ request }) => {
|
|
94
|
+
const name = request.headers.get('x-user');
|
|
95
|
+
return { user: name === null ? null : { name } };
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export default server;
|
|
100
|
+
|
|
101
|
+
declare module '@alxia/react-router' {
|
|
102
|
+
interface Register {
|
|
103
|
+
server: typeof server;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Every option is optional:
|
|
109
|
+
|
|
110
|
+
| option | what it does |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `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 |
|
|
113
|
+
| `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 |
|
|
114
|
+
| `getLoadContext(ctx, context)` | sets the app's own keys on React Router's provider, `ctx` typed by `configure`'s app |
|
|
115
|
+
| `build`, `mode`, `client` | override what the plugin wires; see [Escape hatches](#escape-hatches) |
|
|
116
|
+
| `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 |
|
|
117
|
+
| `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 |
|
|
118
|
+
|
|
119
|
+
A request goes through four layers, in order:
|
|
120
|
+
|
|
121
|
+
1. what `beforeAll` declared;
|
|
122
|
+
2. the client's files, `build/client`, in a build;
|
|
123
|
+
3. what `configure` declared;
|
|
124
|
+
4. the pages: `GET`, `POST`, `PUT`, `PATCH` and `DELETE` at `/*`.
|
|
125
|
+
|
|
126
|
+
A middleware given to `use` runs on every route declared after it, and on a
|
|
127
|
+
request no route matches. So a session or a guard in `configure` runs around
|
|
128
|
+
every page and its data, and not around what is declared before it: the
|
|
129
|
+
client's files, which sit between `beforeAll` and `configure`. What must see
|
|
130
|
+
every request, the client's files included, goes in `beforeAll`: the logger,
|
|
131
|
+
`@alxia/cors`, `@alxia/compress`, `@alxia/secure-headers`. A guard on the app
|
|
132
|
+
answers a request no route matches too: an anonymous request to a missing path
|
|
133
|
+
gets the 401, not the 404. Scope it with a path, `use('/app', guard)`
|
|
134
|
+
(a guard that adds nothing to the context: `bearer` and `session` add `user`),
|
|
135
|
+
to guard only some pages.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// app/server.ts: a guard on everything, the assets included
|
|
139
|
+
import { createServer } from '@alxia/react-router';
|
|
140
|
+
|
|
141
|
+
export default createServer({
|
|
142
|
+
beforeAll: (app) =>
|
|
143
|
+
app.derive(({ request, reply }) =>
|
|
144
|
+
request.headers.get('authorization') === `Bearer ${process.env['TOKEN']}`
|
|
145
|
+
? {}
|
|
146
|
+
: reply(401, { error: 'unauthorized' }),
|
|
147
|
+
),
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`configure` and `beforeAll` must return the app; one that returns nothing
|
|
152
|
+
is a compile error. A middleware that needs something an earlier one
|
|
153
|
+
derived is typed by the app it is given, as anywhere in alxia.
|
|
154
|
+
|
|
155
|
+
## Behind a proxy
|
|
156
|
+
|
|
157
|
+
A proxy or a load balancer that terminates TLS asks the server over plain
|
|
158
|
+
HTTP, at an address of its own, so React Router's `request.url` reads
|
|
159
|
+
`http://10.0.0.5:3000/…`. Name the proxies with `proxy`, `@alxia/core`'s
|
|
160
|
+
`trustProxy`:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// app/server.ts
|
|
164
|
+
import { trustProxy } from '@alxia/core';
|
|
165
|
+
import { createServer } from '@alxia/react-router';
|
|
166
|
+
|
|
167
|
+
export default createServer({
|
|
168
|
+
proxy: trustProxy({ trusted: ['10.0.0.0/8'], untrusted: 'refuse' }),
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
// app/routes/login.tsx
|
|
174
|
+
import type { LoaderFunctionArgs } from 'react-router';
|
|
175
|
+
|
|
176
|
+
export function loader({ request }: LoaderFunctionArgs) {
|
|
177
|
+
// https://example.com/auth/callback behind the proxy
|
|
178
|
+
return { callback: new URL('/auth/callback', request.url).href };
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Every request through the catch-all is handed to React Router at
|
|
183
|
+
`originalUrl(ctx)`: the scheme and host the trusted proxy wrote in
|
|
184
|
+
`X-Forwarded-Proto` and `X-Forwarded-Host` (or `Forwarded`, with
|
|
185
|
+
`header: 'forwarded'`), each checked, and the request's own for any it did
|
|
186
|
+
not. A direct client's forwarded headers are ignored, or, with
|
|
187
|
+
`untrusted: 'refuse'`, answered 403 before the pages. `ctx.url` and what
|
|
188
|
+
alxia's middlewares read stay the request as it reached the server. With a
|
|
189
|
+
server of your own, give `alxia({ proxy })` the same option. See
|
|
190
|
+
[Behind a proxy](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/serving.md#behind-a-proxy-proxy)
|
|
191
|
+
for what is believed and what is valid.
|
|
192
|
+
|
|
193
|
+
## Escape hatches
|
|
194
|
+
|
|
195
|
+
### Another server file
|
|
196
|
+
|
|
197
|
+
`alxia({ entry })` names the server file, relative to Vite's root, in
|
|
198
|
+
place of `app/server.ts`:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// vite.config.ts
|
|
202
|
+
import { alxia } from '@alxia/react-router/vite';
|
|
203
|
+
import { reactRouter } from '@react-router/dev/vite';
|
|
204
|
+
import { defineConfig } from 'vite';
|
|
205
|
+
|
|
206
|
+
export default defineConfig({
|
|
207
|
+
plugins: [reactRouter(), alxia({ entry: 'server/main.ts' })],
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
A missing `entry` is an error, where a missing `app/server.ts` falls back
|
|
212
|
+
to the default server.
|
|
213
|
+
|
|
214
|
+
### Overriding the wiring
|
|
215
|
+
|
|
216
|
+
`build`, `mode` and `client` override what the plugin passes:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
// app/server.ts: the client's files with headers of your own
|
|
220
|
+
import { createServer } from '@alxia/react-router';
|
|
221
|
+
|
|
222
|
+
export default createServer({
|
|
223
|
+
client: false,
|
|
224
|
+
beforeAll: (app) =>
|
|
225
|
+
app.static('/assets', 'build/client/assets', {
|
|
226
|
+
cacheControl: 'public, max-age=31536000, immutable',
|
|
227
|
+
precompressed: ['br'],
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- **`client: false`** serves none of `build/client`: declare the files
|
|
233
|
+
yourself, in `beforeAll` or `configure`. A relative path there is
|
|
234
|
+
resolved against the process's working directory, the project's when it
|
|
235
|
+
runs `bun run start`.
|
|
236
|
+
- **`client`**, a path or a `file:` URL, serves another folder in its
|
|
237
|
+
place.
|
|
238
|
+
- **`mode`** forces React Router's server mode. `development` makes React
|
|
239
|
+
Router send its errors' stacks, and asks `build` for the build on every
|
|
240
|
+
request.
|
|
241
|
+
- **`build`**, a `ServerBuild` or a function returning one, replaces React
|
|
242
|
+
Router's build: rarely wanted.
|
|
243
|
+
|
|
244
|
+
### A server of your own, without the plugin
|
|
245
|
+
|
|
246
|
+
`reactRouter(app, options)` is the catch-all itself, for a server that
|
|
247
|
+
imports the build. Without the plugin, the dev server is React Router's
|
|
248
|
+
own, and the loaders get no alxia context in dev.
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
// server.ts, beside build/
|
|
252
|
+
import { alxia } from '@alxia/core';
|
|
253
|
+
import { logger } from '@alxia/logger';
|
|
254
|
+
import { reactRouter } from '@alxia/react-router';
|
|
255
|
+
import type { ServerBuild } from 'react-router';
|
|
256
|
+
|
|
257
|
+
export const base = alxia()
|
|
258
|
+
.use(logger())
|
|
259
|
+
.get('/api/health', ({ reply }) => reply.ok({ ok: true }));
|
|
260
|
+
|
|
261
|
+
/** What the loaders read: alxiaOf<Base>(context). */
|
|
262
|
+
export type Base = typeof base;
|
|
263
|
+
|
|
264
|
+
const app = base.plugin((app) =>
|
|
265
|
+
reactRouter(app, {
|
|
266
|
+
build: () =>
|
|
267
|
+
import(new URL('./build/server/index.js', import.meta.url).href) as Promise<ServerBuild>,
|
|
268
|
+
client: new URL('./build/client', import.meta.url),
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
app.listen({ port: Number(process.env['PORT'] ?? 3000) });
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
```sh
|
|
276
|
+
bun run build && bun server.ts
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
| option | |
|
|
280
|
+
| --- | --- |
|
|
281
|
+
| `build` | a `ServerBuild`, loaded at startup; or a function returning one, called on the first request in `production`, again after a failed load, and on **every** request in `development` |
|
|
282
|
+
| `mode` | React Router's server mode, `production` by default |
|
|
283
|
+
| `getLoadContext(ctx, context)` | as `createServer`'s |
|
|
284
|
+
| `client` | the client build's folder, a path or a `file:` URL, served before the catch-all in `production` |
|
|
285
|
+
|
|
286
|
+
`reactRouter()` goes through `app.plugin`, as `@alxia/graphql`'s `graphql(app, …)`
|
|
287
|
+
does: that is how it knows the app's context type. A `HEAD` is handed to
|
|
288
|
+
React Router as a `GET`, since React Router answers a `HEAD` of its own
|
|
289
|
+
with no headers at all; the core then drops the body. In a monorepo where
|
|
290
|
+
this package is linked rather than installed, add
|
|
291
|
+
`ssr: { external: ['@alxia/react-router'] }` to `vite.config.ts`: see
|
|
292
|
+
[the troubleshooting entry](../troubleshooting.md#alxiaof-this-request-has-no-alxia-context-).
|