From 08766d52e205d7abda40cb897af5bc4ef093b525 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 8 Aug 2026 13:46:45 +0200 Subject: [PATCH 1/4] fix(core): fold the build into the route cache validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CacheHint.lastModified` carries the content row's `updated_at`, and Astro emits it as the response `Last-Modified`. The response also depends on the build, because `/_astro/*` filenames are content-hashed and a deployment only serves its own. After a deploy that changes only code the validator is unchanged, so a returning visitor's conditional request is answered with 304 and the browser keeps HTML referencing assets the new deployment no longer has — 404 on Workers Assets, leaving the page without CSS or JavaScript. A response-derived ETag would be the obvious remedy, but Cloudflare strips `ETag` from Worker HTML responses, so `Last-Modified` has to carry it. The middleware now folds a build timestamp, exported from a new `virtual:emdash/build` module, into the validator for on-demand responses. Astro keeps the later of two dates, so a route's own hint still wins whenever content is newer. Prerendered pages stay untouched — the host's static layer manages its own validators. Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Fable 5 --- .changeset/cache-validator-build-dimension.md | 5 + .../src/astro/integration/virtual-modules.ts | 16 ++ .../core/src/astro/integration/vite-config.ts | 14 ++ packages/core/src/astro/middleware.ts | 29 +++ packages/core/src/virtual-modules.d.ts | 10 + .../astro/integration/virtual-modules.test.ts | 12 ++ .../astro/middleware-cache-validator.test.ts | 203 ++++++++++++++++++ packages/core/vitest.config.ts | 3 + 8 files changed, 292 insertions(+) create mode 100644 .changeset/cache-validator-build-dimension.md create mode 100644 packages/core/tests/unit/astro/middleware-cache-validator.test.ts diff --git a/.changeset/cache-validator-build-dimension.md b/.changeset/cache-validator-build-dimension.md new file mode 100644 index 0000000000..9cd8bf2513 --- /dev/null +++ b/.changeset/cache-validator-build-dimension.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes returning visitors getting a page without CSS or JavaScript after a deploy that changed only code. Cached routes now revalidate against the build as well as the content, so a browser holding HTML from an earlier deployment is served a fresh page instead of a 304 pointing at asset files that deployment no longer has. diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index a42c3d2aa6..d8ba59adef 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -72,6 +72,9 @@ export const RESOLVED_VIRTUAL_SCHEDULER_ID = "\0" + VIRTUAL_SCHEDULER_ID; export const VIRTUAL_ENV_ID = "virtual:emdash/env"; export const RESOLVED_VIRTUAL_ENV_ID = "\0" + VIRTUAL_ENV_ID; +export const VIRTUAL_BUILD_ID = "virtual:emdash/build"; +export const RESOLVED_VIRTUAL_BUILD_ID = "\0" + VIRTUAL_BUILD_ID; + /** * Generates the config virtual module. */ @@ -497,6 +500,19 @@ export function generateEnvModule(adapterName: string | undefined): string { return `export const env = undefined;`; } +/** + * Generates the build virtual module. + * + * Content-hashed `/_astro/*` names make the response depend on the build, not + * only on the content. Exposing the build timestamp lets the middleware fold + * that dimension into the cache validator, so a code-only deploy stops + * answering conditional requests with 304 while the assets the cached HTML + * references are already gone. + */ +export function generateBuildModule(buildTime: number): string { + return `export const buildTime = ${buildTime};`; +} + /** * Generates the scheduler virtual module. * diff --git a/packages/core/src/astro/integration/vite-config.ts b/packages/core/src/astro/integration/vite-config.ts index 29266e6352..67ed100c6a 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -48,10 +48,13 @@ import { RESOLVED_VIRTUAL_SCHEDULER_ID, VIRTUAL_ENV_ID, RESOLVED_VIRTUAL_ENV_ID, + VIRTUAL_BUILD_ID, + RESOLVED_VIRTUAL_BUILD_ID, generateSeedModule, generateWaitUntilModule, generateSchedulerModule, generateEnvModule, + generateBuildModule, generateConfigModule, generateDialectModule, generateStorageModule, @@ -179,6 +182,11 @@ export function createVirtualModulesPlugin( let viteCommand: "build" | "serve" | undefined; + // Captured once per plugin instance rather than inside load(): Vite may load + // the module more than once (client and server passes, dev reloads), and a + // validator that moved between those loads would invalidate at random. + const buildTime = Date.now(); + return { name: "emdash-virtual-modules", configResolved(config) { @@ -233,6 +241,9 @@ export function createVirtualModulesPlugin( if (id === VIRTUAL_ENV_ID) { return RESOLVED_VIRTUAL_ENV_ID; } + if (id === VIRTUAL_BUILD_ID) { + return RESOLVED_VIRTUAL_BUILD_ID; + } }, load(id: string) { if (id === RESOLVED_VIRTUAL_CONFIG_ID) { @@ -333,6 +344,9 @@ export function createVirtualModulesPlugin( if (id === RESOLVED_VIRTUAL_ENV_ID) { return generateEnvModule(astroConfig.adapter?.name); } + if (id === RESOLVED_VIRTUAL_BUILD_ID) { + return generateBuildModule(buildTime); + } }, }; } diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 07337359c4..e1840a08f4 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -5,10 +5,13 @@ * All heavy lifting happens in EmDashRuntime. */ +import type { APIContext } from "astro"; import { defineMiddleware } from "astro:middleware"; import type { Kysely } from "kysely"; // Import from virtual modules (populated by integration at build time) // @ts-ignore - virtual module +import { buildTime as virtualBuildTime } from "virtual:emdash/build"; +// @ts-ignore - virtual module import virtualConfig from "virtual:emdash/config"; // @ts-ignore - virtual module import { @@ -496,6 +499,30 @@ function createRequestScopedDb( return fn(opts); } +const buildDate = virtualBuildTime ? new Date(virtualBuildTime) : null; + +/** + * Fold the build timestamp into the route cache validator. + * + * `CacheHint.lastModified` describes the content, but the response also depends + * on the build: `/_astro/*` names are content-hashed, and a deployment only + * serves its own. Without the build dimension a code-only deploy answers a + * returning visitor's conditional request with 304, leaving them on HTML whose + * assets 404. + * + * Prerendered pages are served by the host's static layer, which manages its + * own validators — only on-demand responses need the build dimension. + * + * Must run before next(): Astro keeps the later of two dates, so a route's own + * hint still wins when content is newer, and a route that opts out with + * `Astro.cache.set(false)` stays opted out — calling set() afterwards would + * clear that opt-out. + */ +function applyBuildValidator(context: APIContext): void { + if (context.isPrerendered || !buildDate || !context.cache?.enabled) return; + context.cache.set({ lastModified: buildDate }); +} + export const onRequest = defineMiddleware(async (context, next) => { const { request, locals, cookies } = context; const url = context.url; @@ -514,6 +541,8 @@ export const onRequest = defineMiddleware(async (context, next) => { } } + applyBuildValidator(context); + const queryRecorder = isInstrumentationEnabled() ? createRecorder(url.pathname, request.method, request.headers.get("x-perf-phase") ?? "default") : undefined; diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts index 9ce6519842..1935187ac7 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -170,6 +170,16 @@ declare module "virtual:emdash/env" { export const env: Record | undefined; } +declare module "virtual:emdash/build" { + /** + * Epoch milliseconds at which this build's virtual modules were generated. + * Folded into the route cache validator so a code-only deploy — which + * renames `/_astro/*` without touching content — still invalidates HTML a + * browser cached from an earlier deployment. + */ + export const buildTime: number; +} + declare module "virtual:emdash/scheduler" { import type { CreateSchedulerFn } from "./emdash-runtime.js"; /** diff --git a/packages/core/tests/unit/astro/integration/virtual-modules.test.ts b/packages/core/tests/unit/astro/integration/virtual-modules.test.ts index 79b17db6d2..e547fa283f 100644 --- a/packages/core/tests/unit/astro/integration/virtual-modules.test.ts +++ b/packages/core/tests/unit/astro/integration/virtual-modules.test.ts @@ -13,6 +13,7 @@ import { generateEnvModule, generateSchedulerModule, generateSeedModule, + RESOLVED_VIRTUAL_BUILD_ID, RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID, RESOLVED_VIRTUAL_SCHEDULER_ID, } from "../../../../src/astro/integration/virtual-modules.js"; @@ -185,6 +186,17 @@ describe("createVirtualModulesPlugin scheduler wiring", () => { expect(out).not.toContain("NodeCronScheduler"); }); + it("keeps the build timestamp stable across repeated loads", () => { + const plugin = buildPlugin("@astrojs/cloudflare", "build"); + callHook(plugin.configResolved, { command: "build" }); + + const first = callHook(plugin.load, RESOLVED_VIRTUAL_BUILD_ID); + const second = callHook(plugin.load, RESOLVED_VIRTUAL_BUILD_ID); + + expect(first).toBe(second); + expect(Number(/buildTime = (\d+)/.exec(first)?.[1])).toBeGreaterThan(0); + }); + it("watches resolved sandbox plugin entries", () => { const projectRoot = mkdtempSync(join(tmpdir(), "emdash-sandbox-watch-test-")); try { diff --git a/packages/core/tests/unit/astro/middleware-cache-validator.test.ts b/packages/core/tests/unit/astro/middleware-cache-validator.test.ts new file mode 100644 index 0000000000..dcb4fc47d0 --- /dev/null +++ b/packages/core/tests/unit/astro/middleware-cache-validator.test.ts @@ -0,0 +1,203 @@ +/** + * `CacheHint.lastModified` carries the content's `updated_at`, and Astro emits it + * as the response `Last-Modified`. A deploy that changes only code therefore + * leaves the validator untouched: a returning visitor revalidates, gets 304, and + * keeps HTML referencing `/_astro/*` files the new deployment no longer has — + * 404 on Workers, so the page renders without CSS or JS. + * + * The middleware folds the build timestamp into the validator, so the response + * date reflects the response rather than only the content. + */ +import { beforeEach, describe, it, expect, vi } from "vitest"; + +vi.mock("astro:middleware", () => ({ + defineMiddleware: (handler: unknown) => handler, +})); + +const { BUILD_TIME, MOCK_RUNTIME } = vi.hoisted(() => { + const ok = async () => ({ success: true }); + return { + BUILD_TIME: Date.parse("2026-08-07T22:26:49.000Z"), + MOCK_RUNTIME: { + storage: { getPublicUrl: vi.fn((key: string) => `https://media.example.com/${key}`) }, + db: {}, + hooks: {}, + email: null, + configuredPlugins: [], + getPluginRouteMeta: () => null, + handlePluginApiRoute: async () => ({ success: true }), + getMediaProvider: () => undefined, + getMediaProviderList: () => [], + collectPageMetadata: async () => [], + collectPageFragments: async () => [], + ensureSearchHealthy: async () => undefined, + getManifest: async () => ({}), + getSandboxRunner: () => null, + isSandboxBypassed: () => false, + syncMarketplacePlugins: async () => undefined, + syncRegistryPlugins: async () => undefined, + setPluginStatus: async () => undefined, + handleContentList: ok, + }, + }; +}); + +vi.mock("virtual:emdash/build", () => ({ buildTime: BUILD_TIME }), { virtual: true }); +vi.mock( + "virtual:emdash/config", + () => ({ default: { database: { config: { binding: "DB" } }, auth: { mode: "none" } } }), + { virtual: true }, +); +vi.mock( + "virtual:emdash/dialect", + () => ({ createDialect: vi.fn(), createRequestScopedDb: vi.fn().mockReturnValue(null) }), + { virtual: true }, +); +vi.mock("virtual:emdash/media-providers", () => ({ mediaProviders: [] }), { virtual: true }); +vi.mock("virtual:emdash/plugins", () => ({ plugins: [] }), { virtual: true }); +vi.mock( + "virtual:emdash/sandbox-runner", + () => ({ createSandboxRunner: null, sandboxBypassed: false, sandboxEnabled: false }), + { virtual: true }, +); +vi.mock("virtual:emdash/sandboxed-plugins", () => ({ sandboxedPlugins: [] }), { virtual: true }); +vi.mock("virtual:emdash/storage", () => ({ createStorage: null }), { virtual: true }); +vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true }); +vi.mock("virtual:emdash/scheduler", () => ({ createScheduler: null }), { virtual: true }); + +vi.mock("../../../src/emdash-runtime.js", () => ({ + DB_INIT_DEADLINE_MS: 30_000, + EmDashRuntime: { create: async () => MOCK_RUNTIME }, +})); + +vi.mock("../../../src/loader.js", () => ({ + getDb: vi.fn(async () => ({ + selectFrom: () => ({ selectAll: () => ({ limit: () => ({ execute: async () => [] }) }) }), + })), +})); + +import onRequest from "../../../src/astro/middleware.js"; + +/** + * Stand-in for Astro's `AstroCache`, mirroring the accumulation rules the real + * one applies in `core/cache/runtime/cache.js`: `lastModified` keeps the later + * date, `set(false)` clears accumulated state, and any later `set()` re-enables. + */ +function createCache(enabled = true) { + let disabled = false; + const options: { lastModified?: Date; tags?: string[] } = {}; + return { + enabled, + set(input: { lastModified?: Date; tags?: string[] } | false) { + if (input === false) { + disabled = true; + delete options.lastModified; + delete options.tags; + return; + } + disabled = false; + if ( + input.lastModified && + (!options.lastModified || input.lastModified > options.lastModified) + ) { + options.lastModified = input.lastModified; + } + if (input.tags) options.tags = [...(options.tags ?? []), ...input.tags]; + }, + get disabled() { + return disabled; + }, + get options() { + return options; + }, + }; +} + +type TestCache = ReturnType; + +function anonymousPublicPageContext(cache: TestCache) { + return { + request: new Request("https://example.com/posts/hello"), + url: new URL("https://example.com/posts/hello"), + cookies: { get: vi.fn(() => undefined), set: vi.fn() }, + locals: {} as Record, + redirect: vi.fn(), + isPrerendered: false, + session: { get: vi.fn(async () => null) }, + cache, + } as Record; +} + +/** A page rendering with `Astro.cache.set(cacheHint)`, as the demos do. */ +function pageSetting(cache: TestCache, hint: { lastModified?: Date; tags?: string[] } | false) { + return async () => { + cache.set(hint); + return new Response("", { headers: { "content-type": "text/html" } }); + }; +} + +describe("astro middleware cache validator", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("raises a content-only validator to the build time", async () => { + const cache = createCache(); + const contentModified = new Date(BUILD_TIME - 6 * 60 * 60 * 1000); + + await onRequest( + anonymousPublicPageContext(cache) as Parameters[0], + pageSetting(cache, { lastModified: contentModified, tags: ["posts"] }), + ); + + expect(cache.options.lastModified?.getTime()).toBe(BUILD_TIME); + }); + + it("keeps a content validator newer than the build", async () => { + const cache = createCache(); + const contentModified = new Date(BUILD_TIME + 60 * 60 * 1000); + + await onRequest( + anonymousPublicPageContext(cache) as Parameters[0], + pageSetting(cache, { lastModified: contentModified, tags: ["posts"] }), + ); + + expect(cache.options.lastModified?.getTime()).toBe(contentModified.getTime()); + }); + + it("leaves a route that opts out of caching opted out", async () => { + const cache = createCache(); + + await onRequest( + anonymousPublicPageContext(cache) as Parameters[0], + pageSetting(cache, false), + ); + + expect(cache.disabled).toBe(true); + expect(cache.options.lastModified).toBeUndefined(); + }); + + it("leaves prerendered requests to the host's static layer", async () => { + const cache = createCache(); + const context = anonymousPublicPageContext(cache); + context.isPrerendered = true; + + await onRequest( + context as Parameters[0], + async () => new Response("", { headers: { "content-type": "text/html" } }), + ); + + expect(cache.options.lastModified).toBeUndefined(); + }); + + it("does not touch the cache when no provider is configured", async () => { + const cache = createCache(false); + + await onRequest( + anonymousPublicPageContext(cache) as Parameters[0], + async () => new Response("", { headers: { "content-type": "text/html" } }), + ); + + expect(cache.options.lastModified).toBeUndefined(); + }); +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 437e0f9daf..3fc303a6c6 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -16,6 +16,9 @@ const virtualStubs: Record = { // No Cloudflare bindings under test — like a Node build. Callers fall // back to `import.meta.env`. "virtual:emdash/env": "export const env = undefined;", + // Nothing was built under test, so there is no build dimension to fold + // into cache validators. Tests that need one still `vi.mock(...)`. + "virtual:emdash/build": "export const buildTime = 0;", }; export default defineConfig({ From 2f978c493e60a29939b5e793745af334eeeb8512 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 9 Aug 2026 21:22:21 +0200 Subject: [PATCH 2/4] test(core): drop PR-summary comment from cache validator test Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com> --- .../unit/astro/middleware-cache-validator.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/core/tests/unit/astro/middleware-cache-validator.test.ts b/packages/core/tests/unit/astro/middleware-cache-validator.test.ts index dcb4fc47d0..1d9ecf99fc 100644 --- a/packages/core/tests/unit/astro/middleware-cache-validator.test.ts +++ b/packages/core/tests/unit/astro/middleware-cache-validator.test.ts @@ -1,13 +1,3 @@ -/** - * `CacheHint.lastModified` carries the content's `updated_at`, and Astro emits it - * as the response `Last-Modified`. A deploy that changes only code therefore - * leaves the validator untouched: a returning visitor revalidates, gets 304, and - * keeps HTML referencing `/_astro/*` files the new deployment no longer has — - * 404 on Workers, so the page renders without CSS or JS. - * - * The middleware folds the build timestamp into the validator, so the response - * date reflects the response rather than only the content. - */ import { beforeEach, describe, it, expect, vi } from "vitest"; vi.mock("astro:middleware", () => ({ From 7d8ec2b059dc6b124d56783ffefcb198caf24065 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 10 Aug 2026 21:32:12 +0200 Subject: [PATCH 3/4] test(core): drop the cache validator case that only exercised its mock The suite stands in for Astro's AstroCache with a local `createCache` that reimplements its accumulation rules. `keeps a content validator newer than the build` asserted that the later of the two dates survives, which is a property of that stand-in rather than of anything in this package: removing `applyBuildValidator` entirely leaves the case green, and an upstream switch to last-write-wins would keep it green while production broke. AstroCache is not exported from `astro`, so there is no clean way to drive the real class, and asserting that `set()` received the build date would only restate the call. The remaining four cases each fail on a real regression. --- .../unit/astro/middleware-cache-validator.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/core/tests/unit/astro/middleware-cache-validator.test.ts b/packages/core/tests/unit/astro/middleware-cache-validator.test.ts index 1d9ecf99fc..810d094ba4 100644 --- a/packages/core/tests/unit/astro/middleware-cache-validator.test.ts +++ b/packages/core/tests/unit/astro/middleware-cache-validator.test.ts @@ -143,18 +143,6 @@ describe("astro middleware cache validator", () => { expect(cache.options.lastModified?.getTime()).toBe(BUILD_TIME); }); - it("keeps a content validator newer than the build", async () => { - const cache = createCache(); - const contentModified = new Date(BUILD_TIME + 60 * 60 * 1000); - - await onRequest( - anonymousPublicPageContext(cache) as Parameters[0], - pageSetting(cache, { lastModified: contentModified, tags: ["posts"] }), - ); - - expect(cache.options.lastModified?.getTime()).toBe(contentModified.getTime()); - }); - it("leaves a route that opts out of caching opted out", async () => { const cache = createCache(); From 7af2380dfe553b8f42c5e0247c89d097f4f04c46 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 10 Aug 2026 21:32:12 +0200 Subject: [PATCH 4/4] docs(core): note the rollback limit of the build cache validator A build timestamp only moves forward, while the failure it prevents is symmetric: rolling back to an earlier build leaves a browser holding the newer build's HTML, whose `/_astro/*` names the restored build never had. The conditional request is answered with 304 and the page stays broken. Nothing here fixes that case, and the validator otherwise reads as though it now describes the response completely. Naming the direction keeps the next reader from concluding the case is closed. --- packages/core/src/astro/middleware.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index cf97981e0d..cf01c40fd9 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -526,6 +526,10 @@ const buildDate = virtualBuildTime ? new Date(virtualBuildTime) : null; * Prerendered pages are served by the host's static layer, which manages its * own validators — only on-demand responses need the build dimension. * + * Only forward moves are covered. `Last-Modified` expresses newer, not + * different, so after a rollback the earlier build still answers a conditional + * request with 304 and the browser stays on the newer build's HTML. + * * Must run before next(): Astro keeps the later of two dates, so a route's own * hint still wins when content is newer, and a route that opts out with * `Astro.cache.set(false)` stays opted out — calling set() afterwards would