Skip to content

Commit 14f8955

Browse files
bartlomiejuclaude
andauthored
fix: add cache-bust query param to Vite asset URLs for immutable caching (#3761)
## Summary - Vite-built assets (JS chunks, CSS) were served with `Cache-Control: no-cache, no-store` because the static files middleware only recognized builder-path assets (under `/_fresh/js/{BUILD_ID}/`) as immutable - Adds an `immutable` flag to `FileSnapshot`, `StaticFile`, and `PendingStaticFile` interfaces - The Vite plugin marks manifest chunks and CSS as `immutable: true` (these have content-hashed filenames), while public directory files remain non-immutable - The static files middleware checks `file.immutable` alongside the existing path and query param checks Closes #3282 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f39a198 commit 14f8955

6 files changed

Lines changed: 73 additions & 4 deletions

File tree

packages/fresh/src/build_cache.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface FileSnapshot {
1111
filePath: string;
1212
hash: string | null;
1313
contentType: string;
14+
immutable?: boolean;
1415
}
1516

1617
export interface BuildSnapshot<State> {
@@ -28,6 +29,7 @@ export interface StaticFile {
2829
contentType: string;
2930
readable: ReadableStream<Uint8Array> | Uint8Array;
3031
close(): void;
32+
immutable?: boolean;
3133
}
3234

3335
// deno-lint-ignore no-explicit-any
@@ -87,6 +89,7 @@ export class ProdBuildCache<State> implements BuildCache<State> {
8789
size: stat.size,
8890
readable: file.readable,
8991
close: () => file.close(),
92+
immutable: info.immutable,
9093
};
9194
}
9295
}

packages/fresh/src/dev/dev_build_cache.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ export interface PendingStaticFile {
438438
pathname: string;
439439
filePath: string;
440440
hash: string | null;
441+
immutable?: boolean;
441442
}
442443

443444
export async function writeCompiledEntry(outDir: string) {
@@ -561,7 +562,13 @@ export async function prepareStaticFile(
561562
item: PendingStaticFile,
562563
outDir: string,
563564
): Promise<
564-
{ name: string; hash: string; filePath: string; contentType: string }
565+
{
566+
name: string;
567+
hash: string;
568+
filePath: string;
569+
contentType: string;
570+
immutable?: boolean;
571+
}
565572
> {
566573
const file = await Deno.open(item.filePath);
567574
const hash = item.hash ? item.hash : await hashContent(file.readable);
@@ -576,6 +583,7 @@ export async function prepareStaticFile(
576583
: item.filePath,
577584
),
578585
contentType: getContentType(item.filePath),
586+
immutable: item.immutable,
579587
};
580588
}
581589

packages/fresh/src/middlewares/static_files.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ export function staticFiles<T>(): Middleware<T> {
8989
(BUILD_ID === cacheKey ||
9090
url.pathname.startsWith(
9191
`${ctx.config.basePath}/_fresh/js/${BUILD_ID}/`,
92-
))
92+
) ||
93+
file.immutable)
9394
) {
9495
span.setAttribute("fresh.cache", "immutable");
9596
headers.append("Cache-Control", "public, max-age=31536000, immutable");

packages/plugin-vite/src/plugins/server_entry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ export function registerStaticFile(prepared) {
8383
snapshot.staticFiles.set(prepared.name, {
8484
name: prepared.name,
8585
contentType: prepared.contentType,
86-
filePath: prepared.filePath
86+
filePath: prepared.filePath,
87+
hash: prepared.hash ?? null,
88+
immutable: prepared.immutable,
8789
});
8890
}
8991
`;
@@ -131,6 +133,7 @@ if (import.meta.hot) import.meta.hot.accept();`;
131133
filePath: path.join(serverOutDir, id),
132134
hash: null,
133135
pathname: getAssetPath(id),
136+
immutable: true,
134137
});
135138
}
136139
}
@@ -143,6 +146,7 @@ if (import.meta.hot) import.meta.hot.accept();`;
143146
filePath: path.join(serverOutDir, id),
144147
hash: null,
145148
pathname: getAssetPath(id),
149+
immutable: true,
146150
});
147151
}
148152
}

packages/plugin-vite/src/plugins/server_snapshot.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] {
219219
filePath: path.join(clientOutDir, chunk.file),
220220
pathname: chunk.file,
221221
hash: null,
222+
immutable: true,
222223
});
223224

224225
if (chunk.css !== undefined) {
@@ -231,6 +232,7 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] {
231232
filePath: path.join(clientOutDir, id),
232233
hash: null,
233234
pathname,
235+
immutable: true,
234236
});
235237

236238
if (chunk.name === clientEntryName) {
@@ -352,7 +354,8 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] {
352354
);
353355

354356
for await (const entry of clientFiles) {
355-
const relative = path.relative(clientOutDir, entry.path);
357+
const relative = path.relative(clientOutDir, entry.path)
358+
.replaceAll("\\", "/");
356359

357360
// Skip .vite directory and already-registered files
358361
if (

packages/plugin-vite/tests/build_test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,3 +606,53 @@ integrationTest(
606606
);
607607
},
608608
);
609+
610+
integrationTest(
611+
"vite build - asset cache headers on CSS and JS",
612+
async () => {
613+
await launchProd(
614+
{ cwd: viteResult.tmp },
615+
async (address) => {
616+
// Fetch a page with islands to get CSS and JS asset URLs
617+
const res = await fetch(`${address}/tests/island_hooks`);
618+
const html = await res.text();
619+
620+
// CSS link tags should get immutable cache headers
621+
const cssMatches = html.matchAll(
622+
/href="(\/assets\/[^"]*\.css[^"]*)"/g,
623+
);
624+
for (const match of cssMatches) {
625+
const href = match[1];
626+
const cssRes = await fetch(`${address}${href}`);
627+
await cssRes.body?.cancel();
628+
expect(cssRes.status).toEqual(200);
629+
expect(cssRes.headers.get("Cache-Control")).toEqual(
630+
"public, max-age=31536000, immutable",
631+
);
632+
}
633+
634+
// JS module imports should get immutable cache headers
635+
const scriptMatch = html.match(
636+
/<script[^>]*type="module"[^>]*>([\s\S]*?)<\/script>/,
637+
);
638+
expect(scriptMatch).not.toBeNull();
639+
const scriptContent = scriptMatch![1];
640+
641+
const importMatches = scriptContent.matchAll(
642+
/from "([^"]+)"/g,
643+
);
644+
for (const match of importMatches) {
645+
const url = match[1];
646+
if (url.startsWith("/assets/") || url.includes("/assets/")) {
647+
const jsRes = await fetch(`${address}${url}`);
648+
await jsRes.body?.cancel();
649+
expect(jsRes.status).toEqual(200);
650+
expect(jsRes.headers.get("Cache-Control")).toEqual(
651+
"public, max-age=31536000, immutable",
652+
);
653+
}
654+
}
655+
},
656+
);
657+
},
658+
);

0 commit comments

Comments
 (0)