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
6 changes: 6 additions & 0 deletions .changeset/cache-vary-host.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/astro-renderer": patch
"@cloudhal/cli": patch
---

The site Worker's responses carry `Vary: Host`, and redirect-host 301s never enter the edge cache (#62). Workers Cache keys by path, not hostname, so before this every host routed to one site Worker shared a cache entry per path: a `--redirect` host's 301 could be served on the site's own domain (a redirect loop), and the sites of a multi-site Worker could serve each other's cached pages.
4 changes: 2 additions & 2 deletions docs/rfcs/0034-multi-site-serving-and-projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ A site in a multi-site installation is as independent as a site in its own insta
3. An unknown host, or one whose site is not in the allow-list, gets a plain 404, cached for 60 s. No other site's content is ever served for it.
4. The Worker's `*.workers.dev` hostname serves the first site in `sites`, marked `noindex`, so a fresh deploy has something to check.
- **Per-site state.** `getResolver(siteId)`, media, search, feeds and the analytics and comments settings use the resolved id. The module-level caches become maps keyed by `siteId`. Same-origin checks (RFC-0006 §5.2, RFC-0007 §6.2) read the site's host rows. `CLOUDHAL_SITE_HOSTS` keeps working in single-site mode.
- **Cache.** The edge cache key already contains the host. Every response, 404s included, carries `cloudhal-site-<id>`. In multi-site mode, purges use tags only, never `purgeEverything`, so one site's publish never empties another site's cache.
- **Cache.** Workers Cache keys by path, not host (correction, #62: this RFC first said the key already contained the host). Every site Worker response carries `Vary: Host`, so each host keeps its own cached variant. Every response, 404s included, carries `cloudhal-site-<id>`. In multi-site mode, purges use tags only, never `purgeEverything`, so one site's publish never empties another site's cache.
- **Locale domains** (RFC-0033 §4) are host rows with `locale` set; the host decides the locale before the path, as today.

### 5. Domains
Expand Down Expand Up @@ -174,7 +174,7 @@ What one installation can hold, from Cloudflare's published limits (Workers and
- **Rollback:** switch `cloudhal.json` back to `siteId` and redeploy. The tables are ignored in single-site mode.

## Security & policy impact
- **Isolation within an installation:** invariants 1–4. The cache key includes the host, and purge tags are per site.
- **Isolation within an installation:** invariants 1–4. Cached responses vary by host (`Vary: Host`, #62), and purge tags are per site.
- **New scopes:**
- `site:create`: installation Owner and admins; also used for editions.
- `site:delete`: Owner only, typed confirmation, audited.
Expand Down
20 changes: 20 additions & 0 deletions packages/astro-renderer/src/cache-vary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Workers Cache keys by path, not hostname, and answers before the Worker runs (#62). Every host
* routed to the site Worker — a custom domain, its workers.dev name, a redirect host (#49), the
* other sites of a multi-site Worker (RFC-0034) — would otherwise share one entry per path. `Vary:
* Host` makes it keep one variant per host.
*
* Worker-safe: no imports.
*/
export function varyByHost(response: Response): Response {
const vary = response.headers.get("vary") ?? "";
if (vary.trim() === "*" || /(^|,)\s*host\s*(,|$)/i.test(vary)) return response;
// A redirect's or a WebSocket's headers can be immutable; copy them into a fresh response.
const headers = new Headers(response.headers);
headers.set("vary", vary.trim() ? `${vary}, Host` : "Host");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
31 changes: 18 additions & 13 deletions packages/astro-renderer/src/cloudflare-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
SiteAnalytics,
} from "./analytics.ts";
import { isCloudhalCacheTag } from "./cache-tags.ts";
import { varyByHost } from "./cache-vary.ts";
import { type CommentsRateLimit, isCommentsPath, SiteComments } from "./comments.ts";
import { SITE_CSP } from "./csp.ts";
import { EMBED_HEADER, withEmbedFrames } from "./embed-client.ts";
Expand Down Expand Up @@ -529,21 +530,25 @@ async function serveSite(

export function createSiteWorker(options: { siteId?: string } = {}) {
const searchLimiter = new SearchLimiter();
async function route(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/__cloudhal/purge") return purge(request, env, options.siteId);
if (url.pathname === "/__cloudhal/features") return features(request, env);
const moved = hostRedirect(request, env);
if (moved) return moved;
// RFC-0034 §4: the hostname picks the site on a multi-site Worker; everything the request
// runs (Astro's render included) then sees that site.
const choice = await siteDispatch(env, options.siteId).choose(request);
if (choice.kind === "unknown") return unknownHost();
const siteId = choice.siteId;
const run = () => serveSite({ request, env, ctx }, siteId, searchLimiter);
const response = siteId ? await withRequestSite(siteId, run) : await run();
return choice.noindex ? withNoindex(response) : response;
}
return {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/__cloudhal/purge") return purge(request, env, options.siteId);
if (url.pathname === "/__cloudhal/features") return features(request, env);
const moved = hostRedirect(request, env);
if (moved) return moved;
// RFC-0034 §4: the hostname picks the site on a multi-site Worker; everything the request
// runs (Astro's render included) then sees that site.
const choice = await siteDispatch(env, options.siteId).choose(request);
if (choice.kind === "unknown") return unknownHost();
const siteId = choice.siteId;
const run = () => serveSite({ request, env, ctx }, siteId, searchLimiter);
const response = siteId ? await withRequestSite(siteId, run) : await run();
return choice.noindex ? withNoindex(response) : response;
// #62: the edge cache is shared by every host this Worker answers on, unless told to vary.
return varyByHost(await route(request, env, ctx));
},
};
}
Expand Down
2 changes: 2 additions & 0 deletions packages/astro-renderer/src/host-redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export function hostRedirect(request: Request, env: RedirectEnv): Response | und
headers: {
location: `https://${to}${url.pathname}${url.search}`,
"cache-control": "public, max-age=86400",
// Never in the edge cache (#62): it is shared with the hosts this one redirects to.
"cloudflare-cdn-cache-control": "no-store",
},
});
}
21 changes: 21 additions & 0 deletions packages/astro-renderer/test/cache-vary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { varyByHost } from "../src/cache-vary.ts";

describe("varyByHost (#62: the edge cache is keyed by path, not host)", () => {
it("adds Host to Vary, keeping what is there", () => {
expect(varyByHost(new Response("x")).headers.get("vary")).toBe("Host");
const r = varyByHost(new Response("x", { headers: { vary: "Accept-Encoding" } }));
expect(r.headers.get("vary")).toBe("Accept-Encoding, Host");
});

it("leaves Vary: * and an existing Host alone, and keeps status and body", async () => {
expect(varyByHost(new Response("x", { headers: { vary: "*" } })).headers.get("vary")).toBe("*");
expect(
varyByHost(new Response("x", { headers: { vary: "accept, host" } })).headers.get("vary"),
).toBe("accept, host");
const moved = varyByHost(Response.redirect("https://example.com/a", 301));
expect(moved.status).toBe(301);
expect(moved.headers.get("location")).toBe("https://example.com/a");
expect(await varyByHost(new Response("body", { status: 404 })).text()).toBe("body");
});
});
2 changes: 2 additions & 0 deletions packages/astro-renderer/test/host-redirect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ describe("redirect-only hosts (#49)", () => {
const r = hostRedirect(new Request("https://WWW.example.com/blog/post?ref=x"), env);
expect(r?.status).toBe(301);
expect(r?.headers.get("location")).toBe("https://example.com/blog/post?ref=x");
// #62: never in the edge cache, which every host of the Worker shares
expect(r?.headers.get("cloudflare-cdn-cache-control")).toBe("no-store");
expect(hostRedirect(new Request("http://old.example.org/"), env)?.headers.get("location")).toBe(
"https://example.com/",
);
Expand Down
Loading