Skip to content

Commit f80b116

Browse files
bartlomiejuclaude
andcommitted
fix: add immutable cache headers for Vite build assets
Vite-built assets (JS chunks, CSS) were served with no-cache headers because the static files middleware only recognized builder-path assets (under /_fresh/js/{BUILD_ID}/) as immutable. Add an `immutable` flag to the file snapshot interface. The Vite plugin marks manifest chunks and CSS as immutable (content-hashed filenames), while public directory files remain non-immutable. The middleware checks this flag alongside the existing path and query param checks. Closes #3282 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 895bcac commit f80b116

7 files changed

Lines changed: 74 additions & 7 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
@@ -425,6 +425,7 @@ export interface PendingStaticFile {
425425
pathname: string;
426426
filePath: string;
427427
hash: string | null;
428+
immutable?: boolean;
428429
}
429430

430431
export async function writeCompiledEntry(outDir: string) {
@@ -548,7 +549,13 @@ export async function prepareStaticFile(
548549
item: PendingStaticFile,
549550
outDir: string,
550551
): Promise<
551-
{ name: string; hash: string; filePath: string; contentType: string }
552+
{
553+
name: string;
554+
hash: string;
555+
filePath: string;
556+
contentType: string;
557+
immutable?: boolean;
558+
}
552559
> {
553560
const file = await Deno.open(item.filePath);
554561
const hash = item.hash ? item.hash : await hashContent(file.readable);
@@ -563,6 +570,7 @@ export async function prepareStaticFile(
563570
: item.filePath,
564571
),
565572
contentType: getContentType(item.filePath),
573+
immutable: item.immutable,
566574
};
567575
}
568576

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/fresh/src/runtime/server/preact_hooks.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -507,13 +507,13 @@ function RemainingHead() {
507507
if (island.css.length > 0) {
508508
for (let i = 0; i < island.css.length; i++) {
509509
const css = island.css[i];
510-
items.push(h("link", { rel: "stylesheet", href: css }));
510+
items.push(h("link", { rel: "stylesheet", href: asset(css) }));
511511
}
512512
}
513513
});
514514

515515
RENDER_STATE.islandAssets.forEach((css) => {
516-
items.push(h("link", { rel: "stylesheet", href: css }));
516+
items.push(h("link", { rel: "stylesheet", href: asset(css) }));
517517
});
518518

519519
if (items.length > 0) {
@@ -702,7 +702,7 @@ function FreshRuntimeScript() {
702702
const islandSpec = island.file.startsWith(".")
703703
? island.file.slice(1)
704704
: island.file;
705-
return `import ${named} from "${basePath}${islandSpec}";`;
705+
return `import ${named} from "${asset(`${basePath}${islandSpec}`)}";`;
706706
}).join("");
707707

708708
const islandObj = "{" + islandArr.map((island) => island.name)
@@ -717,8 +717,9 @@ function FreshRuntimeScript() {
717717
const runtimeUrl = buildCache.clientEntry.startsWith(".")
718718
? buildCache.clientEntry.slice(1)
719719
: buildCache.clientEntry;
720-
const scriptContent =
721-
`import { boot } from "${basePath}${runtimeUrl}";${islandImports}boot(${islandObj},${serializedProps});`;
720+
const scriptContent = `import { boot } from "${
721+
asset(`${basePath}${runtimeUrl}`)
722+
}";${islandImports}boot(${islandObj},${serializedProps});`;
722723

723724
return (
724725
h(

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ if (import.meta.hot) import.meta.hot.accept();`;
131131
filePath: path.join(serverOutDir, id),
132132
hash: null,
133133
pathname: getAssetPath(id),
134+
immutable: true,
134135
});
135136
}
136137
}
@@ -143,6 +144,7 @@ if (import.meta.hot) import.meta.hot.accept();`;
143144
filePath: path.join(serverOutDir, id),
144145
hash: null,
145146
pathname: getAssetPath(id),
147+
immutable: true,
146148
});
147149
}
148150
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] {
221221
filePath: path.join(clientOutDir, chunk.file),
222222
pathname: chunk.file,
223223
hash: null,
224+
immutable: true,
224225
});
225226

226227
if (chunk.css !== undefined) {
@@ -233,6 +234,7 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] {
233234
filePath: path.join(clientOutDir, id),
234235
hash: null,
235236
pathname,
237+
immutable: true,
236238
});
237239

238240
if (chunk.name === clientEntryName) {

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)