diff --git a/docs/latest/advanced/builder.md b/docs/latest/advanced/builder.md index 2170343568f..34007b593c2 100644 --- a/docs/latest/advanced/builder.md +++ b/docs/latest/advanced/builder.md @@ -48,8 +48,12 @@ const builder = new Builder({ islandDir?: string; // Path to routes directory. (Default: `/routes`) routeDir?: string; - // File paths which should be ignored + // File paths which should be ignored ignore?: RegExp[]; + // Glob patterns for static files that should use content-hash + // caching instead of build-ID. Useful for large assets like WASM + // that rarely change between deploys. + contentAddressedStatic?: string[]; // Optionally generate production source maps // See https://esbuild.github.io/api/#source-maps sourceMap?: { diff --git a/docs/latest/concepts/static-files.md b/docs/latest/concepts/static-files.md index 4b587444e98..be172a8e4df 100644 --- a/docs/latest/concepts/static-files.md +++ b/docs/latest/concepts/static-files.md @@ -122,6 +122,45 @@ export default function Gallery() { } ``` +## Content-addressed static files + +By default, `asset()` appends a cache-bust key based on the current build ID. +This means **every deploy invalidates every cached static file**, even if the +file content hasn't changed. For small files this is fine, but for large assets +like WASM binaries, fonts, or media files, re-downloading unchanged files on +every deploy is wasteful. + +The `contentAddressedStatic` option lets you specify glob patterns for files +that should use their **content hash** as the cache-bust key instead of the +build ID. The URL only changes when the file content changes — surviving deploys +unchanged. + +```ts vite.config.ts +import { defineConfig } from "vite"; +import { fresh } from "@fresh/plugin-vite"; + +export default defineConfig({ + plugins: [ + fresh({ + contentAddressedStatic: ["**/*.wasm", "**/*.bin"], + }), + ], +}); +``` + +> [info]: If you're using the [Builder](/docs/advanced/builder) API, the same +> option is available on the `Builder` constructor. + +With this config, `asset("/module.wasm")` produces a URL like +`/module.wasm?__frsh_c=` instead of +`/module.wasm?__frsh_c=`. The middleware serves it with a one-year +immutable cache header. On the next deploy, if the file hasn't changed, the +content hash (and therefore the URL) stays the same — the browser uses its +cache. + +> [info]: The content hash is computed at build time from the file contents +> using SHA-256. It's the same hash Fresh already computes for ETag headers. + ## Image optimization Fresh does not include a built-in image optimization pipeline, but since Fresh 2 diff --git a/packages/fresh/src/build_cache.ts b/packages/fresh/src/build_cache.ts index 6a8161a2b77..4a0da82f3c1 100644 --- a/packages/fresh/src/build_cache.ts +++ b/packages/fresh/src/build_cache.ts @@ -5,6 +5,7 @@ import type { ServerIslandRegistry } from "./context.ts"; import type { AnyComponent } from "preact"; import { UniqueNamer } from "./utils.ts"; import { setBuildId } from "@fresh/build-id"; +import { setContentHashMap } from "./runtime/shared_internal.ts"; export interface FileSnapshot { name: string; @@ -58,6 +59,18 @@ export class ProdBuildCache implements BuildCache { this.#snapshot = snapshot; this.islandRegistry = snapshot.islands; this.clientEntry = snapshot.clientEntry; + + // Populate content hash registry so asset() uses content hashes + // for content-addressed files instead of BUILD_ID. + const hashMap = new Map(); + for (const [pathname, file] of snapshot.staticFiles) { + if (file.immutable && file.hash) { + hashMap.set(pathname, file.hash); + } + } + if (hashMap.size > 0) { + setContentHashMap(hashMap); + } } getEntryAssets(): string[] { diff --git a/packages/fresh/src/dev/builder.ts b/packages/fresh/src/dev/builder.ts index c78ec430fae..746251ef230 100644 --- a/packages/fresh/src/dev/builder.ts +++ b/packages/fresh/src/dev/builder.ts @@ -95,6 +95,16 @@ export interface BuildOptions { */ ignore?: RegExp[]; + /** + * Glob patterns for static files that should use content-hash caching + * instead of BUILD_ID. When a file matches, `asset()` uses its content + * hash as the cache-bust key so the URL only changes when the file + * content changes — surviving deploys unchanged. + * + * @example ["**\/*.wasm", "**\/*.bin"] + */ + contentAddressedStatic?: string[]; + /** * Control if/how production source maps should be handled. * See https://esbuild.github.io/api/#source-maps for more information. @@ -106,13 +116,16 @@ export interface BuildOptions { * The final resolved Builder configuration. */ export type ResolvedBuildConfig = - & Required> + & Required< + Omit + > & { /** Always normalized to an array of absolute paths. */ staticDir: string[]; mode: "development" | "production"; buildId: string; sourceMap?: FreshBundleOptions["sourceMap"]; + contentAddressedStatic: string[]; }; // deno-lint-ignore no-explicit-any @@ -151,6 +164,7 @@ export class Builder { mode: "production", buildId: BUILD_ID, sourceMap: options?.sourceMap, + contentAddressedStatic: options?.contentAddressedStatic ?? [], }; } @@ -376,9 +390,14 @@ export class Builder { } } + const contentAddressedPrefix = "/_fresh/js/c/"; for (let i = 0; i < output.files.length; i++) { const file = output.files[i]; - const pathname = `${prefix}${file.path}`; + // Content-hashed chunks/assets are placed outside the BUILD_ID + // directory so their URLs survive across deploys unchanged. + const pathname = file.path.startsWith("../c/") + ? `${contentAddressedPrefix}${file.path.slice("../c/".length)}` + : `${prefix}${file.path}`; await buildCache.addProcessedFile(pathname, file.contents, file.hash); } diff --git a/packages/fresh/src/dev/dev_build_cache.ts b/packages/fresh/src/dev/dev_build_cache.ts index dd985551113..5dd7db41223 100644 --- a/packages/fresh/src/dev/dev_build_cache.ts +++ b/packages/fresh/src/dev/dev_build_cache.ts @@ -14,6 +14,7 @@ import { fsItemsToCommands, type FsRouteFile } from "../fs_routes.ts"; import type { Command } from "../commands.ts"; import type { ServerIslandRegistry } from "../context.ts"; import { contentType as getStdContentType } from "@std/media-types/content-type"; +import { globToRegExp } from "@std/path"; const WINDOWS_SEPARATOR = pathWin32.SEPARATOR; @@ -355,9 +356,21 @@ export class DiskBuildCache implements DevBuildCache { } } + const caPatterns = this.#config.contentAddressedStatic; + const caRegexps = caPatterns.map((p) => + globToRegExp(p, { extended: true, globstar: true }) + ); + const isContentAddressed = (pathname: string) => + caRegexps.some((re) => re.test(pathname)); + const staticFiles: PendingStaticFile[] = []; for (const [name, filePath] of this.#unprocessedFiles.entries()) { - staticFiles.push({ filePath, pathname: name, hash: null }); + staticFiles.push({ + filePath, + pathname: name, + hash: null, + immutable: isContentAddressed(name) || undefined, + }); } for (const [name, maybeHash] of this.#processedFiles.entries()) { @@ -367,7 +380,12 @@ export class DiskBuildCache implements DevBuildCache { } const filePath = path.join(outDir, "static", name); - staticFiles.push({ filePath, pathname: name, hash: maybeHash }); + staticFiles.push({ + filePath, + pathname: name, + hash: maybeHash, + immutable: isContentAddressed(name) || undefined, + }); } const islandSpecifiers: string[] = []; diff --git a/packages/fresh/src/dev/esbuild.ts b/packages/fresh/src/dev/esbuild.ts index 3470e8463f0..719e679a717 100644 --- a/packages/fresh/src/dev/esbuild.ts +++ b/packages/fresh/src/dev/esbuild.ts @@ -56,6 +56,8 @@ export async function bundleJs( bundle: true, splitting: true, treeShaking: true, + chunkNames: "../c/chunk-[hash]", + assetNames: "../c/[name]-[hash]", sourcemap: options.dev ? "linked" : options.sourceMap?.kind, sourceRoot: options.dev ? undefined : options.sourceMap?.sourceRoot, sourcesContent: options.dev ? undefined : options.sourceMap?.sourcesContent, diff --git a/packages/fresh/src/middlewares/static_files.ts b/packages/fresh/src/middlewares/static_files.ts index 288f9a98eb6..f49f79a01e9 100644 --- a/packages/fresh/src/middlewares/static_files.ts +++ b/packages/fresh/src/middlewares/static_files.ts @@ -63,7 +63,12 @@ export function staticFiles(): Middleware { try { const cacheKey = url.searchParams.get(ASSET_CACHE_BUST_KEY); - if (cacheKey !== null && BUILD_ID !== cacheKey) { + if ( + cacheKey !== null && BUILD_ID !== cacheKey && + // Accept the file's content hash as a valid cache key for + // content-addressed static files. + file.hash !== cacheKey + ) { url.searchParams.delete(ASSET_CACHE_BUST_KEY); const location = url.pathname + url.search; file.close(); @@ -105,6 +110,11 @@ export function staticFiles(): Middleware { url.pathname.startsWith( `${ctx.config.basePath}/_fresh/js/${BUILD_ID}/`, ) || + // Content-hashed chunks/assets under /_fresh/js/c/ are + // immutable by filename — no BUILD_ID needed. + url.pathname.startsWith( + `${ctx.config.basePath}/_fresh/js/c/`, + ) || file.immutable) ) { span.setAttribute("fresh.cache", "immutable"); diff --git a/packages/fresh/src/middlewares/static_files_test.ts b/packages/fresh/src/middlewares/static_files_test.ts index 3c0b311c74c..7326a63ce5a 100644 --- a/packages/fresh/src/middlewares/static_files_test.ts +++ b/packages/fresh/src/middlewares/static_files_test.ts @@ -20,7 +20,12 @@ class MockBuildCache implements BuildCache { clientEntry = ""; features = { errorOverlay: false }; - constructor(files: Record) { + constructor( + files: Record< + string, + { hash: string | null; content: string; immutable?: boolean } + >, + ) { const encoder = new TextEncoder(); for (const [pathname, info] of Object.entries(files)) { const text = encoder.encode(info.content); @@ -32,6 +37,7 @@ class MockBuildCache implements BuildCache { readable: text, contentType: getContentType(normalized), close: () => {}, + immutable: info.immutable, }); } } @@ -231,6 +237,97 @@ Deno.test("static files - enables caching in production", async () => { ); }); +Deno.test("static files - content-addressed static file uses content hash as cache key", async () => { + const contentHash = "abc123contentHash"; + const buildCache = new MockBuildCache({ + "/large.wasm": { + content: "\x00asm", + hash: contentHash, + immutable: true, + }, + }); + const server = serveMiddleware( + staticFiles(), + { + buildCache, + config: { + root: "", + basePath: "", + mode: "production", + trustProxy: false, + }, + }, + ); + + // Content hash is accepted as a valid cache key → immutable cache + let res = await server.get( + `/large.wasm?${ASSET_CACHE_BUST_KEY}=${contentHash}`, + ); + await res.body?.cancel(); + expect(res.status).toEqual(200); + expect(res.headers.get("Cache-Control")).toEqual( + "public, max-age=31536000, immutable", + ); + + // BUILD_ID is also accepted (e.g. from client-side asset() call) + res = await server.get( + `/large.wasm?${ASSET_CACHE_BUST_KEY}=${BUILD_ID}`, + ); + await res.body?.cancel(); + expect(res.status).toEqual(200); + expect(res.headers.get("Cache-Control")).toEqual( + "public, max-age=31536000, immutable", + ); + + // Stale cache key still redirects + res = await server.get( + `/large.wasm?${ASSET_CACHE_BUST_KEY}=stale-key`, + ); + await res.body?.cancel(); + expect(res.status).toEqual(307); +}); + +Deno.test("static files - immutable caching for content-addressed chunks", async () => { + const buildCache = new MockBuildCache({ + "/_fresh/js/c/chunk-abc123.js": { + content: "console.log('shared')", + hash: "abc123", + }, + "/_fresh/js/c/module-def456.wasm": { + content: "\x00asm", + hash: "def456", + }, + }); + const server = serveMiddleware( + staticFiles(), + { + buildCache, + config: { + root: "", + basePath: "", + mode: "production", + trustProxy: false, + }, + }, + ); + + // Content-addressed chunks get immutable caching without __frsh_c + let res = await server.get("/_fresh/js/c/chunk-abc123.js"); + await res.body?.cancel(); + expect(res.status).toEqual(200); + expect(res.headers.get("Cache-Control")).toEqual( + "public, max-age=31536000, immutable", + ); + + // WASM assets also get immutable caching + res = await server.get("/_fresh/js/c/module-def456.wasm"); + await res.body?.cancel(); + expect(res.status).toEqual(200); + expect(res.headers.get("Cache-Control")).toEqual( + "public, max-age=31536000, immutable", + ); +}); + Deno.test("static files - encoded pathname", async () => { // Build cache stores URL-encoded paths (matching what prepareStaticFile produces) const fileKeys = ["C#.svg", "西安市.png", "인천.avif"]; diff --git a/packages/fresh/src/runtime/shared_internal.ts b/packages/fresh/src/runtime/shared_internal.ts index bd3e999b7ee..80f705a41ab 100644 --- a/packages/fresh/src/runtime/shared_internal.ts +++ b/packages/fresh/src/runtime/shared_internal.ts @@ -107,6 +107,17 @@ export const enum PartialMode { Prepend, } +let contentHashMap: Map | null = null; + +/** + * Register a map of pathname → content hash for content-addressed static + * files. When set, `assetInternal` uses the content hash instead of + * BUILD_ID for matching files, so their URLs survive deploys unchanged. + */ +export function setContentHashMap(map: Map): void { + contentHashMap = map; +} + /** * Create a "locked" asset path. This differs from a plain path in that it is * specific to the current version of the application, and as such can be safely @@ -122,7 +133,10 @@ export function assetInternal(path: string, buildId: string): string { ) { return path; } - url.searchParams.set(ASSET_CACHE_BUST_KEY, buildId); + // Use content hash for content-addressed files so the URL only + // changes when the file content changes, not on every deploy. + const cacheKey = contentHashMap?.get(url.pathname) ?? buildId; + url.searchParams.set(ASSET_CACHE_BUST_KEY, cacheKey); return url.pathname + url.search + url.hash; } catch (err) { // deno-lint-ignore no-console diff --git a/packages/plugin-vite/src/mod.ts b/packages/plugin-vite/src/mod.ts index c49c244526c..a0c85b181b8 100644 --- a/packages/plugin-vite/src/mod.ts +++ b/packages/plugin-vite/src/mod.ts @@ -66,6 +66,7 @@ export function fresh(config?: FreshViteConfig): Plugin[] { islandSpecifiers: new Map(), namer: new UniqueNamer(), checkImports: config?.checkImports ?? [], + contentAddressedStatic: config?.contentAddressedStatic ?? [], }; fConfig.checkImports.push((id, env) => { diff --git a/packages/plugin-vite/src/plugins/server_snapshot.ts b/packages/plugin-vite/src/plugins/server_snapshot.ts index cf37c0452dd..8fbd4c54994 100644 --- a/packages/plugin-vite/src/plugins/server_snapshot.ts +++ b/packages/plugin-vite/src/plugins/server_snapshot.ts @@ -21,6 +21,7 @@ import { type ResolvedFreshViteConfig, } from "../utils.ts"; import * as path from "@std/path"; +import { globToRegExp } from "@std/path"; import { getBuildId } from "./build_id.ts"; export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { @@ -276,6 +277,12 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { // Walk all static directories. First directory wins for // duplicate pathnames. + const caRegexps = options.contentAddressedStatic.map((p) => + globToRegExp(p, { extended: true, globstar: true }) + ); + const isContentAddressed = (pathname: string) => + caRegexps.some((re) => re.test(pathname)); + const seenStaticPaths = new Set(); for (const dir of options.staticDir) { if (!(await fsAdapter.isDirectory(dir))) continue; @@ -312,6 +319,7 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { filePath, hash: null, pathname: relative, + immutable: isContentAddressed(relative) || undefined, }); if (path.basename(relative) === "index.html") { diff --git a/packages/plugin-vite/src/utils.ts b/packages/plugin-vite/src/utils.ts index 0a866ef347f..a5a19819ae0 100644 --- a/packages/plugin-vite/src/utils.ts +++ b/packages/plugin-vite/src/utils.ts @@ -69,14 +69,30 @@ export interface FreshViteConfig { * are not imported in Islands running in the browser. */ checkImports?: ImportCheck[]; + /** + * Glob patterns for static files that should use content-hash + * caching instead of build ID. Matching files get immutable cache + * headers and their `asset()` URLs only change when the file + * content changes — surviving deploys unchanged. + * + * Useful for large assets like WASM binaries, fonts, or media + * that rarely change between deploys. + * + * @example ["**\/*.wasm", "**\/*.bin"] + */ + contentAddressedStatic?: string[]; } export type ResolvedFreshViteConfig = & Required< - Omit + Omit< + FreshViteConfig, + "islandSpecifiers" | "staticDir" | "contentAddressedStatic" + > > & { staticDir: string[]; islandSpecifiers: Map; namer: UniqueNamer; + contentAddressedStatic: string[]; };