Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/csp-nonce-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@ngrok/app-server": patch
---

Add `cspNonce()` to `@ngrok/app-server/client`. It gives the CSP nonce of the request on the server, and reads it from the document in the browser, so the hydration matches. React Router's `<HydratedRouter>` takes no nonce, so a page with a CSP nonce got a hydration warning in development. Give `cspNonce()` to `<Links>`, `<ScrollRestoration>`, and `<Scripts>` in the root `Layout`:

```tsx
const nonce = cspNonce();
<Links nonce={nonce} />
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
```

In TanStack Start, use `createRouter({ routeTree, ssr: { nonce: cspNonce() } })`.
14 changes: 14 additions & 0 deletions .changeset/twin-of-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@ngrok/app-server": patch
---

`markdownTwins.twinOf` gets the request's Hono context as its second argument, the same as `rewrite` and `headers`. If `twinOf` gives `undefined`, the request gets no twin, and `rewrite` runs. Use it to keep a request off the prerendered twin, such as a request with a query that the framework renders:

```ts
prerendered({
markdownTwins: {
twinOf: (path, c) =>
new URL(c.req.url).search === "" ? `${path === "/" ? "/index" : path}.md` : undefined,
},
});
```
53 changes: 47 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ After the build and after the prerender pass, `appServer` writes a gzip copy (`.

The build exports `appServer`, and the CLI calls it. So the copy of this package in the build runs the server, and the CLI's copy runs nothing. The CLI refuses a build from a version of this package with another entry protocol.

A React Router app with no Vite plugin also works: `app-server serve build/server/index.js` reads a plain React Router build and runs it with the CLI's copy, the node host, and the default plugins. In that build, a route that calls `shutdownSignal()`, `eventStream()`, or `requestContext()` must import the CLI's copy, so do not bundle `@ngrok/app-server` into it.
A React Router app with no Vite plugin also works: `app-server serve build/server/index.js` reads a plain React Router build and runs it with the CLI's copy, the node host, and the default plugins. In that build, a route that calls `shutdownSignal()`, `eventStream()`, `requestContext()`, or `cspNonce()` must import the CLI's copy, so do not bundle `@ngrok/app-server` into it.

## Commands

Expand Down Expand Up @@ -381,21 +381,51 @@ createServer({
});
```

Give the nonce to the framework's render. `requestContext()?.cspNonce` has it. In React Router, do it in `app/entry.server.tsx`:
Give the nonce to the framework's render with `cspNonce()` from `@ngrok/app-server/client`. On the server, it gives the nonce of the request. In the browser, it reads the nonce from the document, so the hydration matches. It gives `undefined` when the page has no nonce. A bundler that resolves the `browser` export condition, such as Vite for the client build, gets a `cspNonce()` with no server code.

In React Router, give it to the render in `app/entry.server.tsx`:

```tsx
const nonce = requestContext()?.cspNonce;
import { cspNonce } from "@ngrok/app-server/client";

const nonce = cspNonce();
const { pipe } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} nonce={nonce} />,
{ nonce /* , … */ },
);
```

In TanStack Start, give it to the router:
`<HydratedRouter>` takes no nonce, so in the browser `<Links>`, `<ScrollRestoration>`, and `<Scripts>` render no nonce, and React warns that the hydration does not match. Give them the nonce in the root `Layout`. Give it also to each inline script of the app, such as a theme script:

```tsx
// app/root.tsx
import { cspNonce } from "@ngrok/app-server/client";

export function Layout({ children }: { children: React.ReactNode }) {
const nonce = cspNonce();
return (
<html lang="en">
<head>
<Meta />
<Links nonce={nonce} />
</head>
<body>
{children}
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
</html>
);
}
```

In TanStack Start, give it to the router. `getRouter` runs on the server and in the browser:

```ts
import { cspNonce } from "@ngrok/app-server/client";

export function getRouter() {
return createRouter({ routeTree, ssr: { nonce: requestContext()?.cspNonce } });
return createRouter({ routeTree, ssr: { nonce: cspNonce() } });
}
```

Expand Down Expand Up @@ -617,6 +647,17 @@ The default twin is the path with no trailing slash plus `.md`, and `/index.md`
prerendered({ markdownTwins: { twinOf: (path) => (path === "/" ? "/llms.md" : `${path}.md`) } });
```

`twinOf` also gets the request's Hono context, the same as `rewrite`. If it gives `undefined`, the request gets no twin, `rewrite` runs, and the answer gets no `Vary: Accept` and no `Link`. For example, when the framework renders a page for each query, keep a request with a query off the prerendered twin:

```ts
prerendered({
markdownTwins: {
twinOf: (path, c) =>
new URL(c.req.url).search === "" ? `${path === "/" ? "/index" : path}.md` : undefined,
},
});
```

The framework makes the twins. In React Router, one splat resource route can answer every twin. Prerender each twin URL, and point `twinOf` at the route. `markdownFor` stands for the app's own lookup:

```ts
Expand All @@ -642,7 +683,7 @@ createServer({ plugins: defaultPlugins({ prerendered: { markdownTwins: { twinOf

The same route answers the twins in development.

For other negotiation, `rewrite` maps a request path to the file to serve. It does not run for a request that gets the twin. When its answer depends on a request header, add a `Vary` for that header in a plugin.
For other negotiation, `rewrite` maps a request path to the file to serve. It does not run for a request that gets the twin. To keep a request off the twin, give `undefined` from `twinOf`. When its answer depends on a request header, add a `Vary` for that header in a plugin.

### A rendered page

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
},
"./client": {
"types": "./dist/client.d.ts",
"browser": "./dist/client-browser.js",
"import": "./dist/client.js"
},
"./node": {
Expand Down
7 changes: 7 additions & 0 deletions src/client/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// The `browser` build of `@ngrok/app-server/client`. It exports the same names as `index.ts`.
export { documentNonce as cspNonce } from "./document-nonce.js";
export {
//,
staleBuildReactRouter,
staleBuildTanStack,
} from "./stale-build-navigation.js";
70 changes: 70 additions & 0 deletions src/client/csp-nonce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Window } from "happy-dom";
import { describe, expect, it, vi } from "vitest";
import { contentSecurityPolicy } from "../core/plugins/security-headers.js";
import { host } from "../node/index.js";
import { testServer } from "../test-server.js";
import { cspNonce as browserCspNonce } from "./browser.js";
import { cspNonce } from "./csp-nonce.js";

/** A server whose framework renders a page with the nonce of `cspNonce()`. */
async function server() {
const { request, internals } = await testServer({
plugins: [contentSecurityPolicy({ policy: (scriptSrc) => `script-src ${scriptSrc}` })],
render: async () =>
new Response(`<!doctype html><script nonce="${cspNonce() ?? ""}"></script>`, {
headers: { "Content-Type": "text/html; charset=utf-8" },
}),
});
internals.setRequestStore(host.storage);
return request;
}

/**
* A browser document of `html`. Why the stand-in: happy-dom has no `nonce`
* property, and a browser hides the value of the `nonce` attribute.
*/
function browserDocument(html: string) {
const { document } = new Window();
document.write(html);
for (const element of document.querySelectorAll("[nonce]")) {
const nonce = element.getAttribute("nonce");
element.setAttribute("nonce", "");
Object.defineProperty(element, "nonce", { value: nonce });
}
return document;
}

describe("cspNonce", () => {
it("gives the same nonce on the server and in the browser, so the hydration matches", async () => {
const request = await server();

const response = await request("/");
const html = await response.text();
vi.stubGlobal("document", browserDocument(html));

const nonce = cspNonce();
expect(nonce).toMatch(/^[\w+/]+=*$/u);
expect(browserCspNonce()).toBe(nonce);
expect(html).toContain(`nonce="${nonce}"`);
expect(response.headers.get("Content-Security-Policy")).toBe(
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
);
});

it("exports the same names from the browser build", async () => {
const [browserEntry, serverEntry] = await Promise.all([
import("./browser.js"),
import("./index.js"),
]);

expect(Object.keys(browserEntry).toSorted()).toStrictEqual(Object.keys(serverEntry).toSorted());
});

it("gives undefined outside a request and in a document with no nonce", () => {
expect(cspNonce()).toBeUndefined();

vi.stubGlobal("document", browserDocument("<!doctype html><script></script>"));

expect(cspNonce()).toBeUndefined();
});
});
23 changes: 23 additions & 0 deletions src/client/csp-nonce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { requestContext } from "../core/request-context.js";
import { documentNonce } from "./document-nonce.js";

/**
* The CSP nonce of the document, for the framework's render. It gives the
* same value on the server and in the browser, so the hydration matches.
*
* - On the server, the nonce of the request that runs, from `requestContext()`.
* - In the browser, the nonce of the document's first element with a nonce.
* Why: React Router's `<HydratedRouter>` takes no nonce, so the app reads it
* from the document.
*
* It gives `undefined` when the page has no nonce: with no
* `contentSecurityPolicy` plugin, during a prerender, and on a prerendered page.
* A bundler that resolves the `browser` condition, such as Vite for the
* client, gets a build with no server code.
*
* @example
* <Scripts nonce={cspNonce()} />
*/
export function cspNonce(): string | undefined {
return typeof document === "undefined" ? requestContext()?.cspNonce : documentNonce();
}
12 changes: 12 additions & 0 deletions src/client/document-nonce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* The CSP nonce of the document's first element with a nonce, or `undefined`
* when it has none. The browser build of `@ngrok/app-server/client` exports it
* as `cspNonce()`, so the browser bundle holds no server code.
*
* @example
* documentNonce(); // => "d2hhdCBhIG5vbmNl"
*/
export function documentNonce(): string | undefined {
// Why the property: a browser hides the value of the `nonce` attribute when the page's CSP applies.
return document.querySelector<HTMLElement>("[nonce]")?.nonce;
}
1 change: 1 addition & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { cspNonce } from "./csp-nonce.js";
export {
//,
staleBuildReactRouter,
Expand Down
33 changes: 33 additions & 0 deletions src/core/plugins/static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,22 @@ describe("prerendered markdownTwins", () => {
}
});

it("gives twinOf the request, so a request with a query can skip the twin", async () => {
const { request } = await staticServer({
markdownTwins: {
twinOf: (path, context) =>
new URL(context.req.url).search === "" ? `${path}.md` : undefined,
},
});

const twin = await request("/components/button", markdown);
const query = await request("/components/button?q=filter", markdown);

await expect(twin.text()).resolves.toBe("# Button");
await expect(query.text()).resolves.toBe("<h1>Button</h1>");
expect(negotiation(query)).toStrictEqual({ vary: null, link: null });
});

it("runs rewrite only when the request does not get the twin", async () => {
const seen: string[] = [];
const { request } = await staticServer({
Expand Down Expand Up @@ -908,6 +924,23 @@ describe("prerendered markdownTwins in development", () => {
]);
});

it("gives twinOf the request, so a request with a query can skip the twin", async () => {
const { request, seen } = await devServer({
markdownTwins: {
twinOf: (path, context) =>
new URL(context.req.url).search === "" ? `${path}.md` : undefined,
},
});

await expect((await request("/docs/button", markdown)).text()).resolves.toBe(
"# /docs/button.md",
);
await expect((await request("/docs/button?q=filter", markdown)).text()).resolves.toBe(
"rendered",
);
expect(seen).toStrictEqual(["GET /docs/button.md", "GET /docs/button"]);
});

it("uses twinOf, and is off by default", async () => {
const mapped = await devServer({ markdownTwins: { twinOf: (path) => `/markdown${path}.md` } });
const off = await devServer({});
Expand Down
27 changes: 15 additions & 12 deletions src/core/plugins/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ export type PrerenderedOptions = {
/**
* Serves the markdown twin of a page, such as `/docs/button.md` for
* `/docs/button`, to a request that {@link prefersMarkdown} accepts. A
* page has a twin when `twinOf` names a file in the client build.
* page has a twin when `twinOf` names a file in the client build. `twinOf`
* gets the request path and the request's Hono context, the same as
* `rewrite`. It returns `undefined` for a request with no twin.
*
* - A request that prefers markdown gets the twin, with the twin's
* captured headers. `rewrite` does not run for it.
Expand All @@ -100,7 +102,7 @@ export type PrerenderedOptions = {
* @example
* prerendered({ markdownTwins: { twinOf: (path) => `/markdown${path}.md` } });
*/
markdownTwins?: boolean | { twinOf: (path: string) => string | undefined };
markdownTwins?: boolean | { twinOf: TwinOf };
/**
* Sends a request path to the next stage, with no file lookup. Use it for
* private pages, so the auth check in the app or the framework runs for
Expand All @@ -127,6 +129,9 @@ export type PrerenderedOptions = {
cacheControl?: (path: string) => string | undefined;
};

/** Maps a request to the URL path of its markdown twin, or `undefined` when it has none. */
type TwinOf = (path: string, context: Context<AppServerEnv>) => string | undefined;

const REVALIDATE = "public, max-age=0, must-revalidate";
const ONE_HOUR = "public, max-age=3600, stale-while-revalidate=86400";

Expand All @@ -135,13 +140,13 @@ function defaultTwinOf(path: string): string {
return `${trimTrailingSlashes(path) || "/index"}.md`;
}

/** The file of the markdown twin of `path`, or `undefined` when the page has none. */
/** The file of the markdown twin of the request, or `undefined` when the page has none. */
async function findTwin(
files: StaticFiles,
path: string,
twinOf: ((path: string) => string | undefined) | undefined,
context: Context<AppServerEnv>,
twinOf: TwinOf | undefined,
): Promise<{ path: string; file: StaticFile } | undefined> {
const twinPath = twinOf?.(path);
const twinPath = twinOf?.(context.req.path, context);
const file = twinPath == null ? undefined : await findStaticFile(files, twinPath);
return twinPath == null || file == null ? undefined : { path: twinPath, file };
}
Expand Down Expand Up @@ -182,7 +187,7 @@ function linkToTwin(context: Context<AppServerEnv>, twinPath: string): void {
* request on for the HTML.
*/
function developmentTwins(
twinOf: (path: string) => string | undefined,
twinOf: TwinOf,
skip: PrerenderedOptions["skip"],
prerenderedPaths: ReadonlySet<string> | undefined,
): MiddlewareHandler<AppServerEnv> {
Expand All @@ -192,7 +197,7 @@ function developmentTwins(
(method === "GET" || method === "HEAD") &&
skip?.(path) !== true &&
prefersMarkdown(context.req.raw)
? twinOf(path)
? twinOf(path, context)
: undefined;
// Why both checks: production has no twin that the build does not prerender, or at a path that fails `isFilePath`.
if (
Expand Down Expand Up @@ -226,9 +231,7 @@ function developmentTwins(
}

/** The `twinOf` function that the `markdownTwins` option selects, or `undefined` when it is off. */
function twinOfOption(
markdownTwins: PrerenderedOptions["markdownTwins"],
): ((path: string) => string | undefined) | undefined {
function twinOfOption(markdownTwins: PrerenderedOptions["markdownTwins"]): TwinOf | undefined {
if (markdownTwins === true) {
return defaultTwinOf;
}
Expand Down Expand Up @@ -316,7 +319,7 @@ export function prerendered(options: PrerenderedOptions = {}): Plugin {
if ((method !== "GET" && method !== "HEAD") || options.skip?.(context.req.path) === true) {
return next();
}
const twin = await findTwin(files, context.req.path, twinOf);
const twin = await findTwin(files, context, twinOf);
const preferred = twin != null && prefersMarkdown(context.req.raw) ? twin : undefined;
const path =
preferred?.path ?? options.rewrite?.(context.req.path, context) ?? context.req.path;
Expand Down
1 change: 1 addition & 0 deletions tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default defineConfig({
entry: {
cli: "./src/cli/cli.ts",
client: "./src/client/index.ts",
"client-browser": "./src/client/browser.ts",
index: "./src/core/index.ts",
node: "./src/node/index.ts",
"node-vite": "./src/node/vite.ts",
Expand Down
Loading