From f80b1162d5c0ecda7d42458685dc050b138e9d1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 9 Apr 2026 10:09:21 +0200 Subject: [PATCH 1/5] 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) --- packages/fresh/src/build_cache.ts | 3 ++ packages/fresh/src/dev/dev_build_cache.ts | 10 +++- .../fresh/src/middlewares/static_files.ts | 3 +- .../fresh/src/runtime/server/preact_hooks.ts | 11 ++-- .../plugin-vite/src/plugins/server_entry.ts | 2 + .../src/plugins/server_snapshot.ts | 2 + packages/plugin-vite/tests/build_test.ts | 50 +++++++++++++++++++ 7 files changed, 74 insertions(+), 7 deletions(-) diff --git a/packages/fresh/src/build_cache.ts b/packages/fresh/src/build_cache.ts index e99f0ebc628..6a8161a2b77 100644 --- a/packages/fresh/src/build_cache.ts +++ b/packages/fresh/src/build_cache.ts @@ -11,6 +11,7 @@ export interface FileSnapshot { filePath: string; hash: string | null; contentType: string; + immutable?: boolean; } export interface BuildSnapshot { @@ -28,6 +29,7 @@ export interface StaticFile { contentType: string; readable: ReadableStream | Uint8Array; close(): void; + immutable?: boolean; } // deno-lint-ignore no-explicit-any @@ -87,6 +89,7 @@ export class ProdBuildCache implements BuildCache { size: stat.size, readable: file.readable, close: () => file.close(), + immutable: info.immutable, }; } } diff --git a/packages/fresh/src/dev/dev_build_cache.ts b/packages/fresh/src/dev/dev_build_cache.ts index 220353b28b2..3081614e3ee 100644 --- a/packages/fresh/src/dev/dev_build_cache.ts +++ b/packages/fresh/src/dev/dev_build_cache.ts @@ -425,6 +425,7 @@ export interface PendingStaticFile { pathname: string; filePath: string; hash: string | null; + immutable?: boolean; } export async function writeCompiledEntry(outDir: string) { @@ -548,7 +549,13 @@ export async function prepareStaticFile( item: PendingStaticFile, outDir: string, ): Promise< - { name: string; hash: string; filePath: string; contentType: string } + { + name: string; + hash: string; + filePath: string; + contentType: string; + immutable?: boolean; + } > { const file = await Deno.open(item.filePath); const hash = item.hash ? item.hash : await hashContent(file.readable); @@ -563,6 +570,7 @@ export async function prepareStaticFile( : item.filePath, ), contentType: getContentType(item.filePath), + immutable: item.immutable, }; } diff --git a/packages/fresh/src/middlewares/static_files.ts b/packages/fresh/src/middlewares/static_files.ts index fce149aa154..ee06b0f7e3f 100644 --- a/packages/fresh/src/middlewares/static_files.ts +++ b/packages/fresh/src/middlewares/static_files.ts @@ -89,7 +89,8 @@ export function staticFiles(): Middleware { (BUILD_ID === cacheKey || url.pathname.startsWith( `${ctx.config.basePath}/_fresh/js/${BUILD_ID}/`, - )) + ) || + file.immutable) ) { span.setAttribute("fresh.cache", "immutable"); headers.append("Cache-Control", "public, max-age=31536000, immutable"); diff --git a/packages/fresh/src/runtime/server/preact_hooks.ts b/packages/fresh/src/runtime/server/preact_hooks.ts index b7ad8f8e733..69ba082d7fb 100644 --- a/packages/fresh/src/runtime/server/preact_hooks.ts +++ b/packages/fresh/src/runtime/server/preact_hooks.ts @@ -507,13 +507,13 @@ function RemainingHead() { if (island.css.length > 0) { for (let i = 0; i < island.css.length; i++) { const css = island.css[i]; - items.push(h("link", { rel: "stylesheet", href: css })); + items.push(h("link", { rel: "stylesheet", href: asset(css) })); } } }); RENDER_STATE.islandAssets.forEach((css) => { - items.push(h("link", { rel: "stylesheet", href: css })); + items.push(h("link", { rel: "stylesheet", href: asset(css) })); }); if (items.length > 0) { @@ -702,7 +702,7 @@ function FreshRuntimeScript() { const islandSpec = island.file.startsWith(".") ? island.file.slice(1) : island.file; - return `import ${named} from "${basePath}${islandSpec}";`; + return `import ${named} from "${asset(`${basePath}${islandSpec}`)}";`; }).join(""); const islandObj = "{" + islandArr.map((island) => island.name) @@ -717,8 +717,9 @@ function FreshRuntimeScript() { const runtimeUrl = buildCache.clientEntry.startsWith(".") ? buildCache.clientEntry.slice(1) : buildCache.clientEntry; - const scriptContent = - `import { boot } from "${basePath}${runtimeUrl}";${islandImports}boot(${islandObj},${serializedProps});`; + const scriptContent = `import { boot } from "${ + asset(`${basePath}${runtimeUrl}`) + }";${islandImports}boot(${islandObj},${serializedProps});`; return ( h( diff --git a/packages/plugin-vite/src/plugins/server_entry.ts b/packages/plugin-vite/src/plugins/server_entry.ts index 200c4ee28d7..3bd769e2e97 100644 --- a/packages/plugin-vite/src/plugins/server_entry.ts +++ b/packages/plugin-vite/src/plugins/server_entry.ts @@ -131,6 +131,7 @@ if (import.meta.hot) import.meta.hot.accept();`; filePath: path.join(serverOutDir, id), hash: null, pathname: getAssetPath(id), + immutable: true, }); } } @@ -143,6 +144,7 @@ if (import.meta.hot) import.meta.hot.accept();`; filePath: path.join(serverOutDir, id), hash: null, pathname: getAssetPath(id), + immutable: true, }); } } diff --git a/packages/plugin-vite/src/plugins/server_snapshot.ts b/packages/plugin-vite/src/plugins/server_snapshot.ts index 1cc1042c66d..04060e737f3 100644 --- a/packages/plugin-vite/src/plugins/server_snapshot.ts +++ b/packages/plugin-vite/src/plugins/server_snapshot.ts @@ -221,6 +221,7 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { filePath: path.join(clientOutDir, chunk.file), pathname: chunk.file, hash: null, + immutable: true, }); if (chunk.css !== undefined) { @@ -233,6 +234,7 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { filePath: path.join(clientOutDir, id), hash: null, pathname, + immutable: true, }); if (chunk.name === clientEntryName) { diff --git a/packages/plugin-vite/tests/build_test.ts b/packages/plugin-vite/tests/build_test.ts index ef08df5ebaf..6fc9ec823b6 100644 --- a/packages/plugin-vite/tests/build_test.ts +++ b/packages/plugin-vite/tests/build_test.ts @@ -606,3 +606,53 @@ integrationTest( ); }, ); + +integrationTest( + "vite build - asset cache headers on CSS and JS", + async () => { + await launchProd( + { cwd: viteResult.tmp }, + async (address) => { + // Fetch a page with islands to get CSS and JS asset URLs + const res = await fetch(`${address}/tests/island_hooks`); + const html = await res.text(); + + // CSS link tags should get immutable cache headers + const cssMatches = html.matchAll( + /href="(\/assets\/[^"]*\.css[^"]*)"/g, + ); + for (const match of cssMatches) { + const href = match[1]; + const cssRes = await fetch(`${address}${href}`); + await cssRes.body?.cancel(); + expect(cssRes.status).toEqual(200); + expect(cssRes.headers.get("Cache-Control")).toEqual( + "public, max-age=31536000, immutable", + ); + } + + // JS module imports should get immutable cache headers + const scriptMatch = html.match( + /]*type="module"[^>]*>([\s\S]*?)<\/script>/, + ); + expect(scriptMatch).not.toBeNull(); + const scriptContent = scriptMatch![1]; + + const importMatches = scriptContent.matchAll( + /from "([^"]+)"/g, + ); + for (const match of importMatches) { + const url = match[1]; + if (url.startsWith("/assets/") || url.includes("/assets/")) { + const jsRes = await fetch(`${address}${url}`); + await jsRes.body?.cancel(); + expect(jsRes.status).toEqual(200); + expect(jsRes.headers.get("Cache-Control")).toEqual( + "public, max-age=31536000, immutable", + ); + } + } + }, + ); + }, +); From f6ad1bd86f64d6d62c1ba20449b511b1d52ce9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 9 Apr 2026 13:02:19 +0200 Subject: [PATCH 2/5] ci: trigger re-run Co-Authored-By: Claude Opus 4.6 (1M context) From d00b871f2252f4f3144d3b669a9e74b3caacd63c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 9 Apr 2026 14:21:09 +0200 Subject: [PATCH 3/5] fix: revert asset() wrapping on import URLs from first approach The preact_hooks.ts changes from the initial URL-based approach were accidentally left in the commit. These added ?__frsh_c query params to JS module imports which broke builder-path tests. The immutable flag approach doesn't need any changes to import URLs. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/runtime/server/preact_hooks.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/fresh/src/runtime/server/preact_hooks.ts b/packages/fresh/src/runtime/server/preact_hooks.ts index 69ba082d7fb..b7ad8f8e733 100644 --- a/packages/fresh/src/runtime/server/preact_hooks.ts +++ b/packages/fresh/src/runtime/server/preact_hooks.ts @@ -507,13 +507,13 @@ function RemainingHead() { if (island.css.length > 0) { for (let i = 0; i < island.css.length; i++) { const css = island.css[i]; - items.push(h("link", { rel: "stylesheet", href: asset(css) })); + items.push(h("link", { rel: "stylesheet", href: css })); } } }); RENDER_STATE.islandAssets.forEach((css) => { - items.push(h("link", { rel: "stylesheet", href: asset(css) })); + items.push(h("link", { rel: "stylesheet", href: css })); }); if (items.length > 0) { @@ -702,7 +702,7 @@ function FreshRuntimeScript() { const islandSpec = island.file.startsWith(".") ? island.file.slice(1) : island.file; - return `import ${named} from "${asset(`${basePath}${islandSpec}`)}";`; + return `import ${named} from "${basePath}${islandSpec}";`; }).join(""); const islandObj = "{" + islandArr.map((island) => island.name) @@ -717,9 +717,8 @@ function FreshRuntimeScript() { const runtimeUrl = buildCache.clientEntry.startsWith(".") ? buildCache.clientEntry.slice(1) : buildCache.clientEntry; - const scriptContent = `import { boot } from "${ - asset(`${basePath}${runtimeUrl}`) - }";${islandImports}boot(${islandObj},${serializedProps});`; + const scriptContent = + `import { boot } from "${basePath}${runtimeUrl}";${islandImports}boot(${islandObj},${serializedProps});`; return ( h( From fc86925ab07d41ea9f5a89dea99386aa0e3956cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 9 Apr 2026 14:45:21 +0200 Subject: [PATCH 4/5] fix: preserve hash and immutable in registerStaticFile The registerStaticFile function in server_entry.ts was dropping the hash and immutable fields when adding files to the snapshot's staticFiles Map. On Windows, this caused server-entry assets to overwrite snapshot entries, losing the immutable flag. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/plugin-vite/src/plugins/server_entry.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/plugin-vite/src/plugins/server_entry.ts b/packages/plugin-vite/src/plugins/server_entry.ts index 3bd769e2e97..d4b1dec8726 100644 --- a/packages/plugin-vite/src/plugins/server_entry.ts +++ b/packages/plugin-vite/src/plugins/server_entry.ts @@ -83,7 +83,9 @@ export function registerStaticFile(prepared) { snapshot.staticFiles.set(prepared.name, { name: prepared.name, contentType: prepared.contentType, - filePath: prepared.filePath + filePath: prepared.filePath, + hash: prepared.hash ?? null, + immutable: prepared.immutable, }); } `; From b8ccaea011eb9c0f703f3e89d1b5b67aefce3e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 9 Apr 2026 15:06:25 +0200 Subject: [PATCH 5/5] fix: normalize path separators in extra files scan on Windows On Windows, path.relative returns backslashes but the registeredPaths Set uses forward slashes from Vite's manifest. This caused every file to be re-registered without the immutable flag, overwriting the original entries from the manifest scan. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/plugin-vite/src/plugins/server_snapshot.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugin-vite/src/plugins/server_snapshot.ts b/packages/plugin-vite/src/plugins/server_snapshot.ts index 04060e737f3..6eca6bc40b7 100644 --- a/packages/plugin-vite/src/plugins/server_snapshot.ts +++ b/packages/plugin-vite/src/plugins/server_snapshot.ts @@ -343,7 +343,8 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { ); for await (const entry of clientFiles) { - const relative = path.relative(clientOutDir, entry.path); + const relative = path.relative(clientOutDir, entry.path) + .replaceAll("\\", "/"); // Skip .vite directory and already-registered files if (