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/plugin-vite/src/plugins/server_entry.ts b/packages/plugin-vite/src/plugins/server_entry.ts index 200c4ee28d7..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, }); } `; @@ -131,6 +133,7 @@ if (import.meta.hot) import.meta.hot.accept();`; filePath: path.join(serverOutDir, id), hash: null, pathname: getAssetPath(id), + immutable: true, }); } } @@ -143,6 +146,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..6eca6bc40b7 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) { @@ -341,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 ( 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", + ); + } + } + }, + ); + }, +);