Skip to content

Commit 08766d5

Browse files
danielmlrclaude
andcommitted
fix(core): fold the build into the route cache validator
`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 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3540e33 commit 08766d5

8 files changed

Lines changed: 292 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
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.

packages/core/src/astro/integration/virtual-modules.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ export const RESOLVED_VIRTUAL_SCHEDULER_ID = "\0" + VIRTUAL_SCHEDULER_ID;
7272
export const VIRTUAL_ENV_ID = "virtual:emdash/env";
7373
export const RESOLVED_VIRTUAL_ENV_ID = "\0" + VIRTUAL_ENV_ID;
7474

75+
export const VIRTUAL_BUILD_ID = "virtual:emdash/build";
76+
export const RESOLVED_VIRTUAL_BUILD_ID = "\0" + VIRTUAL_BUILD_ID;
77+
7578
/**
7679
* Generates the config virtual module.
7780
*/
@@ -497,6 +500,19 @@ export function generateEnvModule(adapterName: string | undefined): string {
497500
return `export const env = undefined;`;
498501
}
499502

503+
/**
504+
* Generates the build virtual module.
505+
*
506+
* Content-hashed `/_astro/*` names make the response depend on the build, not
507+
* only on the content. Exposing the build timestamp lets the middleware fold
508+
* that dimension into the cache validator, so a code-only deploy stops
509+
* answering conditional requests with 304 while the assets the cached HTML
510+
* references are already gone.
511+
*/
512+
export function generateBuildModule(buildTime: number): string {
513+
return `export const buildTime = ${buildTime};`;
514+
}
515+
500516
/**
501517
* Generates the scheduler virtual module.
502518
*

packages/core/src/astro/integration/vite-config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,13 @@ import {
4848
RESOLVED_VIRTUAL_SCHEDULER_ID,
4949
VIRTUAL_ENV_ID,
5050
RESOLVED_VIRTUAL_ENV_ID,
51+
VIRTUAL_BUILD_ID,
52+
RESOLVED_VIRTUAL_BUILD_ID,
5153
generateSeedModule,
5254
generateWaitUntilModule,
5355
generateSchedulerModule,
5456
generateEnvModule,
57+
generateBuildModule,
5558
generateConfigModule,
5659
generateDialectModule,
5760
generateStorageModule,
@@ -179,6 +182,11 @@ export function createVirtualModulesPlugin(
179182

180183
let viteCommand: "build" | "serve" | undefined;
181184

185+
// Captured once per plugin instance rather than inside load(): Vite may load
186+
// the module more than once (client and server passes, dev reloads), and a
187+
// validator that moved between those loads would invalidate at random.
188+
const buildTime = Date.now();
189+
182190
return {
183191
name: "emdash-virtual-modules",
184192
configResolved(config) {
@@ -233,6 +241,9 @@ export function createVirtualModulesPlugin(
233241
if (id === VIRTUAL_ENV_ID) {
234242
return RESOLVED_VIRTUAL_ENV_ID;
235243
}
244+
if (id === VIRTUAL_BUILD_ID) {
245+
return RESOLVED_VIRTUAL_BUILD_ID;
246+
}
236247
},
237248
load(id: string) {
238249
if (id === RESOLVED_VIRTUAL_CONFIG_ID) {
@@ -333,6 +344,9 @@ export function createVirtualModulesPlugin(
333344
if (id === RESOLVED_VIRTUAL_ENV_ID) {
334345
return generateEnvModule(astroConfig.adapter?.name);
335346
}
347+
if (id === RESOLVED_VIRTUAL_BUILD_ID) {
348+
return generateBuildModule(buildTime);
349+
}
336350
},
337351
};
338352
}

packages/core/src/astro/middleware.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
* All heavy lifting happens in EmDashRuntime.
66
*/
77

8+
import type { APIContext } from "astro";
89
import { defineMiddleware } from "astro:middleware";
910
import type { Kysely } from "kysely";
1011
// Import from virtual modules (populated by integration at build time)
1112
// @ts-ignore - virtual module
13+
import { buildTime as virtualBuildTime } from "virtual:emdash/build";
14+
// @ts-ignore - virtual module
1215
import virtualConfig from "virtual:emdash/config";
1316
// @ts-ignore - virtual module
1417
import {
@@ -496,6 +499,30 @@ function createRequestScopedDb(
496499
return fn(opts);
497500
}
498501

502+
const buildDate = virtualBuildTime ? new Date(virtualBuildTime) : null;
503+
504+
/**
505+
* Fold the build timestamp into the route cache validator.
506+
*
507+
* `CacheHint.lastModified` describes the content, but the response also depends
508+
* on the build: `/_astro/*` names are content-hashed, and a deployment only
509+
* serves its own. Without the build dimension a code-only deploy answers a
510+
* returning visitor's conditional request with 304, leaving them on HTML whose
511+
* assets 404.
512+
*
513+
* Prerendered pages are served by the host's static layer, which manages its
514+
* own validators — only on-demand responses need the build dimension.
515+
*
516+
* Must run before next(): Astro keeps the later of two dates, so a route's own
517+
* hint still wins when content is newer, and a route that opts out with
518+
* `Astro.cache.set(false)` stays opted out — calling set() afterwards would
519+
* clear that opt-out.
520+
*/
521+
function applyBuildValidator(context: APIContext): void {
522+
if (context.isPrerendered || !buildDate || !context.cache?.enabled) return;
523+
context.cache.set({ lastModified: buildDate });
524+
}
525+
499526
export const onRequest = defineMiddleware(async (context, next) => {
500527
const { request, locals, cookies } = context;
501528
const url = context.url;
@@ -514,6 +541,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
514541
}
515542
}
516543

544+
applyBuildValidator(context);
545+
517546
const queryRecorder = isInstrumentationEnabled()
518547
? createRecorder(url.pathname, request.method, request.headers.get("x-perf-phase") ?? "default")
519548
: undefined;

packages/core/src/virtual-modules.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,16 @@ declare module "virtual:emdash/env" {
170170
export const env: Record<string, unknown> | undefined;
171171
}
172172

173+
declare module "virtual:emdash/build" {
174+
/**
175+
* Epoch milliseconds at which this build's virtual modules were generated.
176+
* Folded into the route cache validator so a code-only deploy — which
177+
* renames `/_astro/*` without touching content — still invalidates HTML a
178+
* browser cached from an earlier deployment.
179+
*/
180+
export const buildTime: number;
181+
}
182+
173183
declare module "virtual:emdash/scheduler" {
174184
import type { CreateSchedulerFn } from "./emdash-runtime.js";
175185
/**

packages/core/tests/unit/astro/integration/virtual-modules.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
generateEnvModule,
1414
generateSchedulerModule,
1515
generateSeedModule,
16+
RESOLVED_VIRTUAL_BUILD_ID,
1617
RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID,
1718
RESOLVED_VIRTUAL_SCHEDULER_ID,
1819
} from "../../../../src/astro/integration/virtual-modules.js";
@@ -185,6 +186,17 @@ describe("createVirtualModulesPlugin scheduler wiring", () => {
185186
expect(out).not.toContain("NodeCronScheduler");
186187
});
187188

189+
it("keeps the build timestamp stable across repeated loads", () => {
190+
const plugin = buildPlugin("@astrojs/cloudflare", "build");
191+
callHook(plugin.configResolved, { command: "build" });
192+
193+
const first = callHook<string>(plugin.load, RESOLVED_VIRTUAL_BUILD_ID);
194+
const second = callHook<string>(plugin.load, RESOLVED_VIRTUAL_BUILD_ID);
195+
196+
expect(first).toBe(second);
197+
expect(Number(/buildTime = (\d+)/.exec(first)?.[1])).toBeGreaterThan(0);
198+
});
199+
188200
it("watches resolved sandbox plugin entries", () => {
189201
const projectRoot = mkdtempSync(join(tmpdir(), "emdash-sandbox-watch-test-"));
190202
try {
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* `CacheHint.lastModified` carries the content's `updated_at`, and Astro emits it
3+
* as the response `Last-Modified`. A deploy that changes only code therefore
4+
* leaves the validator untouched: a returning visitor revalidates, gets 304, and
5+
* keeps HTML referencing `/_astro/*` files the new deployment no longer has —
6+
* 404 on Workers, so the page renders without CSS or JS.
7+
*
8+
* The middleware folds the build timestamp into the validator, so the response
9+
* date reflects the response rather than only the content.
10+
*/
11+
import { beforeEach, describe, it, expect, vi } from "vitest";
12+
13+
vi.mock("astro:middleware", () => ({
14+
defineMiddleware: (handler: unknown) => handler,
15+
}));
16+
17+
const { BUILD_TIME, MOCK_RUNTIME } = vi.hoisted(() => {
18+
const ok = async () => ({ success: true });
19+
return {
20+
BUILD_TIME: Date.parse("2026-08-07T22:26:49.000Z"),
21+
MOCK_RUNTIME: {
22+
storage: { getPublicUrl: vi.fn((key: string) => `https://media.example.com/${key}`) },
23+
db: {},
24+
hooks: {},
25+
email: null,
26+
configuredPlugins: [],
27+
getPluginRouteMeta: () => null,
28+
handlePluginApiRoute: async () => ({ success: true }),
29+
getMediaProvider: () => undefined,
30+
getMediaProviderList: () => [],
31+
collectPageMetadata: async () => [],
32+
collectPageFragments: async () => [],
33+
ensureSearchHealthy: async () => undefined,
34+
getManifest: async () => ({}),
35+
getSandboxRunner: () => null,
36+
isSandboxBypassed: () => false,
37+
syncMarketplacePlugins: async () => undefined,
38+
syncRegistryPlugins: async () => undefined,
39+
setPluginStatus: async () => undefined,
40+
handleContentList: ok,
41+
},
42+
};
43+
});
44+
45+
vi.mock("virtual:emdash/build", () => ({ buildTime: BUILD_TIME }), { virtual: true });
46+
vi.mock(
47+
"virtual:emdash/config",
48+
() => ({ default: { database: { config: { binding: "DB" } }, auth: { mode: "none" } } }),
49+
{ virtual: true },
50+
);
51+
vi.mock(
52+
"virtual:emdash/dialect",
53+
() => ({ createDialect: vi.fn(), createRequestScopedDb: vi.fn().mockReturnValue(null) }),
54+
{ virtual: true },
55+
);
56+
vi.mock("virtual:emdash/media-providers", () => ({ mediaProviders: [] }), { virtual: true });
57+
vi.mock("virtual:emdash/plugins", () => ({ plugins: [] }), { virtual: true });
58+
vi.mock(
59+
"virtual:emdash/sandbox-runner",
60+
() => ({ createSandboxRunner: null, sandboxBypassed: false, sandboxEnabled: false }),
61+
{ virtual: true },
62+
);
63+
vi.mock("virtual:emdash/sandboxed-plugins", () => ({ sandboxedPlugins: [] }), { virtual: true });
64+
vi.mock("virtual:emdash/storage", () => ({ createStorage: null }), { virtual: true });
65+
vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true });
66+
vi.mock("virtual:emdash/scheduler", () => ({ createScheduler: null }), { virtual: true });
67+
68+
vi.mock("../../../src/emdash-runtime.js", () => ({
69+
DB_INIT_DEADLINE_MS: 30_000,
70+
EmDashRuntime: { create: async () => MOCK_RUNTIME },
71+
}));
72+
73+
vi.mock("../../../src/loader.js", () => ({
74+
getDb: vi.fn(async () => ({
75+
selectFrom: () => ({ selectAll: () => ({ limit: () => ({ execute: async () => [] }) }) }),
76+
})),
77+
}));
78+
79+
import onRequest from "../../../src/astro/middleware.js";
80+
81+
/**
82+
* Stand-in for Astro's `AstroCache`, mirroring the accumulation rules the real
83+
* one applies in `core/cache/runtime/cache.js`: `lastModified` keeps the later
84+
* date, `set(false)` clears accumulated state, and any later `set()` re-enables.
85+
*/
86+
function createCache(enabled = true) {
87+
let disabled = false;
88+
const options: { lastModified?: Date; tags?: string[] } = {};
89+
return {
90+
enabled,
91+
set(input: { lastModified?: Date; tags?: string[] } | false) {
92+
if (input === false) {
93+
disabled = true;
94+
delete options.lastModified;
95+
delete options.tags;
96+
return;
97+
}
98+
disabled = false;
99+
if (
100+
input.lastModified &&
101+
(!options.lastModified || input.lastModified > options.lastModified)
102+
) {
103+
options.lastModified = input.lastModified;
104+
}
105+
if (input.tags) options.tags = [...(options.tags ?? []), ...input.tags];
106+
},
107+
get disabled() {
108+
return disabled;
109+
},
110+
get options() {
111+
return options;
112+
},
113+
};
114+
}
115+
116+
type TestCache = ReturnType<typeof createCache>;
117+
118+
function anonymousPublicPageContext(cache: TestCache) {
119+
return {
120+
request: new Request("https://example.com/posts/hello"),
121+
url: new URL("https://example.com/posts/hello"),
122+
cookies: { get: vi.fn(() => undefined), set: vi.fn() },
123+
locals: {} as Record<string, unknown>,
124+
redirect: vi.fn(),
125+
isPrerendered: false,
126+
session: { get: vi.fn(async () => null) },
127+
cache,
128+
} as Record<string, unknown>;
129+
}
130+
131+
/** A page rendering with `Astro.cache.set(cacheHint)`, as the demos do. */
132+
function pageSetting(cache: TestCache, hint: { lastModified?: Date; tags?: string[] } | false) {
133+
return async () => {
134+
cache.set(hint);
135+
return new Response("<html></html>", { headers: { "content-type": "text/html" } });
136+
};
137+
}
138+
139+
describe("astro middleware cache validator", () => {
140+
beforeEach(() => {
141+
vi.clearAllMocks();
142+
});
143+
144+
it("raises a content-only validator to the build time", async () => {
145+
const cache = createCache();
146+
const contentModified = new Date(BUILD_TIME - 6 * 60 * 60 * 1000);
147+
148+
await onRequest(
149+
anonymousPublicPageContext(cache) as Parameters<typeof onRequest>[0],
150+
pageSetting(cache, { lastModified: contentModified, tags: ["posts"] }),
151+
);
152+
153+
expect(cache.options.lastModified?.getTime()).toBe(BUILD_TIME);
154+
});
155+
156+
it("keeps a content validator newer than the build", async () => {
157+
const cache = createCache();
158+
const contentModified = new Date(BUILD_TIME + 60 * 60 * 1000);
159+
160+
await onRequest(
161+
anonymousPublicPageContext(cache) as Parameters<typeof onRequest>[0],
162+
pageSetting(cache, { lastModified: contentModified, tags: ["posts"] }),
163+
);
164+
165+
expect(cache.options.lastModified?.getTime()).toBe(contentModified.getTime());
166+
});
167+
168+
it("leaves a route that opts out of caching opted out", async () => {
169+
const cache = createCache();
170+
171+
await onRequest(
172+
anonymousPublicPageContext(cache) as Parameters<typeof onRequest>[0],
173+
pageSetting(cache, false),
174+
);
175+
176+
expect(cache.disabled).toBe(true);
177+
expect(cache.options.lastModified).toBeUndefined();
178+
});
179+
180+
it("leaves prerendered requests to the host's static layer", async () => {
181+
const cache = createCache();
182+
const context = anonymousPublicPageContext(cache);
183+
context.isPrerendered = true;
184+
185+
await onRequest(
186+
context as Parameters<typeof onRequest>[0],
187+
async () => new Response("<html></html>", { headers: { "content-type": "text/html" } }),
188+
);
189+
190+
expect(cache.options.lastModified).toBeUndefined();
191+
});
192+
193+
it("does not touch the cache when no provider is configured", async () => {
194+
const cache = createCache(false);
195+
196+
await onRequest(
197+
anonymousPublicPageContext(cache) as Parameters<typeof onRequest>[0],
198+
async () => new Response("<html></html>", { headers: { "content-type": "text/html" } }),
199+
);
200+
201+
expect(cache.options.lastModified).toBeUndefined();
202+
});
203+
});

packages/core/vitest.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const virtualStubs: Record<string, string> = {
1616
// No Cloudflare bindings under test — like a Node build. Callers fall
1717
// back to `import.meta.env`.
1818
"virtual:emdash/env": "export const env = undefined;",
19+
// Nothing was built under test, so there is no build dimension to fold
20+
// into cache validators. Tests that need one still `vi.mock(...)`.
21+
"virtual:emdash/build": "export const buildTime = 0;",
1922
};
2023

2124
export default defineConfig({

0 commit comments

Comments
 (0)