From 050a65a2ec3ba4782de2be01977207974600b0d8 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Thu, 24 Sep 2026 18:14:21 -0400 Subject: [PATCH] feat: keep documents private by default A document carries the hydrated data of every loader that ran and the request's serialized context, but a loader's, action's or thrown error's cache headers reached it as written. `Cache-Control: public` from one route made a publicly cacheable page holding per-reader data. On a document, Juniper now rewrites route cache headers so a shared cache cannot store the page: `Cache-Control` drops `public`, `s-maxage` and a field-qualified `private` and gains `private` unless `private` or `no-store` remains; CDN cache fields become `no-store`; a lone `Expires` is dropped. Route middleware opts a page back in with `c.set("publicDocument", true)`. Data responses and middleware-set headers are unchanged. The document now commits through `newResponse`, the same path as every other loader and action response. Closes #157 Co-Authored-By: Claude Opus 5.5 --- docs/error-handling.md | 8 + docs/routing.md | 106 +++++++++- src/_server.tsx | 88 +++++--- src/server.test.tsx | 462 +++++++++++++++++++++++++++++++++++++++++ src/server.tsx | 6 +- 5 files changed, 634 insertions(+), 36 deletions(-) diff --git a/docs/error-handling.md b/docs/error-handling.md index 6d3ee7d..7afb918 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -112,6 +112,14 @@ When a loader or action throws the error, the error document or the data request's error response carries those headers. Every `Set-Cookie` value is kept. +The error document also carries the data of every loader that ran and the +request's shared context, so the error's cache headers are made private there. +For example, `Cache-Control: public, max-age=60` becomes `private, max-age=60`. +This applies whether a loader, an action or middleware throws the error. The +data request's error response keeps the headers as written. To send the error +document's cache headers as written, set `publicDocument` in the route's +middleware. See [Caching Documents](routing.md#caching-documents). + A loader or action can also throw a `Response` other than a redirect, or throw `data()` from React Router. On a data request, Juniper sends it as an `HttpError` with that status and those headers. A status outside 400–599 becomes diff --git a/docs/routing.md b/docs/routing.md index 1c5987c..0df9e1b 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -496,17 +496,23 @@ import { Hono } from "hono"; const app = new Hono(); -// Blog data is the same for every visitor. +// Blog data is the same for every visitor. Documents also carry the layout's +// data, so only data requests get the public policy. app.use(async (c, next) => { - c.header("Cache-Control", "public, max-age=60"); + if (c.req.header("X-Juniper-Route-Id")) { + c.header("Cache-Control", "public, max-age=60"); + } await next(); }); export default app; ``` -Route middleware also runs for document requests. A data request carries the -`X-Juniper-Route-Id` request header, so check for it when the policy is only for +Route middleware also runs for document requests. Juniper doesn't rewrite a +policy that middleware sets, although a policy from a loader, an action or an +error still replaces it. A document carries more than this route's data (see +[Caching Documents](#caching-documents)), so check for the `X-Juniper-Route-Id` +request header, which only data requests carry, when the policy is only for data. A few other cases: @@ -515,17 +521,103 @@ A few other cases: written, so add `no-transform` yourself if the response might be a deferred stream. - A `Cache-Control` header on a thrown `HttpError` is used for that error - response, instead of the middleware policy or the default. + response, instead of the middleware policy or the default. On a document, it + is made private first, as described below. - A `Cache-Control` header on a redirect is used for that redirect, instead of the middleware policy or the default. This holds whether a loader or action throws the redirect or returns it. - A `Response` other than a redirect that a loader or action returns keeps its - own headers. Juniper adds no default policy to it. + own headers on a data request. Juniper adds no default policy to it. On a + document, its cache headers are made private first. - `data()` from React Router that a loader or action returns arrives on a data request as data with a `200` status, because the client reads any other status as an error. Its headers are kept, and a `Cache-Control` header among them is used instead of the middleware policy or the default. Its status applies to - document requests. + document requests. On a document, its cache headers are made private first. + +#### Caching Documents + +A document is the HTML page Juniper renders for a full page load, including an +error page. It carries the data of every loader that ran for the page, such as a +layout loader that returns the signed-in user. It also carries the request's +[shared context](state-management.md#sharing-server-context-with-the-client), +which middleware often fills per user. The pages it renders can show any of +these. + +A loader's own policy describes only its own data. So when a document's cache +headers come from a loader or an action, Juniper doesn't let a shared cache +store the document. This covers `data()` or a `Response` that a loader or action +returns, and an `HttpError` that a loader, action or middleware throws. Juniper +rewrites `Cache-Control`: + +- It removes `public`, `s-maxage`, and a `private` that names header fields. +- It adds `private` at the front, unless `private` or `no-store` remains. +- It keeps every other directive, such as `max-age`, `no-cache`, and `no-store`. + +| The route's `Cache-Control` | The document's `Cache-Control` | +| ---------------------------------- | ------------------------------ | +| `public, max-age=60` | `private, max-age=60` | +| `public, s-maxage=300, max-age=60` | `private, max-age=60` | +| `max-age=60` | `private, max-age=60` | +| `no-cache` | `private, no-cache` | +| `public, no-store` | `no-store` | +| `private, max-age=60` | `private, max-age=60` | + +Juniper also changes two other kinds of cache header from the route: + +- Fields that only CDNs read become `no-store`. These are `Surrogate-Control`, + `CDN-Cache-Control`, and names that end in `-CDN-Cache-Control`, such as + `Cloudflare-CDN-Cache-Control`. +- `Expires` is dropped when the route sends no `Cache-Control`, because it would + let a shared cache store the page on its own. Use `max-age` in `Cache-Control` + instead. + +This happens even when no loader ran, for example on the error page for an error +that middleware throws. That page still carries the request's context and +whatever the layouts render from it. + +It doesn't happen in these cases: + +- A data response keeps the route's policy as written, because it carries only + that route's data or error. +- Juniper doesn't rewrite a header that route middleware sets. Middleware sets + it for every response of the route, documents included. +- A document that gets no policy from the route or from middleware is sent + without one. + +When a page is the same for every visitor, set `publicDocument` in the route's +middleware. Juniper then sends the route's policy on the document as written: + +```typescript +// routes/blog/[id]/index.ts +import { Hono } from "hono"; +import { data } from "react-router"; +import type { RouteLoaderArgs } from "@udibo/juniper"; +import type { AppEnv } from "@udibo/juniper/server"; +import { postService } from "@/services/post.ts"; + +const app = new Hono(); + +// Every loader on this page, layouts included, returns the same data to every +// visitor. +app.use(async (c, next) => { + c.set("publicDocument", true); + await next(); +}); + +export default app; + +export async function loader({ params }: RouteLoaderArgs<{ id: string }>) { + const post = await postService.get(params.id); + return data({ post }, { + headers: { "Cache-Control": "public, max-age=300" }, + }); +} +``` + +Only set `publicDocument` for a page whose loaders, including every layout +loader above it, and whose shared context give every visitor the same values. It +applies to every document the middleware runs for, including error pages. ### Client Loaders diff --git a/src/_server.tsx b/src/_server.tsx index d7985ec..5de2406 100644 --- a/src/_server.tsx +++ b/src/_server.tsx @@ -151,6 +151,20 @@ export type AppEnv = Env & { * exists. */ buildId?: string; + /** + * Set to `true` in route middleware when the document for this request is + * the same for every reader. Juniper then sends the cache headers that a + * loader, an action, or a thrown error sets on that document as written. + * + * Without it, Juniper keeps those headers from letting a shared cache store + * the document, because a document carries the data of every loader that + * ran and the request's context. `Cache-Control` loses `public` and + * `s-maxage` and gains `private`. `CDN-Cache-Control`, `Surrogate-Control` + * and other CDN cache fields become `no-store`. An `Expires` without a + * `Cache-Control` is dropped. Headers on a data response, and headers that + * route middleware sets, are never rewritten. + */ + publicDocument?: boolean; }; }; @@ -580,37 +594,21 @@ async function renderDocument( ? reportedError.headers : undefined; - c.status(statusCode); - - for (const [key, value] of actionHeaders?.entries() ?? []) { - if (key.toLowerCase() !== "set-cookie") { - c.header(key, value); - } - } - for (const cookie of actionHeaders?.getSetCookie() ?? []) { - c.header("Set-Cookie", cookie, { append: true }); - } - for (const [key, value] of loaderHeaders?.entries() ?? []) { - if (key.toLowerCase() !== "set-cookie") { - c.header(key, value); - } - } - for (const cookie of loaderHeaders?.getSetCookie() ?? []) { - c.header("Set-Cookie", cookie, { append: true }); - } - - if (errorHeaders) { - for (const [key, value] of errorHeaders) { - if (!BODY_HEADERS.has(key) && key !== "set-cookie") c.header(key, value); + const headers = new Headers(); + for (const routeHeaders of [actionHeaders, loaderHeaders, errorHeaders]) { + for (const [key, value] of routeHeaders ?? []) { + if (key === "set-cookie") continue; + if (routeHeaders === errorHeaders && BODY_HEADERS.has(key)) continue; + headers.set(key, value); } - for (const cookie of errorHeaders.getSetCookie()) { - c.header("Set-Cookie", cookie, { append: true }); + for (const cookie of routeHeaders?.getSetCookie() ?? []) { + headers.append("Set-Cookie", cookie); } } + if (!c.get("publicDocument")) keepDocumentPrivate(headers); + headers.set("Content-Type", "text/html; charset=utf-8"); - c.header("Content-Type", "text/html; charset=utf-8"); - - const response = stream(c, async (streamInstance) => { + const { body } = stream(c, async (streamInstance) => { return await startActiveSpan("stream.pipe", async (streamSpan) => { try { await streamInstance.pipe(renderStream); @@ -626,7 +624,7 @@ async function renderDocument( } }); }); - return response; + return newResponse(c, new Response(body, { status: statusCode, headers })); } /** @@ -955,6 +953,40 @@ function dataCachePolicy( : appPolicy; } +const CACHE_DIRECTIVE = /(?:[^,"]|"(?:[^"\\]|\\.)*")+/g; + +function isSharedCacheDirective(directive: string): boolean { + const name = directive.split("=", 1)[0].trim().toLowerCase(); + return name === "public" || name === "s-maxage" || + (name === "private" && directive.includes("=")); +} + +function privateCachePolicy(policy: string): string { + const directives = (policy.match(CACHE_DIRECTIVE) ?? []) + .map((directive) => directive.trim()) + .filter(Boolean); + const kept = directives.filter((directive) => + !isSharedCacheDirective(directive) + ); + const isPrivate = kept.some((directive) => + ["private", "no-store"].includes(directive.toLowerCase()) + ); + if (isPrivate && kept.length === directives.length) return policy; + return (isPrivate ? kept : ["private", ...kept]).join(", "); +} + +const CDN_CACHE_FIELD = + /^(?:(?:[a-z0-9-]+-)?cdn-cache-control|surrogate-control)$/; + +function keepDocumentPrivate(routeHeaders: Headers): void { + const policy = routeHeaders.get("Cache-Control"); + if (policy === null) routeHeaders.delete("Expires"); + else routeHeaders.set("Cache-Control", privateCachePolicy(policy)); + for (const name of [...routeHeaders.keys()]) { + if (CDN_CACHE_FIELD.test(name)) routeHeaders.set(name, "no-store"); + } +} + function commitResponse(c: Context, response: Response): Response { if (c.finalized && !c.error) return response; // Hono copies an already-read `c.res`'s headers over the response a handler returns. diff --git a/src/server.test.tsx b/src/server.test.tsx index 965fd1e..5f7b87d 100644 --- a/src/server.test.tsx +++ b/src/server.test.tsx @@ -1734,6 +1734,468 @@ describe("the headers a loader or action response sets itself", () => { } }); +describe("the cache policy of a document", () => { + const readerState = "reader-state-for-ada"; + const publicPolicy = "public, max-age=60"; + + const tagResponse: MiddlewareHandler = async (c, next) => { + c.header("X-Application", "app"); + await next(); + }; + + const allowPublicDocument: MiddlewareHandler = async (c, next) => { + c.set("publicDocument", true); + await next(); + }; + + function throwFromMiddleware(policy: string): MiddlewareHandler { + return () => { + throw new HttpError(404, { + message: "Not found", + headers: new Headers({ "Cache-Control": policy }), + }); + }; + } + + function serverWithPage( + readsResponseFirst: boolean, + page: { + loader?: () => unknown; + action?: () => unknown; + middleware?: MiddlewareHandler[]; + rootLoader?: (() => unknown) | null; + }, + ) { + const { + loader, + action, + middleware = [], + rootLoader = () => ({ reader: readerState }), + } = page; + const client = new Client({ + path: "/", + main: { + default: () => , + ErrorBoundary: () =>
Error page
, + }, + children: [{ + path: "page", + main: { + default: () =>
Page
, + ErrorBoundary: () =>
Page error
, + }, + }], + }); + return createServer(import.meta.url, client, { + path: "/", + main: { + default: new Hono().use( + ...(readsResponseFirst ? [cors(), tagResponse] : [tagResponse]), + ), + loader: rootLoader ?? undefined, + }, + children: [{ + path: "page", + main: { + default: middleware.length > 0 + ? new Hono().use(...middleware) + : undefined, + loader, + action, + }, + }], + }); + } + + async function requestDocument( + server: ReturnType, + method = "GET", + ): Promise<{ response: Response; html: string }> { + const response = await server.request("http://localhost/page", { + method, + }); + const html = await response.text(); + assertEquals( + response.headers.get("Content-Type"), + "text/html; charset=utf-8", + ); + assertEquals(response.headers.get("X-Application"), "app"); + return { response, html }; + } + + const appPolicyAndCookie: MiddlewareHandler = async (c, next) => { + c.header("X-Owner", "app"); + c.header("Cache-Control", "private, no-cache"); + setCookie(c, "app", "1"); + await next(); + }; + + const cdnPolicies: [string, string][] = [ + ["CDN-Cache-Control", "public, max-age=600"], + ["Cloudflare-CDN-Cache-Control", "max-age=600"], + ["Surrogate-Control", "max-age=600"], + ]; + + const rewrites: [string, string][] = [ + ["public, max-age=60", "private, max-age=60"], + ["public, s-maxage=300, max-age=60", "private, max-age=60"], + ["S-MaxAge=300", "private"], + ['private="Set-Cookie, X-Owner", max-age=60', "private, max-age=60"], + [ + "max-age=60, stale-while-revalidate=30", + "private, max-age=60, stale-while-revalidate=30", + ], + ["no-cache", "private, no-cache"], + ["public, no-store", "no-store"], + ["no-store", "no-store"], + ["private, max-age=60", "private, max-age=60"], + ["Private,max-age=60", "Private,max-age=60"], + ]; + + const pageSources: [ + string, + (policy: string) => { + loader?: () => unknown; + action?: () => unknown; + }, + string, + number, + ][] = [ + [ + "an HttpError the loader throws", + (policy) => ({ + loader: () => { + throw new HttpError(404, { + message: "Not found", + headers: new Headers({ "Cache-Control": policy }), + }); + }, + }), + "GET", + 404, + ], + [ + "data() the loader returns", + (policy) => ({ + loader: () => + data({ page: "page" }, { headers: { "Cache-Control": policy } }), + }), + "GET", + 200, + ], + [ + "a Response the loader returns", + (policy) => ({ + loader: () => + new Response(null, { headers: { "Cache-Control": policy } }), + }), + "GET", + 200, + ], + [ + "data() the action returns", + (policy) => ({ + loader: () => ({ page: "page" }), + action: () => + data({ saved: true }, { headers: { "Cache-Control": policy } }), + }), + "POST", + 200, + ], + [ + "an HttpError the action throws", + (policy) => ({ + loader: () => ({ page: "page" }), + action: () => { + throw new HttpError(409, { + message: "Conflict", + headers: new Headers({ "Cache-Control": policy }), + }); + }, + }), + "POST", + 409, + ], + ]; + + for ( + const [order, readsResponseFirst] of [ + ["after middleware has read the response", true], + ["when no middleware has read the response", false], + ] as const + ) { + describe(order, () => { + for (const [source, page, method, status] of pageSources) { + for (const [policy, expected] of rewrites) { + it(`sends ${JSON.stringify(policy)} from ${source} on a document that hydrates loader data as ${JSON.stringify(expected)}`, async () => { + using _log = stub(console, "error"); + const server = serverWithPage(readsResponseFirst, page(policy)); + const { response, html } = await requestDocument(server, method); + assertEquals(response.status, status); + assertStringIncludes(html, readerState); + assertEquals(response.headers.get("Cache-Control"), expected); + }); + } + + it(`keeps ${publicPolicy} from ${source} on a document the route marks public`, async () => { + using _log = stub(console, "error"); + const server = serverWithPage(readsResponseFirst, { + ...page(publicPolicy), + middleware: [allowPublicDocument], + }); + const { response, html } = await requestDocument(server, method); + assertEquals(response.status, status); + assertStringIncludes(html, readerState); + assertEquals(response.headers.get("Cache-Control"), publicPolicy); + }); + } + + it("sends a shared policy from an HttpError middleware throws as private, although no loader ran", async () => { + using _log = stub(console, "error"); + const server = serverWithPage(readsResponseFirst, { + loader: () => ({ page: "page" }), + middleware: [throwFromMiddleware(publicPolicy)], + }); + const { response, html } = await requestDocument(server); + assertEquals(response.status, 404); + assertStringIncludes(html, "Error page"); + assertFalse(html.includes(readerState)); + assertEquals( + response.headers.get("Cache-Control"), + "private, max-age=60", + ); + }); + + it("sends a shared policy from an HttpError as private on a document of routes without loaders", async () => { + using _log = stub(console, "error"); + const server = serverWithPage(readsResponseFirst, { + rootLoader: null, + middleware: [throwFromMiddleware(publicPolicy)], + }); + const { response, html } = await requestDocument(server); + assertEquals(response.status, 404); + assertStringIncludes(html, "Page error"); + assertEquals( + response.headers.get("Cache-Control"), + "private, max-age=60", + ); + }); + + it("keeps a shared policy from an HttpError middleware throws on a document the route marks public", async () => { + using _log = stub(console, "error"); + const server = serverWithPage(readsResponseFirst, { + rootLoader: null, + middleware: [allowPublicDocument, throwFromMiddleware(publicPolicy)], + }); + const { response } = await requestDocument(server); + assertEquals(response.status, 404); + assertEquals(response.headers.get("Cache-Control"), publicPolicy); + }); + + it("keeps the policy route middleware sets on a document", async () => { + const server = serverWithPage(readsResponseFirst, { + loader: () => ({ page: "page" }), + middleware: [async (c, next) => { + c.header("Cache-Control", publicPolicy); + await next(); + }], + }); + const { response, html } = await requestDocument(server); + assertEquals(response.status, 200); + assertStringIncludes(html, readerState); + assertEquals(response.headers.get("Cache-Control"), publicPolicy); + }); + + it("sends a document without a policy when no route or middleware sets one", async () => { + const server = serverWithPage(readsResponseFirst, { + loader: () => ({ page: "page" }), + }); + const { response } = await requestDocument(server); + assertEquals(response.status, 200); + assertEquals(response.headers.get("Cache-Control"), null); + }); + + it("sends the cookies of the app, then the loader, and the loader's headers over the app's, on a document", async () => { + const server = serverWithPage(readsResponseFirst, { + middleware: [appPolicyAndCookie], + loader: () => + data({ page: "page" }, { + headers: [ + ["Set-Cookie", "loader=1; Path=/"], + ["Set-Cookie", "loader=2; Path=/"], + ["Cache-Control", "max-age=60"], + ["X-Owner", "loader"], + ], + }), + }); + const { response, html } = await requestDocument(server); + assertEquals(response.status, 200); + assertStringIncludes(html, readerState); + assertEquals(response.headers.getSetCookie(), [ + "app=1; Path=/", + "loader=1; Path=/", + "loader=2; Path=/", + ]); + assertEquals(response.headers.get("X-Owner"), "loader"); + assertEquals( + response.headers.get("Cache-Control"), + "private, max-age=60", + ); + }); + + it("sends the cookies of the app, the action, then the error, and the error's headers over the action's, on a document", async () => { + using _log = stub(console, "error"); + const server = serverWithPage(readsResponseFirst, { + middleware: [appPolicyAndCookie], + action: () => + data({ saved: true }, { + headers: [ + ["Set-Cookie", "action=1; Path=/"], + ["Cache-Control", "max-age=60"], + ["X-Owner", "action"], + ], + }), + loader: () => { + throw new HttpError(409, { + message: "Conflict", + headers: new Headers([ + ["Set-Cookie", "error=1; Path=/"], + ["Cache-Control", "public, max-age=5"], + ["X-Owner", "error"], + ]), + }); + }, + }); + const { response, html } = await requestDocument(server, "POST"); + assertEquals(response.status, 409); + assertStringIncludes(html, readerState); + assertEquals(response.headers.getSetCookie(), [ + "app=1; Path=/", + "action=1; Path=/", + "error=1; Path=/", + ]); + assertEquals(response.headers.get("X-Owner"), "error"); + assertEquals( + response.headers.get("Cache-Control"), + "private, max-age=5", + ); + }); + + it("sends the policy of an error a layout loader throws over the page loader's on a document", async () => { + using _log = stub(console, "error"); + const server = serverWithPage(readsResponseFirst, { + rootLoader: () => { + throw new HttpError(503, { + message: "Unavailable", + headers: new Headers({ "Cache-Control": "no-store" }), + }); + }, + loader: () => + data({ page: "page" }, { + headers: { "Cache-Control": "public, max-age=60" }, + }), + }); + const { response } = await requestDocument(server); + assertEquals(response.status, 503); + assertEquals(response.headers.get("Cache-Control"), "no-store"); + }); + + it("sends the CDN cache fields a loader sets as no-store on a document", async () => { + const server = serverWithPage(readsResponseFirst, { + loader: () => data({ page: "page" }, { headers: cdnPolicies }), + }); + const { response, html } = await requestDocument(server); + assertEquals(response.status, 200); + assertStringIncludes(html, readerState); + for (const [name] of cdnPolicies) { + assertEquals(response.headers.get(name), "no-store", name); + } + assertEquals(response.headers.get("Cache-Control"), null); + }); + + it("keeps the CDN cache fields a loader sets on a document the route marks public", async () => { + const server = serverWithPage(readsResponseFirst, { + middleware: [allowPublicDocument], + loader: () => data({ page: "page" }, { headers: cdnPolicies }), + }); + const { response } = await requestDocument(server); + assertEquals(response.status, 200); + for (const [name, policy] of cdnPolicies) { + assertEquals(response.headers.get(name), policy, name); + } + }); + + it("drops an Expires a loader sets without a Cache-Control from a document", async () => { + const server = serverWithPage(readsResponseFirst, { + loader: () => + data({ page: "page" }, { + headers: { Expires: "Wed, 21 Oct 2099 07:28:00 GMT" }, + }), + }); + const { response, html } = await requestDocument(server); + assertEquals(response.status, 200); + assertStringIncludes(html, readerState); + assertEquals(response.headers.get("Expires"), null); + assertEquals(response.headers.get("Cache-Control"), null); + }); + + it("keeps an Expires a loader sets beside a Cache-Control, which becomes private, on a document", async () => { + const server = serverWithPage(readsResponseFirst, { + loader: () => + data({ page: "page" }, { + headers: { + "Cache-Control": "public", + Expires: "Wed, 21 Oct 2099 07:28:00 GMT", + }, + }), + }); + const { response } = await requestDocument(server); + assertEquals(response.status, 200); + assertEquals( + response.headers.get("Expires"), + "Wed, 21 Oct 2099 07:28:00 GMT", + ); + assertEquals(response.headers.get("Cache-Control"), "private"); + }); + + it("keeps an Expires a loader sets on a document the route marks public", async () => { + const server = serverWithPage(readsResponseFirst, { + middleware: [allowPublicDocument], + loader: () => + data({ page: "page" }, { + headers: { Expires: "Wed, 21 Oct 2099 07:28:00 GMT" }, + }), + }); + const { response } = await requestDocument(server); + assertEquals(response.status, 200); + assertEquals( + response.headers.get("Expires"), + "Wed, 21 Oct 2099 07:28:00 GMT", + ); + }); + + it("keeps the shared policy of data() a loader returns on a data request, which carries only that loader's data", async () => { + const server = serverWithPage(readsResponseFirst, { + loader: () => + data({ page: "page" }, { + headers: [["Cache-Control", publicPolicy], ...cdnPolicies], + }), + }); + const response = await server.request("http://localhost/page", { + headers: { "X-Juniper-Route-Id": "/page" }, + }); + assertEquals(response.status, 200); + assertEquals(response.headers.get("X-Juniper"), "data"); + assertFalse((await response.text()).includes(readerState)); + assertEquals(response.headers.get("Cache-Control"), publicPolicy); + for (const [name, policy] of cdnPolicies) { + assertEquals(response.headers.get(name), policy, name); + } + }); + }); + } +}); + describe("data requests React Router rejects before a loader or action runs", () => { const appMiddleware: MiddlewareHandler = async (c, next) => { c.header("X-Application", "app"); diff --git a/src/server.tsx b/src/server.tsx index 56edf46..7221679 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -57,7 +57,11 @@ function varyByRoute(headers: Headers): void { * private, no-cache`, plus `no-transform` when deferred; a policy route * middleware sets before `next()` replaces it, and a policy on a redirect or * error a loader or action returns or throws, or on the `data()` it returns, - * replaces both. + * replaces both. On a document, cache headers from a loader, an action or a + * thrown error are made private: `Cache-Control` drops `public` and `s-maxage` + * and gains `private`, CDN cache fields become `no-store`, and an `Expires` + * without a `Cache-Control` is dropped. Route middleware that sets the + * `publicDocument` variable to `true` keeps them as written. * * @param moduleUrl - File URL of the application entrypoint; its directory owns `public/`. * @param client - Matching client route definitions from the same build.