Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/latest/advanced/builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@ const builder = new Builder({
islandDir?: string;
// Path to routes directory. (Default: `<root>/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?: {
Expand Down
39 changes: 39 additions & 0 deletions docs/latest/concepts/static-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<content-hash>` instead of
`/module.wasm?__frsh_c=<build-id>`. 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
Expand Down
13 changes: 13 additions & 0 deletions packages/fresh/src/build_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +59,18 @@ export class ProdBuildCache<State> implements BuildCache<State> {
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<string, string>();
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[] {
Expand Down
23 changes: 21 additions & 2 deletions packages/fresh/src/dev/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -106,13 +116,16 @@ export interface BuildOptions {
* The final resolved Builder configuration.
*/
export type ResolvedBuildConfig =
& Required<Omit<BuildOptions, "sourceMap" | "staticDir">>
& Required<
Omit<BuildOptions, "sourceMap" | "staticDir" | "contentAddressedStatic">
>
& {
/** 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
Expand Down Expand Up @@ -151,6 +164,7 @@ export class Builder<State = any> {
mode: "production",
buildId: BUILD_ID,
sourceMap: options?.sourceMap,
contentAddressedStatic: options?.contentAddressedStatic ?? [],
};
}

Expand Down Expand Up @@ -376,9 +390,14 @@ export class Builder<State = any> {
}
}

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);
}

Expand Down
22 changes: 20 additions & 2 deletions packages/fresh/src/dev/dev_build_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -355,9 +356,21 @@ export class DiskBuildCache<State> implements DevBuildCache<State> {
}
}

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()) {
Expand All @@ -367,7 +380,12 @@ export class DiskBuildCache<State> implements DevBuildCache<State> {
}

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[] = [];
Expand Down
2 changes: 2 additions & 0 deletions packages/fresh/src/dev/esbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion packages/fresh/src/middlewares/static_files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ export function staticFiles<T>(): Middleware<T> {

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();
Expand Down Expand Up @@ -105,6 +110,11 @@ export function staticFiles<T>(): Middleware<T> {
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");
Expand Down
99 changes: 98 additions & 1 deletion packages/fresh/src/middlewares/static_files_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ class MockBuildCache implements BuildCache {
clientEntry = "";
features = { errorOverlay: false };

constructor(files: Record<string, { hash: string | null; content: string }>) {
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);
Expand All @@ -32,6 +37,7 @@ class MockBuildCache implements BuildCache {
readable: text,
contentType: getContentType(normalized),
close: () => {},
immutable: info.immutable,
});
}
}
Expand Down Expand Up @@ -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"];
Expand Down
16 changes: 15 additions & 1 deletion packages/fresh/src/runtime/shared_internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,17 @@ export const enum PartialMode {
Prepend,
}

let contentHashMap: Map<string, string> | 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<string, string>): 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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-vite/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading
Loading