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 018455ff67..cf01c40fd9 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 { @@ -509,6 +512,34 @@ 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. + * + * 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 + * 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; @@ -527,6 +558,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 700f9f815f..a4057b13c2 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -178,6 +178,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..810d094ba4 --- /dev/null +++ b/packages/core/tests/unit/astro/middleware-cache-validator.test.ts @@ -0,0 +1,181 @@ +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("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({