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
8 changes: 8 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 99 additions & 7 deletions docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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<AppEnv>();

// 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

Expand Down
88 changes: 60 additions & 28 deletions src/_server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};

Expand Down Expand Up @@ -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);
Expand All @@ -626,7 +624,7 @@ async function renderDocument(
}
});
});
return response;
return newResponse(c, new Response(body, { status: statusCode, headers }));
}

/**
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading