@alxia/react-router 0.1.1 → 0.2.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 +52 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +5 -4
- package/dist/nonce.d.ts +16 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/server.d.ts +4 -1
- package/dist/server.d.ts.map +1 -1
- package/docs/guide.md +102 -2
- package/docs/roadmap.md +10 -3
- package/docs/troubleshooting.md +67 -3
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -151,6 +151,53 @@ How `alxiaOf(context)` is typed:
|
|
|
151
151
|
|
|
152
152
|
[More](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide.md#typing-the-loaders)
|
|
153
153
|
|
|
154
|
+
## A CSP nonce
|
|
155
|
+
|
|
156
|
+
With `secureHeaders({ nonce: true })` from `@alxia/secure-headers` in
|
|
157
|
+
`configure`, each request's policy names a fresh nonce, and the context
|
|
158
|
+
carries it. `nonceOf(loadContext)` reads it in `entry.server.tsx`, and React
|
|
159
|
+
Router puts it on every script it renders, so `script-src` needs no
|
|
160
|
+
`'unsafe-inline'`:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// app/server.ts
|
|
164
|
+
configure: (app) =>
|
|
165
|
+
app.use(
|
|
166
|
+
secureHeaders({
|
|
167
|
+
nonce: true,
|
|
168
|
+
contentSecurityPolicy:
|
|
169
|
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'",
|
|
170
|
+
}),
|
|
171
|
+
),
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The template has no `entry.server.tsx`; reveal React Router's, then add
|
|
175
|
+
three lines:
|
|
176
|
+
|
|
177
|
+
```sh
|
|
178
|
+
bunx react-router reveal entry.server
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
```diff
|
|
182
|
+
// app/entry.server.tsx, as reveal writes it
|
|
183
|
+
import { PassThrough } from "node:stream";
|
|
184
|
+
|
|
185
|
+
+import { nonceOf } from "@alxia/react-router";
|
|
186
|
+
import type { EntryContext, RouterContextProvider } from "react-router";
|
|
187
|
+
…
|
|
188
|
+
const { pipe, abort } = renderToPipeableStream(
|
|
189
|
+
- <ServerRouter context={routerContext} url={request.url} />,
|
|
190
|
+
+ <ServerRouter context={routerContext} url={request.url} nonce={nonceOf(loadContext)} />,
|
|
191
|
+
{
|
|
192
|
+
+ nonce: nonceOf(loadContext),
|
|
193
|
+
[readyOption]() {
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`nonceOf` returns `undefined` when no hook set a nonce, so the entry works
|
|
197
|
+
with or without secure-headers: neither package depends on the other. Any
|
|
198
|
+
`derive` that returns a string `nonce` works the same.
|
|
199
|
+
[More](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide.md#a-csp-nonce)
|
|
200
|
+
|
|
154
201
|
## WebSockets
|
|
155
202
|
|
|
156
203
|
A `ws` route in `configure` connects under `react-router dev`, under
|
|
@@ -241,7 +288,8 @@ files, so `@alxia/openapi` can leave them out.
|
|
|
241
288
|
- **An index route's action is `POST /?index`**; `POST /` gets React
|
|
242
289
|
Router's 405. [More](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#you-made-a-post-request-to--but-did-not-provide-an-action-for-route-root-so-there-is-no-way-to-handle-the-request)
|
|
243
290
|
- **`@alxia/secure-headers`' default policy blocks the page's scripts**
|
|
244
|
-
and forms: give the pages a policy of their own
|
|
291
|
+
and forms: give the pages a policy of their own, with `nonce: true` and
|
|
292
|
+
`nonceOf` in `entry.server.tsx` rather than `'unsafe-inline'`. [More](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-default-src-none)
|
|
245
293
|
- **Under `react-router dev`, `page()` and an HTTP request's
|
|
246
294
|
`ctx.server` are absent**: requests arrive through `app.fetch`. A
|
|
247
295
|
socket's upgrade has its server. [More](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#ctxserver-is-undefined-under-react-router-dev)
|
|
@@ -265,6 +313,7 @@ files, so `@alxia/openapi` can leave them out.
|
|
|
265
313
|
| `InvalidRegister` | what a `Register` naming neither a server nor an app reads as: every key of the app's own a compile error |
|
|
266
314
|
| `AppOf<Server>` | the app a server makes |
|
|
267
315
|
| `alxiaContext` | the React Router context key `alxiaOf` reads, set on every request |
|
|
316
|
+
| `nonceOf(context)` | the request's CSP nonce, for `entry.server.tsx`: the context's `nonce` when a hook set one, such as `secureHeaders({ nonce: true })`, else `undefined` |
|
|
268
317
|
| `reactRouter(app, options)` | the catch-all and the client's files, for a server of your own. `build`, `mode`, `getLoadContext`, `client` |
|
|
269
318
|
| `ReactRouterOptions<Ctx>` | its options |
|
|
270
319
|
| `isReactRouterRoute(route)` | whether this package declared a route, for OpenAPI's `exclude` |
|
|
@@ -284,7 +333,7 @@ The `alxia-react-router` bin, run with `bunx`:
|
|
|
284
333
|
|
|
285
334
|
## Documentation
|
|
286
335
|
|
|
287
|
-
- [Guide](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide.md): the setup, how dev, the build and `vite preview` work, customising the server, typing the loaders, the app's own keys, escape hatches, WebSockets, the client's files, OpenAPI, testing and deploying.
|
|
336
|
+
- [Guide](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide.md): the setup, how dev, the build and `vite preview` work, customising the server, typing the loaders, the app's own keys, a CSP nonce, escape hatches, WebSockets, the client's files, OpenAPI, testing and deploying.
|
|
288
337
|
- [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md): each message, and the traps that print none.
|
|
289
338
|
- [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/roadmap.md): what is coming, and what is not planned.
|
|
290
|
-
- [Example](https://github.com/softistx/alxia/tree/develop/examples/react-router): the official template, these three lines, then an `app/server.ts` with a session, an `/api`, secure headers and a streamed page.
|
|
339
|
+
- [Example](https://github.com/softistx/alxia/tree/develop/examples/react-router): the official template, these three lines, then an `app/server.ts` with a session, an `/api`, secure headers with a nonce and a streamed page.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { type AppOf, alxiaContext, alxiaOf, type InvalidRegister, type Register, type RegisteredApp, type RegisteredOf, } from './context';
|
|
2
|
+
export { nonceOf } from './nonce';
|
|
2
3
|
export { isReactRouterRoute, type ReactRouterOptions, reactRouter, } from './react-router';
|
|
3
4
|
export { createServer, type FreshApp, type ReactRouterServer, type ServerOptions, type ServerWiring, } from './server';
|
|
4
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,KAAK,EACV,YAAY,EACZ,OAAO,EACP,KAAK,eAAe,EACpB,KAAK,QAAQ,EACb,KAAK,aAAa,EAClB,KAAK,YAAY,GACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,kBAAkB,EAClB,KAAK,kBAAkB,EACvB,WAAW,GACX,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACN,YAAY,EACZ,KAAK,QAAQ,EACb,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAClB,KAAK,YAAY,GACjB,MAAM,UAAU,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,KAAK,EACV,YAAY,EACZ,OAAO,EACP,KAAK,eAAe,EACpB,KAAK,QAAQ,EACb,KAAK,aAAa,EAClB,KAAK,YAAY,GACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EACN,kBAAkB,EAClB,KAAK,kBAAkB,EACvB,WAAW,GACX,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACN,YAAY,EACZ,KAAK,QAAQ,EACb,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAClB,KAAK,YAAY,GACjB,MAAM,UAAU,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,14 @@ function alxiaOf(context) {
|
|
|
9
9
|
}
|
|
10
10
|
return value;
|
|
11
11
|
}
|
|
12
|
+
// src/nonce.ts
|
|
13
|
+
function nonceOf(context) {
|
|
14
|
+
const value = context.get(alxiaContext);
|
|
15
|
+
if (typeof value !== "object" || value === null || !("nonce" in value)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
return typeof value.nonce === "string" ? value.nonce : undefined;
|
|
19
|
+
}
|
|
12
20
|
// src/react-router.ts
|
|
13
21
|
import { fileURLToPath } from "node:url";
|
|
14
22
|
import {
|
|
@@ -138,7 +146,6 @@ function createServer(options = {}) {
|
|
|
138
146
|
hostname: process.env["HOST"] || "0.0.0.0",
|
|
139
147
|
...options.listen
|
|
140
148
|
});
|
|
141
|
-
(options.onListen ?? announce)(server);
|
|
142
149
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
143
150
|
process.once(signal, () => {
|
|
144
151
|
app.stop().then(() => process.exit(0), (error) => {
|
|
@@ -147,6 +154,7 @@ function createServer(options = {}) {
|
|
|
147
154
|
});
|
|
148
155
|
});
|
|
149
156
|
}
|
|
157
|
+
(options.onListen ?? announce)(server);
|
|
150
158
|
return server;
|
|
151
159
|
}
|
|
152
160
|
};
|
|
@@ -159,8 +167,9 @@ export {
|
|
|
159
167
|
alxiaOf,
|
|
160
168
|
createServer,
|
|
161
169
|
isReactRouterRoute,
|
|
170
|
+
nonceOf,
|
|
162
171
|
reactRouter
|
|
163
172
|
};
|
|
164
173
|
|
|
165
|
-
//# debugId=
|
|
174
|
+
//# debugId=63BB3B2311BA25F964756E2164756E21
|
|
166
175
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/context.ts", "../src/react-router.ts", "../src/assets.ts", "../src/server.ts"],
|
|
3
|
+
"sources": ["../src/context.ts", "../src/nonce.ts", "../src/react-router.ts", "../src/assets.ts", "../src/server.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
5
|
"/**\n * The key under which `reactRouter()` hands every loader, action and\n * middleware the request's alxia context, and the typed way to read it.\n */\nimport type { Alxia, AnyAlxia, ContextOf, Empty } from '@alxia/core';\nimport { createContext, type RouterContextProvider } from 'react-router';\nimport type { FreshApp, ReactRouterServer } from './server';\n\n/** The default of the key: no catch-all set it. */\nconst MISSING: unique symbol = Symbol('alxia context missing');\n\n/**\n * The key `reactRouter()` sets on React Router's context provider, on every\n * request, to what alxia's hooks built for it. It lives in this package, so\n * it is one object whichever way the server was built or loaded: a key made\n * in the app's own `app/` folder is copied into React Router's build, and\n * the server that imports it separately sets a different one.\n */\nexport const alxiaContext = createContext<unknown>(MISSING);\n\n/**\n * Names the server whose context `alxiaOf(context)` reads when given no\n * type argument. One React Router build has one server, so it may be\n * declared once, beside it:\n *\n * ```ts\n * // app/server.ts\n * const server = createServer({ configure: (app) => app.use(session) });\n * export default server;\n *\n * declare module '@alxia/react-router' {\n * interface Register {\n * server: typeof server;\n * }\n * }\n * ```\n *\n * Unregistered, `alxiaOf(context)` reads `BaseContext`.\n */\n// biome-ignore lint/suspicious/noEmptyInterface: an app augments it\nexport interface Register {}\n\n/** The app a server makes, or the app itself. */\nexport type AppOf<Server> =\n\tServer extends ReactRouterServer<infer App> ? App : Server;\n\n/**\n * What `Register` names when it is neither a server nor an app — the\n * module, say, rather than its default export: an app whose context has\n * nothing but this key, so that reading anything of it is a compile error.\n */\nexport type InvalidRegister = Alxia<\n\t{\n\t\treadonly 'Register.server must be typeof server, the default export of createServer()': never;\n\t},\n\tEmpty,\n\t'',\n\tnever\n>;\n\n/** The app `alxiaOf` reads for a `Register` interface: its server's, a fresh one, or `InvalidRegister`. */\nexport type RegisteredOf<R> = R extends { readonly server: infer Server }\n\t? Server extends AnyAlxia | ReactRouterServer<AnyAlxia>\n\t\t? AppOf<Server>\n\t\t: InvalidRegister\n\t: FreshApp;\n\n/** What `alxiaOf` reads with no type argument: the registered server's app, or a fresh one. */\nexport type RegisteredApp = RegisteredOf<Register>;\n\n/**\n * What alxia's hooks built for this request, read in a loader, an action or\n * a middleware, typed by the app: the server `Register` names, or the one\n * given as the type argument — `typeof server`, or an app *before* the\n * catch-all. With neither, `BaseContext`.\n *\n * ```ts\n * export async function loader({ context }: Route.LoaderArgs) {\n * const { user, log } = alxiaOf(context);\n * }\n * ```\n *\n * Throws when the request did not come through `reactRouter()`: under\n * `react-router dev` without the plugin, or a test that calls a loader\n * directly.\n */\nexport function alxiaOf<\n\tApp extends AnyAlxia | ReactRouterServer<AnyAlxia> = RegisteredApp,\n>(context: Readonly<RouterContextProvider>): ContextOf<AppOf<App>> {\n\tconst value = context.get(alxiaContext);\n\tif (value === MISSING) {\n\t\tthrow new Error(\n\t\t\t\"alxiaOf(): this request has no alxia context. Serve the React Router app through alxia: add alxia() from @alxia/react-router/vite to vite.config.ts's plugins, or, with a server of your own, serve the build through reactRouter() from @alxia/react-router.\",\n\t\t);\n\t}\n\treturn value as ContextOf<AppOf<App>>;\n}\n",
|
|
6
|
+
"/** `nonceOf`: the request's CSP nonce, read from alxia's context if a hook set one. */\nimport type { RouterContextProvider } from 'react-router';\nimport { alxiaContext } from './context';\n\n/**\n * This request's CSP nonce, for `entry.server.tsx`: the `nonce` alxia's\n * hooks put on the context — `secureHeaders({ nonce: true })` from\n * `@alxia/secure-headers`, or a `derive` of your own — or `undefined` when\n * none did, or when the request did not come through alxia. It reads the\n * key if present: neither package depends on the other.\n *\n * ```tsx\n * const nonce = nonceOf(loadContext);\n * <ServerRouter context={routerContext} url={request.url} nonce={nonce} />\n * ```\n */\nexport function nonceOf(\n\tcontext: Readonly<RouterContextProvider>,\n): string | undefined {\n\tconst value = context.get(alxiaContext);\n\tif (typeof value !== 'object' || value === null || !('nonce' in value)) {\n\t\treturn undefined;\n\t}\n\treturn typeof value.nonce === 'string' ? value.nonce : undefined;\n}\n",
|
|
6
7
|
"import { fileURLToPath } from 'node:url';\nimport type {\n\tAlxia,\n\tAnyAlxia,\n\tAnyReply,\n\tBaseContext,\n\tMaybePromise,\n\tRouteDefinition,\n\tStatusCode,\n} from '@alxia/core';\nimport {\n\tcreateRequestHandler,\n\ttype RequestHandler,\n\tRouterContextProvider,\n\ttype ServerBuild,\n} from 'react-router';\nimport { serveClient } from './assets';\nimport { alxiaContext } from './context';\n\nexport interface ReactRouterOptions<Ctx> {\n\t/**\n\t * React Router's server build: the module itself, or a function that\n\t * returns it — `() => import('virtual:react-router/server-build')` in a\n\t * server entry Vite builds, `() => import('./build/server/index.js')`\n\t * beside a build. A function is called on every request in\n\t * `development`, so an edit is picked up, and once in `production`.\n\t */\n\treadonly build: ServerBuild | (() => MaybePromise<ServerBuild>);\n\t/** React Router's server mode: `production` by default. */\n\treadonly mode?: 'development' | 'production';\n\t/**\n\t * Sets the app's own keys on React Router's context provider, from the\n\t * context alxia's hooks built. `ctx` is typed by the app at the point of\n\t * `use`: reading what no hook before it derives is a compile error.\n\t * `alxiaContext` is always set, whether or not this is given.\n\t */\n\treadonly getLoadContext?: (\n\t\tctx: BaseContext & Ctx,\n\t\tcontext: RouterContextProvider,\n\t) => MaybePromise<void>;\n\t/**\n\t * The client build's folder, `build/client`: a path or a `file:` URL.\n\t * Its `assets/` is served immutable, and every other top-level file or\n\t * folder — the copies of `public/` — with an hour's cache, before the\n\t * catch-all. Ignored in `development`, where Vite serves them.\n\t */\n\treadonly client?: string | URL;\n}\n\n/** The methods the catch-all takes. A `HEAD` reaches its `GET`. */\nconst METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const;\n\n/** The handlers `reactRouter()` declared: the catch-all's and the client build's. */\nconst declared = new WeakSet<RouteDefinition['handler']>();\n\n/**\n * Whether `reactRouter()` declared this route: the catch-all, or one of the\n * client build's files. For `@alxia/openapi`'s `exclude`, so the document\n * lists the app's API and not its pages.\n *\n * ```ts\n * app.use(docs(app, { info, exclude: isReactRouterRoute }));\n * ```\n */\nexport function isReactRouterRoute(route: RouteDefinition): boolean {\n\treturn declared.has(route.handler);\n}\n\n/**\n * A React Router framework app, server rendered on `app`: `GET`, `POST`,\n * `PUT`, `PATCH` and `DELETE` at `/*`, behind every hook declared on `app`\n * before it. Each loader, action and middleware reads what those hooks\n * built through `alxiaOf<App>(context)`. The app's own routes — an `/api`\n * — answer their paths, declared before it or after; the client build's\n * files are served when `client` is given. The catch-all adds nothing to\n * the app's route table: pages are not something the typed client calls.\n *\n * ```ts\n * const app = base.use((app) =>\n * reactRouter(app, { build: () => import('./build/server/index.js'), client: 'build/client' }),\n * );\n * ```\n */\nexport function reactRouter<\n\tCtx extends object,\n\tRoutes extends object,\n\tPrefix extends string,\n\tShortcuts extends AnyReply,\n>(\n\tapp: Alxia<Ctx, Routes, Prefix, Shortcuts>,\n\toptions: ReactRouterOptions<Ctx>,\n): Alxia<Ctx, Routes, Prefix, Shortcuts> {\n\tconst mode = options.mode ?? 'production';\n\tconst handle = requestHandler(options.build, mode);\n\tconst getLoadContext = options.getLoadContext;\n\tconst handler = async (ctx: BaseContext & Ctx) => {\n\t\tconst context = new RouterContextProvider();\n\t\tcontext.set(alxiaContext, ctx);\n\t\tawait getLoadContext?.(ctx, context);\n\t\t// React Router answers a HEAD with no headers at all: hand it the GET,\n\t\t// and the core drops the body.\n\t\tconst request =\n\t\t\tctx.request.method === 'HEAD'\n\t\t\t\t? new Request(ctx.request, { method: 'GET' })\n\t\t\t\t: ctx.request;\n\t\tconst response = await handle(request, context);\n\t\treturn ctx.reply(\n\t\t\tresponse.status as StatusCode,\n\t\t\tresponse.body ?? undefined,\n\t\t\t{ headers: response.headers },\n\t\t);\n\t};\n\n\tconst routes = asRoutes(app);\n\tdeclaring(app, () => {\n\t\tif (options.client !== undefined && mode === 'production') {\n\t\t\tserveClient(routes, pathOf(options.client));\n\t\t}\n\t\tfor (const method of METHODS) routes[method]('/*', handler);\n\t});\n\treturn app;\n}\n\n/**\n * Declares the client build's files on `app`, marked for\n * `isReactRouterRoute`: what `reactRouter()` does with `client`, apart, so\n * that `createServer()` serves them before the hooks of `configure`.\n */\nexport function declareClient(app: AnyAlxia, client: string | URL): void {\n\tdeclaring(app, () => serveClient(asRoutes(app), pathOf(client)));\n}\n\n/** Runs `declare`, and marks every route it added to `app` as ours. */\nfunction declaring(app: AnyAlxia, declare: () => void): void {\n\tconst before = new Set(app.routes);\n\tdeclare();\n\tfor (const route of app.routes) {\n\t\tif (!before.has(route)) declared.add(route.handler);\n\t}\n}\n\n/** The app's route methods, untyped: the catch-all's handler is not one a route type admits. */\nfunction asRoutes(app: AnyAlxia) {\n\treturn app as unknown as {\n\t\tstatic(path: string, source: string, options: object): unknown;\n\t\tfile(path: string, file: string, options: object): unknown;\n\t} & Record<\n\t\t(typeof METHODS)[number],\n\t\t(path: string, handler: unknown) => unknown\n\t>;\n}\n\n/** A handler that resolves a function build once in production, on every request in development. */\nfunction requestHandler(\n\tbuild: ReactRouterOptions<never>['build'],\n\tmode: 'development' | 'production',\n): RequestHandler {\n\tif (typeof build !== 'function' || mode === 'development') {\n\t\treturn createRequestHandler(build, mode);\n\t}\n\tlet resolved: Promise<RequestHandler> | undefined;\n\treturn async (request, context) => {\n\t\tresolved ??= Promise.resolve(build()).then(\n\t\t\t(server) => createRequestHandler(server, mode),\n\t\t\t(error: unknown) => {\n\t\t\t\tresolved = undefined;\n\t\t\t\tthrow error;\n\t\t\t},\n\t\t);\n\t\treturn (await resolved)(request, context);\n\t};\n}\n\nfunction pathOf(client: string | URL): string {\n\treturn client instanceof URL ? fileURLToPath(client) : client;\n}\n",
|
|
7
8
|
"/**\n * The client build's files, served before the catch-all: the hashed\n * `assets/` immutable, every other top-level file or folder — what React\n * Router copied from `public/` — with an hour's cache.\n */\nimport { readdirSync, statSync } from 'node:fs';\nimport { join } from 'node:path';\n\n/** A hashed asset never changes under its name. */\nexport const IMMUTABLE = 'public, max-age=31536000, immutable';\n/** A public file keeps its name across builds: revalidated after an hour. */\nexport const AN_HOUR = 'public, max-age=3600';\n\ninterface Serving {\n\tstatic(path: string, source: string, options: object): unknown;\n\tfile(path: string, file: string, options: object): unknown;\n}\n\n/** Declares the client build's routes on `app`. */\nexport function serveClient(app: Serving, client: string): void {\n\tif (!isDirectory(client)) {\n\t\tthrow new TypeError(\n\t\t\t`reactRouter(): client is ${client}, which is not a directory. Pass the client build, build/client by default.`,\n\t\t);\n\t}\n\tfor (const name of readdirSync(client).sort()) {\n\t\tif (name.startsWith('.')) continue;\n\t\tconst path = join(client, name);\n\t\tconst cacheControl = name === 'assets' ? IMMUTABLE : AN_HOUR;\n\t\t// Declared as a request's URL carries the name: `my file.pdf` is\n\t\t// `/my%20file.pdf`, `café.png` is `/caf%C3%A9.png`.\n\t\tconst route = new URL(`/${name}`, 'http://localhost').pathname;\n\t\ttry {\n\t\t\tif (isDirectory(path)) app.static(route, path, { cacheControl });\n\t\t\telse app.file(route, path, { cacheControl });\n\t\t} catch (error) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`reactRouter(): ${path} cannot be served at a path of its own name; rename it. ${(error as Error).message}`,\n\t\t\t);\n\t\t}\n\t}\n}\n\nfunction isDirectory(path: string): boolean {\n\ttry {\n\t\treturn statSync(path).isDirectory();\n\t} catch {\n\t\treturn false;\n\t}\n}\n",
|
|
8
|
-
"/**\n * `createServer()`: the alxia server of a React Router app, as\n * `@alxia/react-router/vite` builds it. The plugin hands it React Router's\n * build, the mode and the client folder; the app's own `app/server.ts`\n * customises the rest, or is not written at all.\n */\nimport {\n\ttype Alxia,\n\ttype AnyAlxia,\n\talxia,\n\ttype ContextOf,\n\ttype Empty,\n\ttype ListenOptions,\n\ttype MaybePromise,\n} from '@alxia/core';\nimport type { RouterContextProvider, ServerBuild } from 'react-router';\nimport { declareClient, reactRouter } from './react-router';\n\n/** An app as `alxia()` makes it: what `beforeAll`, or `configure`, receives. */\nexport type FreshApp = Alxia<Empty, Empty, '', never>;\n\n/**\n * What the Vite plugin hands the server, and a test passes to `create`:\n * React Router's server build, its mode, and the client build's folder.\n */\nexport interface ServerWiring {\n\t/**\n\t * React Router's server build, or a function that returns it: called on\n\t * every request in `development`, once in `production`.\n\t */\n\treadonly build: ServerBuild | (() => MaybePromise<ServerBuild>);\n\t/** `development` under `react-router dev`, `production` in a build, and by default. */\n\treadonly mode?: 'development' | 'production';\n\t/** The client build's folder, a path or a `file:` URL: served in `production`. */\n\treadonly client?: string | URL | undefined;\n}\n\nexport interface ServerOptions<Before extends AnyAlxia, App extends AnyAlxia> {\n\t/**\n\t * Runs first, on a new app: what is declared here runs before the\n\t * client's files too — a guard, a rate limit, a logger that should see\n\t * every request. Returns the app, so its types flow to `configure`.\n\t */\n\treadonly beforeAll?: (app: FreshApp) => Before;\n\t/**\n\t * The app the pages run behind: its plugins, its hooks, its `/api`.\n\t * Declared after the client's files and before the catch-all. Returns\n\t * the app: what it builds is what the loaders read through `alxiaOf`.\n\t */\n\treadonly configure?: (app: Before) => App;\n\t/**\n\t * Sets the app's own keys on React Router's context provider, `ctx`\n\t * typed by `configure`'s app. `alxiaContext` is always set.\n\t */\n\treadonly getLoadContext?: (\n\t\tctx: ContextOf<App>,\n\t\tcontext: RouterContextProvider,\n\t) => MaybePromise<void>;\n\t/** Overrides the plugin's server build: rarely wanted. */\n\treadonly build?: ServerWiring['build'];\n\t/** Overrides the plugin's mode, `development` in dev and `production` in a build. */\n\treadonly mode?: 'development' | 'production';\n\t/**\n\t * Overrides the plugin's client folder, `build/client` beside the built\n\t * server. `false` serves none of it: declare the files yourself, in\n\t * `beforeAll` or `configure`.\n\t */\n\treadonly client?: string | URL | false;\n\t/**\n\t * `listen`'s options for `bun build/server/index.js`; a `port` or `hostname` here wins over `PORT` (3000)\n\t * and `HOST` (`0.0.0.0`) from the environment.\n\t */\n\treadonly listen?: ListenOptions;\n\t
|
|
9
|
+
"/**\n * `createServer()`: the alxia server of a React Router app, as\n * `@alxia/react-router/vite` builds it. The plugin hands it React Router's\n * build, the mode and the client folder; the app's own `app/server.ts`\n * customises the rest, or is not written at all.\n */\nimport {\n\ttype Alxia,\n\ttype AnyAlxia,\n\talxia,\n\ttype ContextOf,\n\ttype Empty,\n\ttype ListenOptions,\n\ttype MaybePromise,\n} from '@alxia/core';\nimport type { RouterContextProvider, ServerBuild } from 'react-router';\nimport { declareClient, reactRouter } from './react-router';\n\n/** An app as `alxia()` makes it: what `beforeAll`, or `configure`, receives. */\nexport type FreshApp = Alxia<Empty, Empty, '', never>;\n\n/**\n * What the Vite plugin hands the server, and a test passes to `create`:\n * React Router's server build, its mode, and the client build's folder.\n */\nexport interface ServerWiring {\n\t/**\n\t * React Router's server build, or a function that returns it: called on\n\t * every request in `development`, once in `production`.\n\t */\n\treadonly build: ServerBuild | (() => MaybePromise<ServerBuild>);\n\t/** `development` under `react-router dev`, `production` in a build, and by default. */\n\treadonly mode?: 'development' | 'production';\n\t/** The client build's folder, a path or a `file:` URL: served in `production`. */\n\treadonly client?: string | URL | undefined;\n}\n\nexport interface ServerOptions<Before extends AnyAlxia, App extends AnyAlxia> {\n\t/**\n\t * Runs first, on a new app: what is declared here runs before the\n\t * client's files too — a guard, a rate limit, a logger that should see\n\t * every request. Returns the app, so its types flow to `configure`.\n\t */\n\treadonly beforeAll?: (app: FreshApp) => Before;\n\t/**\n\t * The app the pages run behind: its plugins, its hooks, its `/api`.\n\t * Declared after the client's files and before the catch-all. Returns\n\t * the app: what it builds is what the loaders read through `alxiaOf`.\n\t */\n\treadonly configure?: (app: Before) => App;\n\t/**\n\t * Sets the app's own keys on React Router's context provider, `ctx`\n\t * typed by `configure`'s app. `alxiaContext` is always set.\n\t */\n\treadonly getLoadContext?: (\n\t\tctx: ContextOf<App>,\n\t\tcontext: RouterContextProvider,\n\t) => MaybePromise<void>;\n\t/** Overrides the plugin's server build: rarely wanted. */\n\treadonly build?: ServerWiring['build'];\n\t/** Overrides the plugin's mode, `development` in dev and `production` in a build. */\n\treadonly mode?: 'development' | 'production';\n\t/**\n\t * Overrides the plugin's client folder, `build/client` beside the built\n\t * server. `false` serves none of it: declare the files yourself, in\n\t * `beforeAll` or `configure`.\n\t */\n\treadonly client?: string | URL | false;\n\t/**\n\t * `listen`'s options for `bun build/server/index.js`; a `port` or `hostname` here wins over `PORT` (3000)\n\t * and `HOST` (`0.0.0.0`) from the environment.\n\t */\n\treadonly listen?: ListenOptions;\n\t/**\n\t * Called once the built server listens and its `SIGINT` and `SIGTERM` handlers are in place.\n\t * Prints `alxia listening on <url>` by default.\n\t */\n\treadonly onListen?: (server: Bun.Server<unknown>) => void;\n}\n\n/**\n * The server `createServer()` describes, made into an app by the Vite\n * plugin, by a test, or by a server file of your own.\n */\nexport interface ReactRouterServer<App extends AnyAlxia> {\n\t/**\n\t * The app, serving React Router's build: `build/server/index.js`'s\n\t * default export. A test drives it with `app.request`.\n\t *\n\t * ```ts\n\t * const app = server.create({ build: await import('./build/server/index.js') });\n\t * ```\n\t */\n\tcreate(wiring: ServerWiring): App;\n\t/**\n\t * Listens with `app`, on `listen`, `PORT` and `HOST`, and stops it on\n\t * `SIGINT` or `SIGTERM`: its `onStop` hooks run, and the process exits.\n\t * What `bun build/server/index.js` runs.\n\t */\n\tstart(app: App): Bun.Server<unknown>;\n}\n\n/**\n * The alxia server of a React Router app, for `app/server.ts`. Every option\n * is optional: the Vite plugin wires React Router's build, the mode and the\n * client folder, and without the file uses `createServer()` as it is.\n *\n * ```ts\n * const server = createServer({\n * configure: (app) => app.use(logger()).get('/api/health', ({ reply }) => reply.ok({ ok: true })),\n * });\n * export default server;\n * ```\n *\n * The request runs through `beforeAll`, the client's files, `configure`,\n * then the pages.\n */\nexport function createServer<\n\tBefore extends AnyAlxia = FreshApp,\n\tApp extends AnyAlxia = Before,\n>(options: ServerOptions<Before, App> = {}): ReactRouterServer<App> {\n\treturn {\n\t\tcreate(wiring) {\n\t\t\tconst mode = options.mode ?? wiring.mode ?? 'production';\n\t\t\tconst client =\n\t\t\t\toptions.client === false\n\t\t\t\t\t? undefined\n\t\t\t\t\t: (options.client ?? wiring.client);\n\t\t\tconst fresh = alxia();\n\t\t\t// Without beforeAll or configure, Before and App are their defaults,\n\t\t\t// the app passed through: a type argument given by hand is believed.\n\t\t\tconst before = (options.beforeAll?.(fresh) ?? fresh) as Before;\n\t\t\tif (client !== undefined && mode === 'production') {\n\t\t\t\tdeclareClient(before, client);\n\t\t\t}\n\t\t\tconst app = (options.configure?.(before) ?? before) as App;\n\t\t\tconst getLoadContext = options.getLoadContext;\n\t\t\treactRouter(app, {\n\t\t\t\tbuild: options.build ?? wiring.build,\n\t\t\t\tmode,\n\t\t\t\t...(getLoadContext === undefined\n\t\t\t\t\t? {}\n\t\t\t\t\t: {\n\t\t\t\t\t\t\tgetLoadContext: (ctx, context) =>\n\t\t\t\t\t\t\t\tgetLoadContext(ctx as ContextOf<App>, context),\n\t\t\t\t\t\t}),\n\t\t\t});\n\t\t\treturn app;\n\t\t},\n\t\tstart(app) {\n\t\t\tconst server = app.listen({\n\t\t\t\tport: Number(process.env['PORT'] || 3000),\n\t\t\t\thostname: process.env['HOST'] || '0.0.0.0',\n\t\t\t\t...options.listen,\n\t\t\t});\n\t\t\t// Stop as the platform asks: the app's onStop hooks run, and the process ends.\n\t\t\t// Installed before onListen: a supervisor may signal as soon as it reads\n\t\t\t// that the server listens, and a signal with no handler yet kills the process.\n\t\t\tfor (const signal of ['SIGINT', 'SIGTERM'] as const) {\n\t\t\t\tprocess.once(signal, () => {\n\t\t\t\t\tvoid app.stop().then(\n\t\t\t\t\t\t() => process.exit(0),\n\t\t\t\t\t\t(error: unknown) => {\n\t\t\t\t\t\t\tconsole.error(error);\n\t\t\t\t\t\t\tprocess.exit(1);\n\t\t\t\t\t\t},\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t\t}\n\t\t\t(options.onListen ?? announce)(server);\n\t\t\treturn server;\n\t\t},\n\t};\n}\n\nfunction announce(server: Bun.Server<unknown>): void {\n\tconsole.log(`alxia listening on ${server.url}`);\n}\n"
|
|
9
10
|
],
|
|
10
|
-
"mappings": ";AAKA;AAIA,IAAM,UAAyB,OAAO,uBAAuB;AAStD,IAAM,eAAe,cAAuB,OAAO;AAoEnD,SAAS,OAEf,CAAC,SAAiE;AAAA,EAClE,MAAM,QAAQ,QAAQ,IAAI,YAAY;AAAA,EACtC,IAAI,UAAU,SAAS;AAAA,IACtB,MAAM,IAAI,MACT,+PACD;AAAA,EACD;AAAA,EACA,OAAO;AAAA;;AC/
|
|
11
|
-
"debugId": "
|
|
11
|
+
"mappings": "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",
|
|
12
|
+
"debugId": "63BB3B2311BA25F964756E2164756E21",
|
|
12
13
|
"names": []
|
|
13
14
|
}
|
package/dist/nonce.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** `nonceOf`: the request's CSP nonce, read from alxia's context if a hook set one. */
|
|
2
|
+
import type { RouterContextProvider } from 'react-router';
|
|
3
|
+
/**
|
|
4
|
+
* This request's CSP nonce, for `entry.server.tsx`: the `nonce` alxia's
|
|
5
|
+
* hooks put on the context — `secureHeaders({ nonce: true })` from
|
|
6
|
+
* `@alxia/secure-headers`, or a `derive` of your own — or `undefined` when
|
|
7
|
+
* none did, or when the request did not come through alxia. It reads the
|
|
8
|
+
* key if present: neither package depends on the other.
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* const nonce = nonceOf(loadContext);
|
|
12
|
+
* <ServerRouter context={routerContext} url={request.url} nonce={nonce} />
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export declare function nonceOf(context: Readonly<RouterContextProvider>): string | undefined;
|
|
16
|
+
//# sourceMappingURL=nonce.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nonce.d.ts","sourceRoot":"","sources":["../src/nonce.ts"],"names":[],"mappings":"AAAA,uFAAuF;AACvF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAG1D;;;;;;;;;;;GAWG;AACH,wBAAgB,OAAO,CACtB,OAAO,EAAE,QAAQ,CAAC,qBAAqB,CAAC,GACtC,MAAM,GAAG,SAAS,CAMpB"}
|
package/dist/server.d.ts
CHANGED
|
@@ -56,7 +56,10 @@ export interface ServerOptions<Before extends AnyAlxia, App extends AnyAlxia> {
|
|
|
56
56
|
* and `HOST` (`0.0.0.0`) from the environment.
|
|
57
57
|
*/
|
|
58
58
|
readonly listen?: ListenOptions;
|
|
59
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Called once the built server listens and its `SIGINT` and `SIGTERM` handlers are in place.
|
|
61
|
+
* Prints `alxia listening on <url>` by default.
|
|
62
|
+
*/
|
|
60
63
|
readonly onListen?: (server: Bun.Server<unknown>) => void;
|
|
61
64
|
}
|
|
62
65
|
/**
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACN,KAAK,KAAK,EACV,KAAK,QAAQ,EAEb,KAAK,SAAS,EACd,KAAK,KAAK,EACV,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,qBAAqB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGvE,gFAAgF;AAChF,MAAM,MAAM,QAAQ,GAAG,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC;AAEtD;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC5B;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,CAAC,MAAM,YAAY,CAAC,WAAW,CAAC,CAAC,CAAC;IAChE,uFAAuF;IACvF,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,GAAG,YAAY,CAAC;IAC7C,kFAAkF;IAClF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,GAAG,GAAG,SAAS,CAAC;CAC3C;AAED,MAAM,WAAW,aAAa,CAAC,MAAM,SAAS,QAAQ,EAAE,GAAG,SAAS,QAAQ;IAC3E;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,QAAQ,KAAK,MAAM,CAAC;IAC/C;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,GAAG,CAAC;IAC1C;;;OAGG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,CACzB,GAAG,EAAE,SAAS,CAAC,GAAG,CAAC,EACnB,OAAO,EAAE,qBAAqB,KAC1B,YAAY,CAAC,IAAI,CAAC,CAAC;IACxB,0DAA0D;IAC1D,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC,OAAO,CAAC,CAAC;IACvC,qFAAqF;IACrF,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,GAAG,YAAY,CAAC;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,GAAG,GAAG,KAAK,CAAC;IACvC;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACN,KAAK,KAAK,EACV,KAAK,QAAQ,EAEb,KAAK,SAAS,EACd,KAAK,KAAK,EACV,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,qBAAqB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGvE,gFAAgF;AAChF,MAAM,MAAM,QAAQ,GAAG,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC;AAEtD;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC5B;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,CAAC,MAAM,YAAY,CAAC,WAAW,CAAC,CAAC,CAAC;IAChE,uFAAuF;IACvF,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,GAAG,YAAY,CAAC;IAC7C,kFAAkF;IAClF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,GAAG,GAAG,SAAS,CAAC;CAC3C;AAED,MAAM,WAAW,aAAa,CAAC,MAAM,SAAS,QAAQ,EAAE,GAAG,SAAS,QAAQ;IAC3E;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,QAAQ,KAAK,MAAM,CAAC;IAC/C;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,GAAG,CAAC;IAC1C;;;OAGG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,CACzB,GAAG,EAAE,SAAS,CAAC,GAAG,CAAC,EACnB,OAAO,EAAE,qBAAqB,KAC1B,YAAY,CAAC,IAAI,CAAC,CAAC;IACxB,0DAA0D;IAC1D,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC,OAAO,CAAC,CAAC;IACvC,qFAAqF;IACrF,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,GAAG,YAAY,CAAC;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,GAAG,GAAG,KAAK,CAAC;IACvC;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC;CAC1D;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB,CAAC,GAAG,SAAS,QAAQ;IACtD;;;;;;;OAOG;IACH,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,GAAG,CAAC;IAClC;;;;OAIG;IACH,KAAK,CAAC,GAAG,EAAE,GAAG,GAAG,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;CACrC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAC3B,MAAM,SAAS,QAAQ,GAAG,QAAQ,EAClC,GAAG,SAAS,QAAQ,GAAG,MAAM,EAC5B,OAAO,GAAE,aAAa,CAAC,MAAM,EAAE,GAAG,CAAM,GAAG,iBAAiB,CAAC,GAAG,CAAC,CAqDlE"}
|
package/docs/guide.md
CHANGED
|
@@ -5,7 +5,7 @@ server rendering from an alxia app, under Bun. This page walks an app
|
|
|
5
5
|
author through it: the setup, what happens in dev, in a build and under
|
|
6
6
|
`vite preview`,
|
|
7
7
|
customising the server, typing the loaders, the app's own context keys,
|
|
8
|
-
the escape hatches, WebSockets, the client's files, OpenAPI, testing and
|
|
8
|
+
a CSP nonce, the escape hatches, WebSockets, the client's files, OpenAPI, testing and
|
|
9
9
|
deploying.
|
|
10
10
|
|
|
11
11
|
- [Setup](#setup)
|
|
@@ -13,6 +13,7 @@ deploying.
|
|
|
13
13
|
- [Customising the server](#customising-the-server)
|
|
14
14
|
- [Typing the loaders](#typing-the-loaders)
|
|
15
15
|
- [The app's own context keys](#the-apps-own-context-keys)
|
|
16
|
+
- [A CSP nonce](#a-csp-nonce)
|
|
16
17
|
- [Escape hatches](#escape-hatches)
|
|
17
18
|
- [Hooks around the pages](#hooks-around-the-pages)
|
|
18
19
|
- [Routes beside the pages](#routes-beside-the-pages)
|
|
@@ -320,7 +321,7 @@ Every option is optional:
|
|
|
320
321
|
| `getLoadContext(ctx, context)` | sets the app's own keys on React Router's provider, `ctx` typed by `configure`'s app |
|
|
321
322
|
| `build`, `mode`, `client` | override what the plugin wires; see [Escape hatches](#escape-hatches) |
|
|
322
323
|
| `listen` | `listen`'s options for `bun build/server/index.js`: `port`, `hostname`, `idleTimeout`, `maxRequestBodySize`, `tls`. A `port` or `hostname` given here wins over `PORT` and `HOST` |
|
|
323
|
-
| `onListen(server)` | called once the built server listens, in place of the `alxia listening on …` line |
|
|
324
|
+
| `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 |
|
|
324
325
|
|
|
325
326
|
A request goes through four layers, in order:
|
|
326
327
|
|
|
@@ -503,6 +504,103 @@ A server of your own, without the plugin, holds another copy of every
|
|
|
503
504
|
key under `app/`: see
|
|
504
505
|
[the troubleshooting entry](troubleshooting.md#error-no-value-found-for-context).
|
|
505
506
|
|
|
507
|
+
## A CSP nonce
|
|
508
|
+
|
|
509
|
+
React Router renders inline scripts: the hydration data, the module
|
|
510
|
+
loader, the scroll restoration, and with streaming one more per resolved
|
|
511
|
+
`<Await>`. A policy without `'unsafe-inline'` allows them only when each
|
|
512
|
+
carries the nonce the policy names. React Router writes it on every one of
|
|
513
|
+
them, and on its `modulepreload` links, when `entry.server.tsx` passes it
|
|
514
|
+
to `<ServerRouter nonce>` and to React's renderer
|
|
515
|
+
([React Router's security guide](https://reactrouter.com/how-to/security)).
|
|
516
|
+
|
|
517
|
+
`@alxia/secure-headers` makes the nonce: `secureHeaders({ nonce: true })`
|
|
518
|
+
draws a fresh one per request, adds it to the policy's `script-src`, and
|
|
519
|
+
puts it on the context of the routes after it, the catch-all included.
|
|
520
|
+
`nonceOf(loadContext)` reads it in the entry.
|
|
521
|
+
|
|
522
|
+
### 1. The policy, in `app/server.ts`
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
import { createServer } from '@alxia/react-router';
|
|
526
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
527
|
+
|
|
528
|
+
export default createServer({
|
|
529
|
+
configure: (app) =>
|
|
530
|
+
app.use(
|
|
531
|
+
secureHeaders({
|
|
532
|
+
nonce: true,
|
|
533
|
+
contentSecurityPolicy: [
|
|
534
|
+
"default-src 'self'",
|
|
535
|
+
"script-src 'self'",
|
|
536
|
+
"style-src 'self' 'unsafe-inline'",
|
|
537
|
+
"img-src 'self' data:",
|
|
538
|
+
"connect-src 'self'",
|
|
539
|
+
"form-action 'self'",
|
|
540
|
+
"base-uri 'self'",
|
|
541
|
+
"frame-ancestors 'none'",
|
|
542
|
+
].join('; '),
|
|
543
|
+
}),
|
|
544
|
+
),
|
|
545
|
+
});
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
`script-src 'self'` goes out as `script-src 'self' 'nonce-…'`, a new
|
|
549
|
+
value on every response. `'self'` still allows the bundles under
|
|
550
|
+
`/assets`, and `connect-src 'self'` the single-fetch data requests.
|
|
551
|
+
|
|
552
|
+
### 2. The nonce, in `app/entry.server.tsx`
|
|
553
|
+
|
|
554
|
+
React Router's template has no entry; reveal the default one, then add an
|
|
555
|
+
import and the nonce in two places:
|
|
556
|
+
|
|
557
|
+
```sh
|
|
558
|
+
bunx react-router reveal entry.server
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
```diff
|
|
562
|
+
// app/entry.server.tsx, as reveal writes it
|
|
563
|
+
import { PassThrough } from "node:stream";
|
|
564
|
+
|
|
565
|
+
+import { nonceOf } from "@alxia/react-router";
|
|
566
|
+
import type { EntryContext, RouterContextProvider } from "react-router";
|
|
567
|
+
…
|
|
568
|
+
const { pipe, abort } = renderToPipeableStream(
|
|
569
|
+
- <ServerRouter context={routerContext} url={request.url} />,
|
|
570
|
+
+ <ServerRouter context={routerContext} url={request.url} nonce={nonceOf(loadContext)} />,
|
|
571
|
+
{
|
|
572
|
+
+ nonce: nonceOf(loadContext),
|
|
573
|
+
[readyOption]() {
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
`loadContext` is the entry's fifth parameter, the `RouterContextProvider`
|
|
577
|
+
alxia hands React Router; the revealed file already declares it. With
|
|
578
|
+
`renderToReadableStream`, the web entry, the option is the same: `{ nonce:
|
|
579
|
+
nonceOf(loadContext), … }`.
|
|
580
|
+
|
|
581
|
+
That is all: under `react-router dev` and from the build, every `<script>`
|
|
582
|
+
of a page carries the nonce of its own response's policy, Vite's dev
|
|
583
|
+
scripts included.
|
|
584
|
+
|
|
585
|
+
### How it stays loose
|
|
586
|
+
|
|
587
|
+
`nonceOf` reads `nonce` from alxia's context if it is a string, and
|
|
588
|
+
returns `undefined` otherwise: no hook set one, or the request did not come
|
|
589
|
+
through alxia at all. `ServerRouter` and React then render no `nonce`
|
|
590
|
+
attribute. So:
|
|
591
|
+
|
|
592
|
+
- this package does not depend on `@alxia/secure-headers`, nor the reverse;
|
|
593
|
+
- the same `entry.server.tsx` serves an app with or without the policy;
|
|
594
|
+
- a nonce of your own works too, from any `derive` in `configure` that
|
|
595
|
+
returns `{ nonce: string }`; the policy is then yours to write with it.
|
|
596
|
+
|
|
597
|
+
```ts
|
|
598
|
+
configure: (app) => app.derive(() => ({ nonce: myNonce() })),
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
Under `exactOptionalPropertyTypes`, spread it in only when there is one:
|
|
602
|
+
see [the troubleshooting entry](troubleshooting.md#type---nonce-string--undefined--is-not-assignable-to-type-serverrouterprops-with-exactoptionalpropertytypes-true).
|
|
603
|
+
|
|
506
604
|
## Escape hatches
|
|
507
605
|
|
|
508
606
|
### Another server file
|
|
@@ -619,6 +717,8 @@ any route:
|
|
|
619
717
|
every script of the page, React Router's inline ones included, and its
|
|
620
718
|
`form-action 'none'` blocks a `<Form>`'s post. Give the pages a policy of
|
|
621
719
|
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).
|
|
720
|
+
With `nonce: true`, the scripts need no `'unsafe-inline'`:
|
|
721
|
+
[A CSP nonce](#a-csp-nonce).
|
|
622
722
|
- **A guard** — `@alxia/jwt`'s `bearer`, `@alxia/janus`' session — in
|
|
623
723
|
`configure` guards every page and its data alike; in `beforeAll`, the
|
|
624
724
|
client's files too.
|
package/docs/roadmap.md
CHANGED
|
@@ -11,9 +11,7 @@ Nothing scheduled yet.
|
|
|
11
11
|
|
|
12
12
|
## Next
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
`<Scripts nonce>` sharing one nonce, so a page's policy needs no
|
|
16
|
-
`'unsafe-inline'`.
|
|
14
|
+
Nothing scheduled yet.
|
|
17
15
|
|
|
18
16
|
## Later
|
|
19
17
|
|
|
@@ -47,6 +45,15 @@ Nothing scheduled yet.
|
|
|
47
45
|
|
|
48
46
|
## Shipped
|
|
49
47
|
|
|
48
|
+
### Next release
|
|
49
|
+
|
|
50
|
+
- **A per-request CSP nonce.** `nonceOf(loadContext)` reads the nonce that
|
|
51
|
+
`@alxia/secure-headers`' `nonce: true`, or a `derive` of the app's own,
|
|
52
|
+
put on the context, so `entry.server.tsx` hands it to `<ServerRouter
|
|
53
|
+
nonce>` and React in three lines. Every script of the page carries the
|
|
54
|
+
nonce of its own response's policy, which needs no `'unsafe-inline'`.
|
|
55
|
+
Neither package depends on the other.
|
|
56
|
+
|
|
50
57
|
### 0.1.0
|
|
51
58
|
|
|
52
59
|
- **React Router as a catch-all.** `reactRouter(app, { build })` serves
|
package/docs/troubleshooting.md
CHANGED
|
@@ -41,10 +41,12 @@ a loader, a message React Router or the browser prints, or an error from
|
|
|
41
41
|
- [`Type '(app: …) => void' is not assignable to type '(app: …) => AnyAlxia'`](#type-app---void-is-not-assignable-to-type-app---anyalxia)
|
|
42
42
|
- [`Property '…' does not exist on type 'BaseContext & { readonly 'Register.server must be typeof server, the default export of createServer()': never; }'`](#property--does-not-exist-on-type-basecontext---readonly-registerserver-must-be-typeof-server-the-default-export-of-createserver-never-)
|
|
43
43
|
- [`Subsequent property declarations must have the same type. Property 'server' must be of type …`](#subsequent-property-declarations-must-have-the-same-type-property-server-must-be-of-type-)
|
|
44
|
+
- [`Type '{ …; nonce: string | undefined; }' is not assignable to type 'ServerRouterProps' with 'exactOptionalPropertyTypes: true'`](#type---nonce-string--undefined--is-not-assignable-to-type-serverrouterprops-with-exactoptionalpropertytypes-true)
|
|
44
45
|
|
|
45
46
|
**Traps**
|
|
46
47
|
|
|
47
48
|
- [`bun build/server/index.js` exits at once, printing nothing](#bun-buildserverindexjs-exits-at-once-printing-nothing)
|
|
49
|
+
- [A script of the page has no nonce](#a-script-of-the-page-has-no-nonce)
|
|
48
50
|
- [A loader reads `null` from the app's own key](#a-loader-reads-null-from-the-apps-own-key)
|
|
49
51
|
- [A page answers alxia's JSON 404 or 405 instead of rendering](#a-page-answers-alxias-json-404-or-405-instead-of-rendering)
|
|
50
52
|
- [A streamed page arrives in one piece](#a-streamed-page-arrives-in-one-piece)
|
|
@@ -419,9 +421,9 @@ export default createServer({
|
|
|
419
421
|
});
|
|
420
422
|
```
|
|
421
423
|
|
|
422
|
-
or `contentSecurityPolicy: false`.
|
|
423
|
-
|
|
424
|
-
[
|
|
424
|
+
or `contentSecurityPolicy: false`. Better than `'unsafe-inline'`: a nonce
|
|
425
|
+
per request, with `nonce: true` and `nonceOf(loadContext)` in
|
|
426
|
+
`entry.server.tsx` ([guide](guide.md#a-csp-nonce)).
|
|
425
427
|
|
|
426
428
|
### `alxia-react-router: … already exists, and reveal leaves it as it is. Run alxia-react-router reveal --force to overwrite it.`
|
|
427
429
|
|
|
@@ -714,6 +716,30 @@ whose catch-all every loader of the build runs behind.
|
|
|
714
716
|
a monorepo its own tsconfig, or drop `Register` and pass the type
|
|
715
717
|
argument, `alxiaOf<Server>(context)`.
|
|
716
718
|
|
|
719
|
+
### `Type '{ …; nonce: string | undefined; }' is not assignable to type 'ServerRouterProps' with 'exactOptionalPropertyTypes: true'`
|
|
720
|
+
|
|
721
|
+
**When:** `entry.server.tsx` passes `nonceOf(loadContext)` to
|
|
722
|
+
`<ServerRouter nonce>` under `exactOptionalPropertyTypes`.
|
|
723
|
+
|
|
724
|
+
```text
|
|
725
|
+
error TS2375: Type '{ context: EntryContext; url: string; nonce: string | undefined; }' is not assignable to type 'ServerRouterProps' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
**Why:** `nonceOf` returns `undefined` when no hook set a nonce, and
|
|
729
|
+
`ServerRouterProps.nonce` is optional without `undefined`. React's
|
|
730
|
+
`nonce` option takes `undefined`, so only the prop complains.
|
|
731
|
+
|
|
732
|
+
**Fix:** spread the prop in only when there is a nonce:
|
|
733
|
+
|
|
734
|
+
```tsx
|
|
735
|
+
const nonce = nonceOf(loadContext);
|
|
736
|
+
<ServerRouter
|
|
737
|
+
context={routerContext}
|
|
738
|
+
url={request.url}
|
|
739
|
+
{...(nonce === undefined ? {} : { nonce })}
|
|
740
|
+
/>
|
|
741
|
+
```
|
|
742
|
+
|
|
717
743
|
## Traps
|
|
718
744
|
|
|
719
745
|
### `bun build/server/index.js` exits at once, printing nothing
|
|
@@ -727,6 +753,44 @@ is the process's entry point.
|
|
|
727
753
|
`alxia listening on <url>` then comes up. Run the file itself,
|
|
728
754
|
`bun build/server/index.js`, not through another module's `import`.
|
|
729
755
|
|
|
756
|
+
### A script of the page has no nonce
|
|
757
|
+
|
|
758
|
+
The policy names `'nonce-…'`, and the browser refuses some or all of the
|
|
759
|
+
page's scripts: `Refused to execute inline script because it violates the
|
|
760
|
+
following Content Security Policy directive: "script-src 'self'
|
|
761
|
+
'nonce-…'"`. The page renders, then never hydrates.
|
|
762
|
+
|
|
763
|
+
**Why:** one of these:
|
|
764
|
+
|
|
765
|
+
- the app has no `entry.server.tsx`, so React Router's default renders
|
|
766
|
+
with no nonce;
|
|
767
|
+
- the entry does not pass it to both `<ServerRouter nonce>` and the
|
|
768
|
+
renderer's `nonce` option: the first covers React Router's scripts, the
|
|
769
|
+
second React's own streaming ones;
|
|
770
|
+
- a `<script>` of the app's own, in `root.tsx` say, has no `nonce`
|
|
771
|
+
attribute.
|
|
772
|
+
|
|
773
|
+
**Fix:** reveal the entry and pass `nonceOf(loadContext)` to both
|
|
774
|
+
([guide](guide.md#a-csp-nonce)). A script of your own reads the nonce
|
|
775
|
+
through a loader, where `nonceOf(context)` works as well:
|
|
776
|
+
|
|
777
|
+
```tsx
|
|
778
|
+
// app/root.tsx
|
|
779
|
+
import { nonceOf } from '@alxia/react-router';
|
|
780
|
+
import type { Route } from './+types/root';
|
|
781
|
+
|
|
782
|
+
export function loader({ context }: Route.LoaderArgs) {
|
|
783
|
+
return { nonce: nonceOf(context) };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// in Layout: <script nonce={useRouteLoaderData('root')?.nonce}>…</script>
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
Check what was sent with
|
|
790
|
+
`curl -s -A 'Mozilla/5.0 Chrome/130' http://localhost:5173/ | grep -o '<script[^>]*>'`:
|
|
791
|
+
every tag should hold `nonce="…"`, the value of the response's
|
|
792
|
+
`Content-Security-Policy`.
|
|
793
|
+
|
|
730
794
|
### A loader reads `null` from the app's own key
|
|
731
795
|
|
|
732
796
|
The same cause as [`Error: No value found for context`](#error-no-value-found-for-context),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/react-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
]
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@alxia/compress": "^0.1.
|
|
55
|
-
"@alxia/core": "^0.
|
|
56
|
-
"@alxia/openapi": "^0.
|
|
54
|
+
"@alxia/compress": "^0.1.2",
|
|
55
|
+
"@alxia/core": "^0.3.0",
|
|
56
|
+
"@alxia/openapi": "^0.3.0",
|
|
57
57
|
"@react-router/dev": "^8.4.0",
|
|
58
58
|
"@types/bun": "^1.4.2",
|
|
59
59
|
"@types/react": "^19.3.0",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"vite": "^7.0.0"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
|
-
"@alxia/core": "^0.
|
|
68
|
+
"@alxia/core": "^0.3.0",
|
|
69
69
|
"react-router": "^8.0.0",
|
|
70
70
|
"typescript": "^6.0.3 || ^7.0.0",
|
|
71
71
|
"vite": "^7.0.0 || ^8.0.0"
|