Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/cache-validator-build-dimension.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions packages/core/src/astro/integration/virtual-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/astro/integration/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
},
};
}
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/virtual-modules.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,16 @@ declare module "virtual:emdash/env" {
export const env: Record<string, unknown> | 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";
/**
Expand Down
12 changes: 12 additions & 0 deletions packages/core/tests/unit/astro/integration/virtual-modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>(plugin.load, RESOLVED_VIRTUAL_BUILD_ID);
const second = callHook<string>(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 {
Expand Down
181 changes: 181 additions & 0 deletions packages/core/tests/unit/astro/middleware-cache-validator.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createCache>;

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<string, unknown>,
redirect: vi.fn(),
isPrerendered: false,
session: { get: vi.fn(async () => null) },
cache,
} as Record<string, unknown>;
}

/** 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("<html></html>", { 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<typeof onRequest>[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<typeof onRequest>[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<typeof onRequest>[0],
async () => new Response("<html></html>", { 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<typeof onRequest>[0],
async () => new Response("<html></html>", { headers: { "content-type": "text/html" } }),
);

expect(cache.options.lastModified).toBeUndefined();
});
});
3 changes: 3 additions & 0 deletions packages/core/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const virtualStubs: Record<string, string> = {
// 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({
Expand Down
Loading