From b4cfac87fe820c4d722d94bc8cbd078d2bfce69b Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 14:18:16 +0000 Subject: [PATCH 01/28] feat: initial work on vercel immedutable Co-Authored-By: Rihan --- src/build/vite/env.ts | 4 ++ src/build/vite/plugin.ts | 8 ++- src/presets/vercel/immutable.ts | 101 ++++++++++++++++++++++++++++++++ src/presets/vercel/preset.ts | 10 ++++ src/types/config.ts | 9 +++ 5 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/presets/vercel/immutable.ts diff --git a/src/build/vite/env.ts b/src/build/vite/env.ts index 43267adb77..b479571350 100644 --- a/src/build/vite/env.ts +++ b/src/build/vite/env.ts @@ -64,6 +64,9 @@ export function createServiceEnvironment( ): EnvironmentOptions { const isDev = ctx.nitro!.options.dev; const isWorkerdRunner = _isWorkerdRunner(ctx); + // Keep SSR-emitted asset URLs (e.g. CSS `` tags) aligned with the + // relocated client assets, so both resolve to the same content-addressed file. + const clientAssetsDir = ctx.nitro!.options.output.clientAssetsDir; return { consumer: "server", build: { @@ -77,6 +80,7 @@ export function createServiceEnvironment( outDir: join(ctx.nitro!.options.buildDir, "vite/services", name), emptyOutDir: true, copyPublicDir: false, + ...(clientAssetsDir ? { assetsDir: clientAssetsDir } : {}), }, resolve: { ...(isDev ? { noExternal: isWorkerdRunner ? true : [/^nitro(\/|$)/] } : {}), diff --git a/src/build/vite/plugin.ts b/src/build/vite/plugin.ts index 4b8b7cd5c0..cf87ba8c51 100644 --- a/src/build/vite/plugin.ts +++ b/src/build/vite/plugin.ts @@ -120,9 +120,15 @@ function nitroEnv(ctx: NitroPluginContext): VitePlugin { configEnvironment(name, config) { if (config.consumer === "client") { debug("[env] Configuring client environment", name === "client" ? "" : ` (${name})`); + const nitro = useNitro(ctx); config.build!.emptyOutDir = false; - config.build!.outDir = useNitro(ctx).options.output.publicDir; + config.build!.outDir = nitro.options.output.publicDir; config.build!.copyPublicDir ??= false; + // Relocate generated client assets (e.g. under `_vercel/immutable`) so + // both client and SSR references point at the immutable base. + if (nitro.options.output.clientAssetsDir) { + config.build!.assetsDir = nitro.options.output.clientAssetsDir; + } return; } diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts new file mode 100644 index 0000000000..fbe25f4813 --- /dev/null +++ b/src/presets/vercel/immutable.ts @@ -0,0 +1,101 @@ +import { createHash } from "node:crypto"; +import fsp from "node:fs/promises"; +import { defu } from "defu"; +import { glob } from "tinyglobby"; +import { join, resolve } from "pathe"; +import { joinURL, withLeadingSlash } from "ufo"; +import type { Nitro, NitroRouteRules } from "nitro/types"; +import { writeFile } from "../_utils/fs.ts"; + +// Immutable Static Files +// +// https://vercel.com/docs/build-output-api/primitives +// +// Immutable static files are content-addressed and shared across deployments which improves cross-deployment +// caching. +// +// Newer deployments must never overwrite an existing file with different content, so the file names embed a content hash. +// +// Alongside `.vercel/output/static`, we emit a `.vercel/output/immutable.json` +// manifest mapping each immutable static file to its full content hash (the file +// name may only contain a truncated hash). + +const IMMUTABLE_MANIFEST = "immutable.json"; + +// Reserved Vercel namespace under which immutable static files are served. +// Used as the client `assetsDir` so content-addressed assets are emitted and +// referenced here, and served without the deployment-scoped `?dpl` query. +export const IMMUTABLE_DIR = "_vercel/immutable"; + +interface ImmutableManifest { + version: 1; + hashes: Record; +} + +export async function generateImmutableManifest(nitro: Nitro) { + // `VERCEL_IMMUTABLE_DEPLOYMENT_ID` is set when a project opts in to immutable + // static files. Skip generating the manifest otherwise. + if (!process.env.VERCEL_IMMUTABLE_DEPLOYMENT_ID) { + return; + } + + // The salt is factored into the hashes to provide a way to rotate file names. + const salt = process.env.VERCEL_HASH_SALT || ""; + + const isImmutable = createImmutableMatcher(nitro); + + // Walk every emitted static file and hash the immutable (content-addressed) + // ones. The output public dir already includes the site `baseURL`, so scanned + // paths are relative to it. + const publicDir = nitro.options.output.publicDir; + const files = await glob("**", { cwd: publicDir, absolute: false, dot: true }); + + const hashes: Record = {}; + for (const file of files) { + const pathname = withLeadingSlash(file); + if (!isImmutable(pathname)) { + continue; + } + const contents = await fsp.readFile(join(publicDir, file)); + const url = joinURL(nitro.options.baseURL, pathname); + hashes[url] = hashContent(contents, salt); + } + + const manifest: ImmutableManifest = { version: 1, hashes }; + await writeFile( + resolve(nitro.options.output.dir, IMMUTABLE_MANIFEST), + JSON.stringify(manifest, null, 2) + ); + + nitro.logger + .withTag("vercel") + .info(`Generated immutable manifest (${Object.keys(hashes).length} files).`); +} + +// A static file is immutable when it is served with a long-lived `immutable` +// cache-control. That covers content-addressed bundler output emitted under an +// assets dir (marked via a route rule, e.g. Vite's `/assets/**`) as well as +// non-fallthrough public asset dirs, while excluding user-authored files in the +// fallthrough `public/` dir. +function createImmutableMatcher(nitro: Nitro): (pathname: string) => boolean { + const immutableBaseURLs = nitro.options.publicAssets + .filter((asset) => !asset.fallthrough) + .map((asset) => withLeadingSlash(asset.baseURL || "/")) + .filter((baseURL) => baseURL !== "/"); + + const getRouteRules = (pathname: string) => + defu({}, ...nitro.routing.routeRules.matchAll("", pathname).reverse()) as NitroRouteRules; + + return (pathname: string) => { + if (immutableBaseURLs.some((baseURL) => pathname.startsWith(baseURL + "/"))) { + return true; + } + const cacheControl = getRouteRules(pathname).headers?.["cache-control"]; + return !!cacheControl && cacheControl.includes("immutable"); + }; +} + +// Full content hash, salted so file names can be rotated via `VERCEL_HASH_SALT`. +function hashContent(contents: Buffer, salt: string): string { + return createHash("sha256").update(salt).update(contents).digest("base64url"); +} diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index c5e8288983..80389a6397 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -9,6 +9,7 @@ import { generateStaticFiles, resolveVercelRuntime, } from "./utils.ts"; +import { IMMUTABLE_DIR, generateImmutableManifest } from "./immutable.ts"; import { vercelDevModule } from "./dev.ts"; import type { VercelFunctionTrigger } from "./types.ts"; @@ -40,6 +41,13 @@ const vercel = defineNitroPreset( "build:before": async (nitro: Nitro) => { const logger = nitro.logger.withTag("vercel"); + // Immutable static files: emit content-addressed client assets under the + // reserved `_vercel/immutable` base so they can be shared across + // deployments. Enabled when the project opts in via Vercel. + if (process.env.VERCEL_IMMUTABLE_DEPLOYMENT_ID) { + nitro.options.output.clientAssetsDir = IMMUTABLE_DIR; + } + // Runtime const runtime = await resolveVercelRuntime(nitro); if (runtime.startsWith("bun") && !nitro.options.exportConditions!.includes("bun")) { @@ -112,6 +120,7 @@ const vercel = defineNitroPreset( }, async compiled(nitro: Nitro) { await generateFunctionFiles(nitro); + await generateImmutableManifest(nitro); }, }, }, @@ -143,6 +152,7 @@ const vercelStatic = defineNitroPreset( }, async compiled(nitro: Nitro) { await generateStaticFiles(nitro); + await generateImmutableManifest(nitro); }, }, }, diff --git a/src/types/config.ts b/src/types/config.ts index 2841b548de..b74822ccea 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -198,6 +198,15 @@ export interface NitroOptions extends PresetOptions { serverDir: string; /** Public/static assets output directory. */ publicDir: string; + /** + * Directory (relative to the served base) for client build assets. + * + * Applied as the bundler `assetsDir` for client environments, so generated + * client assets are emitted and referenced under this path. Presets can use + * it to relocate content-addressed assets (e.g. Vercel immutable static + * files under `_vercel/immutable`). + */ + clientAssetsDir?: string; }; /** @deprecated Migrate to `serverDir`. */ From 4e44a935272cba39840eafe9c7be74bbfb3dad81 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 14:24:36 +0000 Subject: [PATCH 02/28] rename config to buildAssetsDir --- src/build/vite/env.ts | 4 ++-- src/build/vite/plugin.ts | 4 ++-- src/presets/vercel/preset.ts | 2 +- src/types/config.ts | 19 ++++++++++--------- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/build/vite/env.ts b/src/build/vite/env.ts index b479571350..e3c6975239 100644 --- a/src/build/vite/env.ts +++ b/src/build/vite/env.ts @@ -66,7 +66,7 @@ export function createServiceEnvironment( const isWorkerdRunner = _isWorkerdRunner(ctx); // Keep SSR-emitted asset URLs (e.g. CSS `` tags) aligned with the // relocated client assets, so both resolve to the same content-addressed file. - const clientAssetsDir = ctx.nitro!.options.output.clientAssetsDir; + const buildAssetsDir = ctx.nitro!.options.buildAssetsDir; return { consumer: "server", build: { @@ -80,7 +80,7 @@ export function createServiceEnvironment( outDir: join(ctx.nitro!.options.buildDir, "vite/services", name), emptyOutDir: true, copyPublicDir: false, - ...(clientAssetsDir ? { assetsDir: clientAssetsDir } : {}), + ...(buildAssetsDir ? { assetsDir: buildAssetsDir } : {}), }, resolve: { ...(isDev ? { noExternal: isWorkerdRunner ? true : [/^nitro(\/|$)/] } : {}), diff --git a/src/build/vite/plugin.ts b/src/build/vite/plugin.ts index cf87ba8c51..e485e63921 100644 --- a/src/build/vite/plugin.ts +++ b/src/build/vite/plugin.ts @@ -126,8 +126,8 @@ function nitroEnv(ctx: NitroPluginContext): VitePlugin { config.build!.copyPublicDir ??= false; // Relocate generated client assets (e.g. under `_vercel/immutable`) so // both client and SSR references point at the immutable base. - if (nitro.options.output.clientAssetsDir) { - config.build!.assetsDir = nitro.options.output.clientAssetsDir; + if (nitro.options.buildAssetsDir) { + config.build!.assetsDir = nitro.options.buildAssetsDir; } return; } diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index 80389a6397..26f6795d32 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -45,7 +45,7 @@ const vercel = defineNitroPreset( // reserved `_vercel/immutable` base so they can be shared across // deployments. Enabled when the project opts in via Vercel. if (process.env.VERCEL_IMMUTABLE_DEPLOYMENT_ID) { - nitro.options.output.clientAssetsDir = IMMUTABLE_DIR; + nitro.options.buildAssetsDir = IMMUTABLE_DIR; } // Runtime diff --git a/src/types/config.ts b/src/types/config.ts index b74822ccea..2fb04f6bd9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -198,17 +198,18 @@ export interface NitroOptions extends PresetOptions { serverDir: string; /** Public/static assets output directory. */ publicDir: string; - /** - * Directory (relative to the served base) for client build assets. - * - * Applied as the bundler `assetsDir` for client environments, so generated - * client assets are emitted and referenced under this path. Presets can use - * it to relocate content-addressed assets (e.g. Vercel immutable static - * files under `_vercel/immutable`). - */ - clientAssetsDir?: string; }; + /** + * Directory (relative to the served base) for generated build assets. + * + * Applied as the bundler `assetsDir` for client and SSR environments, so + * generated assets are emitted and referenced under this path. Presets can + * use it to relocate content-addressed assets (e.g. Vercel immutable static + * files under `_vercel/immutable`). + */ + buildAssetsDir?: string; + /** @deprecated Migrate to `serverDir`. */ srcDir: string; From d0e3a6c55893e234bea724cb82d9e71e929faa0b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:31:40 +0000 Subject: [PATCH 03/28] chore: apply automated updates --- src/presets/vercel/immutable.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index fbe25f4813..6d2409d73c 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -12,8 +12,8 @@ import { writeFile } from "../_utils/fs.ts"; // https://vercel.com/docs/build-output-api/primitives // // Immutable static files are content-addressed and shared across deployments which improves cross-deployment -// caching. -// +// caching. +// // Newer deployments must never overwrite an existing file with different content, so the file names embed a content hash. // // Alongside `.vercel/output/static`, we emit a `.vercel/output/immutable.json` From adaadc5e13df546db3a56542c768bc3898e7a448 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 14:51:17 +0000 Subject: [PATCH 04/28] change gate --- src/presets/vercel/immutable.ts | 5 ++--- src/presets/vercel/preset.ts | 13 ++++++++++--- src/presets/vercel/types.ts | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index 6d2409d73c..e90168b930 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -33,9 +33,8 @@ interface ImmutableManifest { } export async function generateImmutableManifest(nitro: Nitro) { - // `VERCEL_IMMUTABLE_DEPLOYMENT_ID` is set when a project opts in to immutable - // static files. Skip generating the manifest otherwise. - if (!process.env.VERCEL_IMMUTABLE_DEPLOYMENT_ID) { + // Skip unless immutable static files are enabled (`vercel.immutableAssets`). + if (!nitro.options.vercel?.immutableAssets) { return; } diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index 26f6795d32..186e807c36 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -26,6 +26,7 @@ const vercel = defineNitroPreset( }, vercel: { skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED, + immutableAssets: false, cronHandlerRoute: "/_vercel/cron", }, output: { @@ -41,10 +42,10 @@ const vercel = defineNitroPreset( "build:before": async (nitro: Nitro) => { const logger = nitro.logger.withTag("vercel"); - // Immutable static files: emit content-addressed client assets under the + // Immutable static files: emit content-addressed build assets under the // reserved `_vercel/immutable` base so they can be shared across - // deployments. Enabled when the project opts in via Vercel. - if (process.env.VERCEL_IMMUTABLE_DEPLOYMENT_ID) { + // deployments. + if (nitro.options.vercel?.immutableAssets) { nitro.options.buildAssetsDir = IMMUTABLE_DIR; } @@ -138,6 +139,7 @@ const vercelStatic = defineNitroPreset( }, vercel: { skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED, + immutableAssets: true, }, output: { dir: "{{ rootDir }}/.vercel/output", @@ -147,6 +149,11 @@ const vercelStatic = defineNitroPreset( preview: "npx serve ./static", }, hooks: { + "build:before": (nitro: Nitro) => { + if (nitro.options.vercel?.immutableAssets) { + nitro.options.buildAssetsDir = IMMUTABLE_DIR; + } + }, "rollup:before": (nitro: Nitro) => { deprecateSWR(nitro); }, diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index aab42fcb02..54339d7584 100644 --- a/src/presets/vercel/types.ts +++ b/src/presets/vercel/types.ts @@ -134,6 +134,20 @@ export interface VercelOptions { */ skewProtection?: boolean; + /** + * Emit content-addressed build assets as [immutable static files](https://vercel.com/docs/skew-protection). + * + * Immutable assets are served from the reserved `/_vercel/immutable/` base + * (without the deployment-scoped `?dpl` query), so they can be shared across + * deployments to improve cross-deployment caching. Nitro also writes an + * `immutable.json` manifest mapping each file to its full content hash. + * + * Enabled by default. Set to `false` to opt out. + * + * @default false + */ + immutableAssets?: boolean; + /** * If you are using `vercel-edge`, you can specify the region(s) for your edge function. * @see https://vercel.com/docs/concepts/functions/edge-functions#edge-function-regions From 4472a8550fc366813c4225765cf068b489b24ca2 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 14:59:57 +0000 Subject: [PATCH 05/28] update docs --- docs/2.deploy/20.providers/vercel.md | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index f7d144c008..0cc4e54030 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -228,6 +228,39 @@ If your hook throws, the message is retried locally. Retries honour `retryAfterS You can provide additional [build output configuration](https://vercel.com/docs/build-output-api/v3) using `vercel.config` key inside `nitro.config`. It will be merged with built-in auto-generated config. +## Immutable static files + +:read-more{title="Immutable static files" to="https://vercel.com/docs/skew-protection"} + +Client build assets (such as JS and CSS chunks) can be emitted as **immutable static files**. These are served from the reserved `/_vercel/immutable/` path so they are shared across deployments and keep resolving even after a newer deployment no longer references them, improving cross-deployment caching. + +You can enable this feature with the `vercel.immutableAssets` option: + +```ts [nitro.config.ts] +import { defineConfig } from "nitro"; + +export default defineConfig({ + vercel: { + immutableAssets: true + } +}) +``` + +When enabled, Nitro: + +- Emits content-addressed build assets under `/_vercel/immutable/` (applied to both client and server-rendered references). +- Writes a `.vercel/output/immutable.json` manifest mapping each immutable file to its full content hash. + +::note +The [`VERCEL_HASH_SALT`](https://vercel.com/docs/environment-variables/system-environment-variables) system environment variable is factored into the content hashes, providing a way to rotate the generated file names. +:: + +::note +This works out of the box with the **Nitro + Vite** integration: Nitro sets `nitro.options.buildAssetsDir` and the Vite integration automatically applies it as the `assetsDir` for the client and server-rendered builds, so all generated asset URLs point under `/_vercel/immutable/`. + +Other build integrations must apply `nitro.options.buildAssetsDir` themselves — use it as the client bundler's asset output directory (base) so generated asset URLs are emitted under that path. Without this, assets are still emitted at their default location and the immutable manifest will not match. +:: + ## On-Demand incremental static regeneration (ISR) On-demand revalidation allows you to purge the cache for an ISR route whenever you want, foregoing the time interval required with background revalidation. From 73c4b09975858437bbaa9468a1db0a38a911591a Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 16:26:42 +0000 Subject: [PATCH 06/28] support VERCEL_IMMUTABLE_ASSETS env --- src/presets/vercel/preset.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index 186e807c36..98cb797316 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -26,7 +26,7 @@ const vercel = defineNitroPreset( }, vercel: { skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED, - immutableAssets: false, + immutableAssets: !!process.env.VERCEL_IMMUTABLE_ASSETS, cronHandlerRoute: "/_vercel/cron", }, output: { @@ -139,7 +139,7 @@ const vercelStatic = defineNitroPreset( }, vercel: { skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED, - immutableAssets: true, + immutableAssets: !!process.env.VERCEL_IMMUTABLE_ASSETS, }, output: { dir: "{{ rootDir }}/.vercel/output", From 624311a7cb50189f99d49fba0b4fcd3e16a2d4ce Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 16:28:43 +0000 Subject: [PATCH 07/28] avoid extra hash by default --- src/presets/vercel/immutable.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index e90168b930..5f06422d45 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import fsp from "node:fs/promises"; import { defu } from "defu"; import { glob } from "tinyglobby"; @@ -24,7 +23,8 @@ const IMMUTABLE_MANIFEST = "immutable.json"; // Reserved Vercel namespace under which immutable static files are served. // Used as the client `assetsDir` so content-addressed assets are emitted and -// referenced here, and served without the deployment-scoped `?dpl` query. +// referenced here. +// TODO: use process.env.VERCEL_HASH_SALT when exists export const IMMUTABLE_DIR = "_vercel/immutable"; interface ImmutableManifest { @@ -38,9 +38,6 @@ export async function generateImmutableManifest(nitro: Nitro) { return; } - // The salt is factored into the hashes to provide a way to rotate file names. - const salt = process.env.VERCEL_HASH_SALT || ""; - const isImmutable = createImmutableMatcher(nitro); // Walk every emitted static file and hash the immutable (content-addressed) @@ -57,7 +54,7 @@ export async function generateImmutableManifest(nitro: Nitro) { } const contents = await fsp.readFile(join(publicDir, file)); const url = joinURL(nitro.options.baseURL, pathname); - hashes[url] = hashContent(contents, salt); + hashes[url] = "" } const manifest: ImmutableManifest = { version: 1, hashes }; @@ -93,8 +90,3 @@ function createImmutableMatcher(nitro: Nitro): (pathname: string) => boolean { return !!cacheControl && cacheControl.includes("immutable"); }; } - -// Full content hash, salted so file names can be rotated via `VERCEL_HASH_SALT`. -function hashContent(contents: Buffer, salt: string): string { - return createHash("sha256").update(salt).update(contents).digest("base64url"); -} From c4e8ded01f91b32ab5eac0a9c4dac55ab1638139 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 16:29:40 +0000 Subject: [PATCH 08/28] no need to read file --- src/presets/vercel/immutable.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index 5f06422d45..ea4b631b03 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -1,7 +1,6 @@ -import fsp from "node:fs/promises"; import { defu } from "defu"; import { glob } from "tinyglobby"; -import { join, resolve } from "pathe"; +import { resolve } from "pathe"; import { joinURL, withLeadingSlash } from "ufo"; import type { Nitro, NitroRouteRules } from "nitro/types"; import { writeFile } from "../_utils/fs.ts"; @@ -52,7 +51,6 @@ export async function generateImmutableManifest(nitro: Nitro) { if (!isImmutable(pathname)) { continue; } - const contents = await fsp.readFile(join(publicDir, file)); const url = joinURL(nitro.options.baseURL, pathname); hashes[url] = "" } From d2f391616919f7a1b6ab52664914969ce6447041 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 16:36:09 +0000 Subject: [PATCH 09/28] simplify manifrst creation --- src/presets/vercel/immutable.ts | 50 +++++++-------------------------- 1 file changed, 10 insertions(+), 40 deletions(-) diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index ea4b631b03..9f5ac11e19 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -1,8 +1,7 @@ -import { defu } from "defu"; import { glob } from "tinyglobby"; -import { resolve } from "pathe"; +import { resolve } from "pathe"; import { joinURL, withLeadingSlash } from "ufo"; -import type { Nitro, NitroRouteRules } from "nitro/types"; +import type { Nitro } from "nitro/types"; import { writeFile } from "../_utils/fs.ts"; // Immutable Static Files @@ -37,25 +36,19 @@ export async function generateImmutableManifest(nitro: Nitro) { return; } - const isImmutable = createImmutableMatcher(nitro); - - // Walk every emitted static file and hash the immutable (content-addressed) - // ones. The output public dir already includes the site `baseURL`, so scanned - // paths are relative to it. const publicDir = nitro.options.output.publicDir; - const files = await glob("**", { cwd: publicDir, absolute: false, dot: true }); + const files = await glob(`${IMMUTABLE_DIR}/**`, { + cwd: publicDir, + absolute: false, + dot: true, + }); - const hashes: Record = {}; + const manifest: ImmutableManifest = { version: 1, hashes: {} }; for (const file of files) { const pathname = withLeadingSlash(file); - if (!isImmutable(pathname)) { - continue; - } - const url = joinURL(nitro.options.baseURL, pathname); - hashes[url] = "" + manifest.hashes[joinURL(nitro.options.baseURL, pathname)] = ""; } - const manifest: ImmutableManifest = { version: 1, hashes }; await writeFile( resolve(nitro.options.output.dir, IMMUTABLE_MANIFEST), JSON.stringify(manifest, null, 2) @@ -63,28 +56,5 @@ export async function generateImmutableManifest(nitro: Nitro) { nitro.logger .withTag("vercel") - .info(`Generated immutable manifest (${Object.keys(hashes).length} files).`); -} - -// A static file is immutable when it is served with a long-lived `immutable` -// cache-control. That covers content-addressed bundler output emitted under an -// assets dir (marked via a route rule, e.g. Vite's `/assets/**`) as well as -// non-fallthrough public asset dirs, while excluding user-authored files in the -// fallthrough `public/` dir. -function createImmutableMatcher(nitro: Nitro): (pathname: string) => boolean { - const immutableBaseURLs = nitro.options.publicAssets - .filter((asset) => !asset.fallthrough) - .map((asset) => withLeadingSlash(asset.baseURL || "/")) - .filter((baseURL) => baseURL !== "/"); - - const getRouteRules = (pathname: string) => - defu({}, ...nitro.routing.routeRules.matchAll("", pathname).reverse()) as NitroRouteRules; - - return (pathname: string) => { - if (immutableBaseURLs.some((baseURL) => pathname.startsWith(baseURL + "/"))) { - return true; - } - const cacheControl = getRouteRules(pathname).headers?.["cache-control"]; - return !!cacheControl && cacheControl.includes("immutable"); - }; + .info(`Generated immutable manifest (${Object.keys(manifest.hashes).length} files).`); } From 827134346bc9c7ae373b99a642029f413642800c Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 16:50:53 +0000 Subject: [PATCH 10/28] respect salt --- src/presets/vercel/immutable.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index 9f5ac11e19..cde9b1d488 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -22,8 +22,7 @@ const IMMUTABLE_MANIFEST = "immutable.json"; // Reserved Vercel namespace under which immutable static files are served. // Used as the client `assetsDir` so content-addressed assets are emitted and // referenced here. -// TODO: use process.env.VERCEL_HASH_SALT when exists -export const IMMUTABLE_DIR = "_vercel/immutable"; +export const IMMUTABLE_DIR = `_vercel/immutable/${process.env.VERCEL_HASH_SALT ? `/${process.env.VERCEL_HASH_SALT}` : ""}`; interface ImmutableManifest { version: 1; From f2ee1f8ab101eb3e2e6a5ea7faa704a5e98f6b54 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 16:51:04 +0000 Subject: [PATCH 11/28] use long vite hashes --- src/build/vite/plugin.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/build/vite/plugin.ts b/src/build/vite/plugin.ts index e485e63921..aa41c9fed4 100644 --- a/src/build/vite/plugin.ts +++ b/src/build/vite/plugin.ts @@ -128,6 +128,11 @@ function nitroEnv(ctx: NitroPluginContext): VitePlugin { // both client and SSR references point at the immutable base. if (nitro.options.buildAssetsDir) { config.build!.assetsDir = nitro.options.buildAssetsDir; + // Content-addressed (immutable) assets benefit from longer content + // hashes to reduce collision risk across deployments. Only upgrade the + // default `[hash]` token to a longer one; never override filename + // patterns explicitly set by the user or other plugins. + useLongerAssetHashes(config.build!, ctx._isRolldown, nitro.options.buildAssetsDir); } return; } @@ -463,6 +468,36 @@ async function setupNitroContext( }); } +// Upgrade the default `[hash]` filename token to a longer content hash for the +// client build output. Filename patterns already configured (by the user or +// other plugins) are only touched to lengthen a bare `[hash]`; explicit +// `[hash:n]` tokens and non-string patterns are left untouched. +function useLongerAssetHashes( + build: NonNullable, + isRolldown: boolean | undefined, + assetsDir: string +): void { + const options = ((build as any)[isRolldown ? "rolldownOptions" : "rollupOptions"] ??= {}); + const outputs = Array.isArray(options.output) ? options.output : [(options.output ??= {})]; + const defaults: Record = { + entryFileNames: `${assetsDir}/[name]-[hash:16].js`, + chunkFileNames: `${assetsDir}/[name]-[hash:16].js`, + assetFileNames: `${assetsDir}/[name]-[hash:16][extname]`, + }; + for (const output of outputs) { + for (const key of Object.keys(defaults)) { + const current = output[key]; + if (current === undefined) { + // Not set: opt into a longer-hash default matching Vite's own pattern. + output[key] = defaults[key]; + } else if (typeof current === "string" && current.includes("[hash]")) { + // Already set: only lengthen a bare `[hash]` token, keep the rest as-is. + output[key] = current.replaceAll("[hash]", `[hash:16]`); + } + } + } +} + function getEntry(input: InputOption | undefined): string | undefined { if (typeof input === "string") { return input; From 4dcc7d2b55dda89799029be7cce97e1a21f530d6 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 17:04:04 +0000 Subject: [PATCH 12/28] rm double slash --- src/presets/vercel/immutable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index cde9b1d488..5535356ac4 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -22,7 +22,7 @@ const IMMUTABLE_MANIFEST = "immutable.json"; // Reserved Vercel namespace under which immutable static files are served. // Used as the client `assetsDir` so content-addressed assets are emitted and // referenced here. -export const IMMUTABLE_DIR = `_vercel/immutable/${process.env.VERCEL_HASH_SALT ? `/${process.env.VERCEL_HASH_SALT}` : ""}`; +export const IMMUTABLE_DIR = `_vercel/immutable${process.env.VERCEL_HASH_SALT ? `/${process.env.VERCEL_HASH_SALT}` : ""}`; interface ImmutableManifest { version: 1; From a23d80bda860c04d00b5a95bd6522676d230cb97 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 17:30:44 +0000 Subject: [PATCH 13/28] use filename --- src/presets/vercel/immutable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index 5535356ac4..cb57e79c1d 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -45,7 +45,7 @@ export async function generateImmutableManifest(nitro: Nitro) { const manifest: ImmutableManifest = { version: 1, hashes: {} }; for (const file of files) { const pathname = withLeadingSlash(file); - manifest.hashes[joinURL(nitro.options.baseURL, pathname)] = ""; + manifest.hashes[joinURL(nitro.options.baseURL, pathname)] = pathname; } await writeFile( From c619bd16a3904622c6318c8734ab7ea65e420e45 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 17:35:15 +0000 Subject: [PATCH 14/28] simplify manifest creation --- src/presets/vercel/immutable.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index cb57e79c1d..3307737829 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -42,11 +42,16 @@ export async function generateImmutableManifest(nitro: Nitro) { dot: true, }); - const manifest: ImmutableManifest = { version: 1, hashes: {} }; - for (const file of files) { - const pathname = withLeadingSlash(file); - manifest.hashes[joinURL(nitro.options.baseURL, pathname)] = pathname; - } + const manifest: ImmutableManifest = { + version: 1, + hashes: Object.fromEntries( + files.map((file) => { + const pathname = withLeadingSlash(file); + const url = joinURL(nitro.options.baseURL, pathname) + return [url, url]; + }) + ), + }; await writeFile( resolve(nitro.options.output.dir, IMMUTABLE_MANIFEST), From ee4409d8fcf49034314530f64379fb7327e8f761 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:37:32 +0000 Subject: [PATCH 15/28] chore: apply automated updates --- src/presets/vercel/immutable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index 3307737829..d8fce2c194 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -47,7 +47,7 @@ export async function generateImmutableManifest(nitro: Nitro) { hashes: Object.fromEntries( files.map((file) => { const pathname = withLeadingSlash(file); - const url = joinURL(nitro.options.baseURL, pathname) + const url = joinURL(nitro.options.baseURL, pathname); return [url, url]; }) ), From 43b5fa8ec47733aeb6885f7177c5a704c8fafeff Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 17:51:24 +0000 Subject: [PATCH 16/28] update docs --- docs/2.deploy/20.providers/vercel.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index 0cc4e54030..15b433c4a5 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -230,11 +230,15 @@ You can provide additional [build output configuration](https://vercel.com/docs/ ## Immutable static files -:read-more{title="Immutable static files" to="https://vercel.com/docs/skew-protection"} +::important +This feature is currently only available in the [nightly release channel](/docs/nightly) of Nitro v3. +:: + + Client build assets (such as JS and CSS chunks) can be emitted as **immutable static files**. These are served from the reserved `/_vercel/immutable/` path so they are shared across deployments and keep resolving even after a newer deployment no longer references them, improving cross-deployment caching. -You can enable this feature with the `vercel.immutableAssets` option: +You can enable this feature with the `vercel.immutableAssets` option or by setting the `VERCEL_IMMUTABLE_ASSETS` environment variable. ```ts [nitro.config.ts] import { defineConfig } from "nitro"; @@ -246,19 +250,16 @@ export default defineConfig({ }) ``` -When enabled, Nitro: - -- Emits content-addressed build assets under `/_vercel/immutable/` (applied to both client and server-rendered references). -- Writes a `.vercel/output/immutable.json` manifest mapping each immutable file to its full content hash. +When enabled, Nitro emits content-addressed build assets under `/_vercel/immutable/`. ::note The [`VERCEL_HASH_SALT`](https://vercel.com/docs/environment-variables/system-environment-variables) system environment variable is factored into the content hashes, providing a way to rotate the generated file names. :: ::note -This works out of the box with the **Nitro + Vite** integration: Nitro sets `nitro.options.buildAssetsDir` and the Vite integration automatically applies it as the `assetsDir` for the client and server-rendered builds, so all generated asset URLs point under `/_vercel/immutable/`. +This works out of the box with the **Nitro + Vite** integration: Nitro's vercel preset sets `buildAssetsDir` config and the Vite plugin automatically applies it as the `assetsDir` for the client and server-rendered builds, so all generated asset URLs point under `/_vercel/immutable/`. -Other build integrations must apply `nitro.options.buildAssetsDir` themselves — use it as the client bundler's asset output directory (base) so generated asset URLs are emitted under that path. Without this, assets are still emitted at their default location and the immutable manifest will not match. +Client build setups and frameworks must apply `nitro.options.buildAssetsDir` themselves — use it as the client bundler's asset output directory (base) so generated asset URLs are emitted under that path. Without this, assets are still emitted at their default location and the immutable manifest will not match. :: ## On-Demand incremental static regeneration (ISR) From e5e3730da59b91046be0fc43e6cea79bc55b1d94 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 18:25:40 +0000 Subject: [PATCH 17/28] rename options --- docs/2.deploy/20.providers/vercel.md | 4 ++-- src/presets/vercel/immutable.ts | 4 ++-- src/presets/vercel/preset.ts | 8 ++++---- src/presets/vercel/types.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index 15b433c4a5..b1f43c70b4 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -238,14 +238,14 @@ This feature is currently only available in the [nightly release channel](/docs/ Client build assets (such as JS and CSS chunks) can be emitted as **immutable static files**. These are served from the reserved `/_vercel/immutable/` path so they are shared across deployments and keep resolving even after a newer deployment no longer references them, improving cross-deployment caching. -You can enable this feature with the `vercel.immutableAssets` option or by setting the `VERCEL_IMMUTABLE_ASSETS` environment variable. +You can enable this feature with the `vercel.immutableStaticFiles` option or by setting the `VERCEL_IMMUTABLE_STATIC_FILES_ENABLED` environment variable. ```ts [nitro.config.ts] import { defineConfig } from "nitro"; export default defineConfig({ vercel: { - immutableAssets: true + immutableStaticFiles: true } }) ``` diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index d8fce2c194..23806f2e6b 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -30,8 +30,8 @@ interface ImmutableManifest { } export async function generateImmutableManifest(nitro: Nitro) { - // Skip unless immutable static files are enabled (`vercel.immutableAssets`). - if (!nitro.options.vercel?.immutableAssets) { + // Skip unless immutable static files are enabled (`vercel.immutableStaticFiles`). + if (!nitro.options.vercel?.immutableStaticFiles) { return; } diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index 98cb797316..8156ae878e 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -26,7 +26,7 @@ const vercel = defineNitroPreset( }, vercel: { skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED, - immutableAssets: !!process.env.VERCEL_IMMUTABLE_ASSETS, + immutableStaticFiles: !!process.env.VERCEL_IMMUTABLE_STATIC_FILES_ENABLED, cronHandlerRoute: "/_vercel/cron", }, output: { @@ -45,7 +45,7 @@ const vercel = defineNitroPreset( // Immutable static files: emit content-addressed build assets under the // reserved `_vercel/immutable` base so they can be shared across // deployments. - if (nitro.options.vercel?.immutableAssets) { + if (nitro.options.vercel?.immutableStaticFiles) { nitro.options.buildAssetsDir = IMMUTABLE_DIR; } @@ -139,7 +139,7 @@ const vercelStatic = defineNitroPreset( }, vercel: { skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED, - immutableAssets: !!process.env.VERCEL_IMMUTABLE_ASSETS, + immutableStaticFiles: !!process.env.VERCEL_IMMUTABLE_STATIC_FILES_ENABLED, }, output: { dir: "{{ rootDir }}/.vercel/output", @@ -150,7 +150,7 @@ const vercelStatic = defineNitroPreset( }, hooks: { "build:before": (nitro: Nitro) => { - if (nitro.options.vercel?.immutableAssets) { + if (nitro.options.vercel?.immutableStaticFiles) { nitro.options.buildAssetsDir = IMMUTABLE_DIR; } }, diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index 54339d7584..ba9ed33f3b 100644 --- a/src/presets/vercel/types.ts +++ b/src/presets/vercel/types.ts @@ -146,7 +146,7 @@ export interface VercelOptions { * * @default false */ - immutableAssets?: boolean; + immutableStaticFiles?: boolean; /** * If you are using `vercel-edge`, you can specify the region(s) for your edge function. From 3a583f18198a2cac0bc4243ce839d78527b014f9 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 18:35:02 +0000 Subject: [PATCH 18/28] use framework name in dir --- src/presets/vercel/immutable.ts | 19 +++++++++++++------ src/presets/vercel/preset.ts | 6 +++--- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index 23806f2e6b..0744a73971 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -19,11 +19,6 @@ import { writeFile } from "../_utils/fs.ts"; const IMMUTABLE_MANIFEST = "immutable.json"; -// Reserved Vercel namespace under which immutable static files are served. -// Used as the client `assetsDir` so content-addressed assets are emitted and -// referenced here. -export const IMMUTABLE_DIR = `_vercel/immutable${process.env.VERCEL_HASH_SALT ? `/${process.env.VERCEL_HASH_SALT}` : ""}`; - interface ImmutableManifest { version: 1; hashes: Record; @@ -36,7 +31,7 @@ export async function generateImmutableManifest(nitro: Nitro) { } const publicDir = nitro.options.output.publicDir; - const files = await glob(`${IMMUTABLE_DIR}/**`, { + const files = await glob(`${nitro.options.buildAssetsDir}/**`, { cwd: publicDir, absolute: false, dot: true, @@ -62,3 +57,15 @@ export async function generateImmutableManifest(nitro: Nitro) { .withTag("vercel") .info(`Generated immutable manifest (${Object.keys(manifest.hashes).length} files).`); } + +// Reserved Vercel namespace under which immutable static files are served. +// Used as the client `buildAssetsDir` so content-addressed assets are emitted +// and referenced here. The path is namespaced by an optional hash salt and the +// framework name to avoid cross-framework collisions. +export function immutableDir(nitro: Nitro) { + return joinURL( + "_vercel/immutable", + process.env.VERCEL_HASH_SALT || "", + nitro.options.framework.name || "" + ); +} diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index 8156ae878e..7bc6a3a222 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -9,7 +9,7 @@ import { generateStaticFiles, resolveVercelRuntime, } from "./utils.ts"; -import { IMMUTABLE_DIR, generateImmutableManifest } from "./immutable.ts"; +import { immutableDir, generateImmutableManifest } from "./immutable.ts"; import { vercelDevModule } from "./dev.ts"; import type { VercelFunctionTrigger } from "./types.ts"; @@ -46,7 +46,7 @@ const vercel = defineNitroPreset( // reserved `_vercel/immutable` base so they can be shared across // deployments. if (nitro.options.vercel?.immutableStaticFiles) { - nitro.options.buildAssetsDir = IMMUTABLE_DIR; + nitro.options.buildAssetsDir = immutableDir(nitro); } // Runtime @@ -151,7 +151,7 @@ const vercelStatic = defineNitroPreset( hooks: { "build:before": (nitro: Nitro) => { if (nitro.options.vercel?.immutableStaticFiles) { - nitro.options.buildAssetsDir = IMMUTABLE_DIR; + nitro.options.buildAssetsDir = immutableDir(nitro); } }, "rollup:before": (nitro: Nitro) => { From 8964eca5c63bb4ba822028fe4f3c30d0d63b223b Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 18:35:34 +0000 Subject: [PATCH 19/28] add basic behavior test --- test/fixture/nitro.config.ts | 1 + test/presets/vercel.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index 6d4657ab33..fff62ede58 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -5,6 +5,7 @@ import { existsSync } from "node:fs"; export default defineConfig({ vercel: { + immutableStaticFiles: true, functionRules: { "/api/hello": { maxDuration: 100, diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index ae4d31e71a..b74de48d49 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -454,6 +454,20 @@ describe("nitro:preset:vercel:web", async () => { `); }); + it("should apply immutable buildAssetsDir and write manifest", async () => { + // `vercel.immutableStaticFiles` relocates build assets under the + // reserved `_vercel/immutable` base (namespaced by the framework name) + // and emits an `immutable.json` manifest mapping each file to its full + // content hash. + expect(ctx.nitro!.options.buildAssetsDir).toBe("_vercel/immutable/nitro"); + + const manifest = await fsp + .readFile(resolve(ctx.outDir, "immutable.json"), "utf8") + .then((r) => JSON.parse(r)); + expect(manifest.version).toBe(1); + expect(manifest.hashes).toBeTypeOf("object"); + }); + it("should generate prerender config", async () => { const isrRouteConfig = await fsp.readFile( resolve(ctx.outDir, "functions/rules/isr/[...]-isr.prerender-config.json"), From ced17b84dd217d3808be97d4966b0b32b58591be Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 18:50:50 +0000 Subject: [PATCH 20/28] use salt in test --- test/presets/vercel.test.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index b74de48d49..aefd0268ac 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -2,6 +2,7 @@ import { promises as fsp } from "node:fs"; import { Server, type IncomingMessage, type ServerResponse } from "node:http"; import type { Socket } from "node:net"; import { resolve, join, basename } from "pathe"; +import { joinURL } from "ufo"; import { describe, expect, it, vi, beforeAll, afterAll } from "vitest"; import { setupTest, testNitro, fixtureDir } from "../tests.ts"; import { toFetchHandler } from "srvx/node"; @@ -11,7 +12,14 @@ const presetFixturesDir = resolve(import.meta.dirname, "fixtures"); // NOTE: Always prefer extending the existing `nitro:preset:vercel:web` matrix // (its setup/build is shared across assertions) over adding new top-level // `describe` blocks, which trigger a separate build and slow down CI. +// Example salt used to exercise `VERCEL_HASH_SALT` namespacing of immutable +// static files. Set around the (build-time) `setupTest` below and restored +// immediately after so it does not leak into other builds. +const HASH_SALT = "test-salt"; + describe("nitro:preset:vercel:web", async () => { + const prevHashSalt = process.env.VERCEL_HASH_SALT; + process.env.VERCEL_HASH_SALT = HASH_SALT; const ctx = await setupTest("vercel", { outDirSuffix: "-web", config: { @@ -32,6 +40,11 @@ describe("nitro:preset:vercel:web", async () => { }, }, }); + if (prevHashSalt === undefined) { + delete process.env.VERCEL_HASH_SALT; + } else { + process.env.VERCEL_HASH_SALT = prevHashSalt; + } testNitro( ctx, async () => { @@ -456,10 +469,16 @@ describe("nitro:preset:vercel:web", async () => { it("should apply immutable buildAssetsDir and write manifest", async () => { // `vercel.immutableStaticFiles` relocates build assets under the - // reserved `_vercel/immutable` base (namespaced by the framework name) - // and emits an `immutable.json` manifest mapping each file to its full - // content hash. - expect(ctx.nitro!.options.buildAssetsDir).toBe("_vercel/immutable/nitro"); + // reserved `_vercel/immutable` base (namespaced by the optional + // `VERCEL_HASH_SALT` and the framework name) and emits an + // `immutable.json` manifest mapping each file to its full content hash. + const expectedDir = joinURL( + "_vercel/immutable", + HASH_SALT, + ctx.nitro!.options.framework.name || "" + ); + expect(ctx.nitro!.options.buildAssetsDir).toBe(expectedDir); + expect(expectedDir).toBe("_vercel/immutable/test-salt/nitro"); const manifest = await fsp .readFile(resolve(ctx.outDir, "immutable.json"), "utf8") From eb09b4baa623296129b556f43182ea0c2d3a5714 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 15 Jul 2026 18:54:21 +0000 Subject: [PATCH 21/28] update test --- test/presets/vercel.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index aefd0268ac..9256b6e68d 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -12,14 +12,16 @@ const presetFixturesDir = resolve(import.meta.dirname, "fixtures"); // NOTE: Always prefer extending the existing `nitro:preset:vercel:web` matrix // (its setup/build is shared across assertions) over adding new top-level // `describe` blocks, which trigger a separate build and slow down CI. -// Example salt used to exercise `VERCEL_HASH_SALT` namespacing of immutable -// static files. Set around the (build-time) `setupTest` below and restored -// immediately after so it does not leak into other builds. -const HASH_SALT = "test-salt"; describe("nitro:preset:vercel:web", async () => { + const TEST_HASH_SALT = "initial"; + + // Example salt used to exercise `VERCEL_HASH_SALT` namespacing of immutable + // static files. Set around the (build-time) `setupTest` below and restored + // immediately after so it does not leak into other builds. const prevHashSalt = process.env.VERCEL_HASH_SALT; - process.env.VERCEL_HASH_SALT = HASH_SALT; + process.env.VERCEL_HASH_SALT = TEST_HASH_SALT; + const ctx = await setupTest("vercel", { outDirSuffix: "-web", config: { @@ -40,11 +42,13 @@ describe("nitro:preset:vercel:web", async () => { }, }, }); + if (prevHashSalt === undefined) { delete process.env.VERCEL_HASH_SALT; } else { process.env.VERCEL_HASH_SALT = prevHashSalt; } + testNitro( ctx, async () => { @@ -474,11 +478,11 @@ describe("nitro:preset:vercel:web", async () => { // `immutable.json` manifest mapping each file to its full content hash. const expectedDir = joinURL( "_vercel/immutable", - HASH_SALT, + TEST_HASH_SALT, ctx.nitro!.options.framework.name || "" ); expect(ctx.nitro!.options.buildAssetsDir).toBe(expectedDir); - expect(expectedDir).toBe("_vercel/immutable/test-salt/nitro"); + expect(expectedDir).toBe(`_vercel/immutable/${TEST_HASH_SALT}/nitro`); const manifest = await fsp .readFile(resolve(ctx.outDir, "immutable.json"), "utf8") From 02ee991adc68e9862a631cb8a3678093b2e78be7 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Thu, 16 Jul 2026 02:25:32 +0100 Subject: [PATCH 22/28] fix: match longer asset hash in ssr --- src/build/vite/env.ts | 13 ++++++++++++- src/build/vite/plugin.ts | 6 +++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/build/vite/env.ts b/src/build/vite/env.ts index e3c6975239..c7bcb2aa78 100644 --- a/src/build/vite/env.ts +++ b/src/build/vite/env.ts @@ -66,6 +66,9 @@ export function createServiceEnvironment( const isWorkerdRunner = _isWorkerdRunner(ctx); // Keep SSR-emitted asset URLs (e.g. CSS `` tags) aligned with the // relocated client assets, so both resolve to the same content-addressed file. + // Both the directory (`assetsDir`) and the content-hash length must match the + // client environment, otherwise the SSR bundle references e.g. + // `app-.css` while the client emits `app-.css` → 404. const buildAssetsDir = ctx.nitro!.options.buildAssetsDir; return { consumer: "server", @@ -73,7 +76,15 @@ export function createServiceEnvironment( rollupOptions: { input: { index: serviceConfig.entry }, ...(isDev ? {} : { external: [/^nitro(\/|$)/] }), - output: { minifyInternalExports: false }, + output: { + minifyInternalExports: false, + // Must match the client environment's `[hash:16]` asset pattern (see + // `useLongerAssetHashes` in `plugin.ts`) so a `?url` asset import + // resolves to the same filename on both sides. + ...(buildAssetsDir + ? { assetFileNames: `${buildAssetsDir}/[name]-[hash:16][extname]` } + : {}), + }, }, minify: ctx.nitro!.options.minify, sourcemap: ctx.nitro!.options.sourcemap, diff --git a/src/build/vite/plugin.ts b/src/build/vite/plugin.ts index aa41c9fed4..240192b1a1 100644 --- a/src/build/vite/plugin.ts +++ b/src/build/vite/plugin.ts @@ -472,7 +472,11 @@ async function setupNitroContext( // client build output. Filename patterns already configured (by the user or // other plugins) are only touched to lengthen a bare `[hash]`; explicit // `[hash:n]` tokens and non-string patterns are left untouched. -function useLongerAssetHashes( +// +// NOTE: the `[hash:16]` asset pattern must stay in sync with the SSR service +// environment's `assetFileNames` (see `env.ts`), otherwise a `?url` asset import +// resolves to a different filename on the client vs. the server → 404. +export function useLongerAssetHashes( build: NonNullable, isRolldown: boolean | undefined, assetsDir: string From eb30da5cb0efbb7fca966506bce33fb5f4904cc5 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Thu, 16 Jul 2026 02:39:06 +0100 Subject: [PATCH 23/28] docs: typo --- docs/2.deploy/20.providers/vercel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index b1f43c70b4..2e2e0655cd 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -257,7 +257,7 @@ The [`VERCEL_HASH_SALT`](https://vercel.com/docs/environment-variables/system-en :: ::note -This works out of the box with the **Nitro + Vite** integration: Nitro's vercel preset sets `buildAssetsDir` config and the Vite plugin automatically applies it as the `assetsDir` for the client and server-rendered builds, so all generated asset URLs point under `/_vercel/immutable/`. +This works out of the box with the **Nitro + Vite** integration: Nitro's Vercel preset sets `buildAssetsDir` config and the Vite plugin automatically applies it as the `assetsDir` for the client and server-rendered builds, so all generated asset URLs point under `/_vercel/immutable/`. Client build setups and frameworks must apply `nitro.options.buildAssetsDir` themselves — use it as the client bundler's asset output directory (base) so generated asset URLs are emitted under that path. Without this, assets are still emitted at their default location and the immutable manifest will not match. :: From 009f5414b65cefdf1c5888426cb6833e9f06d709 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Tue, 21 Jul 2026 12:23:44 +0100 Subject: [PATCH 24/28] refactor: use useLongerAssetHashes with ssr --- src/build/vite/env.ts | 17 +---------------- src/build/vite/plugin.ts | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/build/vite/env.ts b/src/build/vite/env.ts index c7bcb2aa78..43267adb77 100644 --- a/src/build/vite/env.ts +++ b/src/build/vite/env.ts @@ -64,34 +64,19 @@ export function createServiceEnvironment( ): EnvironmentOptions { const isDev = ctx.nitro!.options.dev; const isWorkerdRunner = _isWorkerdRunner(ctx); - // Keep SSR-emitted asset URLs (e.g. CSS `` tags) aligned with the - // relocated client assets, so both resolve to the same content-addressed file. - // Both the directory (`assetsDir`) and the content-hash length must match the - // client environment, otherwise the SSR bundle references e.g. - // `app-.css` while the client emits `app-.css` → 404. - const buildAssetsDir = ctx.nitro!.options.buildAssetsDir; return { consumer: "server", build: { rollupOptions: { input: { index: serviceConfig.entry }, ...(isDev ? {} : { external: [/^nitro(\/|$)/] }), - output: { - minifyInternalExports: false, - // Must match the client environment's `[hash:16]` asset pattern (see - // `useLongerAssetHashes` in `plugin.ts`) so a `?url` asset import - // resolves to the same filename on both sides. - ...(buildAssetsDir - ? { assetFileNames: `${buildAssetsDir}/[name]-[hash:16][extname]` } - : {}), - }, + output: { minifyInternalExports: false }, }, minify: ctx.nitro!.options.minify, sourcemap: ctx.nitro!.options.sourcemap, outDir: join(ctx.nitro!.options.buildDir, "vite/services", name), emptyOutDir: true, copyPublicDir: false, - ...(buildAssetsDir ? { assetsDir: buildAssetsDir } : {}), }, resolve: { ...(isDev ? { noExternal: isWorkerdRunner ? true : [/^nitro(\/|$)/] } : {}), diff --git a/src/build/vite/plugin.ts b/src/build/vite/plugin.ts index 17c7b44824..dfddec2859 100644 --- a/src/build/vite/plugin.ts +++ b/src/build/vite/plugin.ts @@ -151,6 +151,12 @@ function nitroEnv(ctx: NitroPluginContext): VitePlugin { return; } + const nitro = useNitro(ctx); + if (name !== "nitro" && nitro.options.buildAssetsDir) { + config.build!.assetsDir = nitro.options.buildAssetsDir; + useLongerAssetHashes(config.build!, ctx._isRolldown, nitro.options.buildAssetsDir); + } + // Skip if already registered as a service if (name === "nitro" || ctx.services[name]) { return; @@ -484,14 +490,15 @@ async function setupNitroContext( }); } -// Upgrade the default `[hash]` filename token to a longer content hash for the -// client build output. Filename patterns already configured (by the user or -// other plugins) are only touched to lengthen a bare `[hash]`; explicit +// Upgrade the default `[hash]` filename token to a longer content hash for a +// build environment's output. Filename patterns already configured (by the user +// or other plugins) are only touched to lengthen a bare `[hash]`; explicit // `[hash:n]` tokens and non-string patterns are left untouched. // -// NOTE: the `[hash:16]` asset pattern must stay in sync with the SSR service -// environment's `assetFileNames` (see `env.ts`), otherwise a `?url` asset import -// resolves to a different filename on the client vs. the server → 404. +// Applied identically to the client and the server (SSR) environments so a +// shared asset resolves to the same filename on both sides. A user/framework +// that overrides `assetFileNames` is responsible for keeping the two in sync +// (and such assets opt out of the `buildAssetsDir` immutable base). export function useLongerAssetHashes( build: NonNullable, isRolldown: boolean | undefined, From eb4f0d167e3d8a1dcc9c18e01c0571f04384cb02 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 22 Jul 2026 03:09:08 +0100 Subject: [PATCH 25/28] refactor: make immutable opt in --- docs/2.deploy/20.providers/vercel.md | 2 +- src/presets/vercel/immutable.ts | 26 ++++++++++++++++++++++++++ src/presets/vercel/preset.ts | 19 +++++++------------ src/presets/vercel/types.ts | 5 ++++- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index 2e2e0655cd..6343d7da53 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -238,7 +238,7 @@ This feature is currently only available in the [nightly release channel](/docs/ Client build assets (such as JS and CSS chunks) can be emitted as **immutable static files**. These are served from the reserved `/_vercel/immutable/` path so they are shared across deployments and keep resolving even after a newer deployment no longer references them, improving cross-deployment caching. -You can enable this feature with the `vercel.immutableStaticFiles` option or by setting the `VERCEL_IMMUTABLE_STATIC_FILES_ENABLED` environment variable. +This feature is opt-in. Enable it with the `vercel.immutableStaticFiles` option or by setting the `NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED` environment variable. ```ts [nitro.config.ts] import { defineConfig } from "nitro"; diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index 0744a73971..7eddea789d 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -1,6 +1,7 @@ import { glob } from "tinyglobby"; import { resolve } from "pathe"; import { joinURL, withLeadingSlash } from "ufo"; +import { provider } from "std-env"; import type { Nitro } from "nitro/types"; import { writeFile } from "../_utils/fs.ts"; @@ -24,6 +25,31 @@ interface ImmutableManifest { hashes: Record; } +// Opt-in guard for immutable static files, run from `build:before`. +// +// Immutable static files are opt-in via `vercel.immutableStaticFiles` (defaulting +// to the `NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED` env var). When running on +// Vercel, the platform advertises support through `VERCEL_IMMUTABLE_STATIC_FILES_ENABLED`. +// If the feature is enabled but the current deployment does not support it, an +// immutable deploy would fail, so we disable it and warn instead of producing broken output. +export function setupImmutableStaticFiles(nitro: Nitro) { + if (!nitro.options.vercel?.immutableStaticFiles) { + return; + } + + if (provider === "vercel" && !process.env.VERCEL_IMMUTABLE_STATIC_FILES_ENABLED) { + nitro.options.vercel.immutableStaticFiles = false; + nitro.logger + .withTag("vercel") + .warn( + "Immutable static files are enabled but not supported by the current Vercel deployment (`VERCEL_IMMUTABLE_STATIC_FILES_ENABLED` is not set). Skipping immutable static files output." + ); + return; + } + + nitro.options.buildAssetsDir = immutableDir(nitro); +} + export async function generateImmutableManifest(nitro: Nitro) { // Skip unless immutable static files are enabled (`vercel.immutableStaticFiles`). if (!nitro.options.vercel?.immutableStaticFiles) { diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index 7bc6a3a222..a4a1ffbd46 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -9,7 +9,7 @@ import { generateStaticFiles, resolveVercelRuntime, } from "./utils.ts"; -import { immutableDir, generateImmutableManifest } from "./immutable.ts"; +import { setupImmutableStaticFiles, generateImmutableManifest } from "./immutable.ts"; import { vercelDevModule } from "./dev.ts"; import type { VercelFunctionTrigger } from "./types.ts"; @@ -26,7 +26,7 @@ const vercel = defineNitroPreset( }, vercel: { skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED, - immutableStaticFiles: !!process.env.VERCEL_IMMUTABLE_STATIC_FILES_ENABLED, + immutableStaticFiles: !!process.env.NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED, cronHandlerRoute: "/_vercel/cron", }, output: { @@ -42,12 +42,8 @@ const vercel = defineNitroPreset( "build:before": async (nitro: Nitro) => { const logger = nitro.logger.withTag("vercel"); - // Immutable static files: emit content-addressed build assets under the - // reserved `_vercel/immutable` base so they can be shared across - // deployments. - if (nitro.options.vercel?.immutableStaticFiles) { - nitro.options.buildAssetsDir = immutableDir(nitro); - } + // Immutable static files + setupImmutableStaticFiles(nitro); // Runtime const runtime = await resolveVercelRuntime(nitro); @@ -139,7 +135,7 @@ const vercelStatic = defineNitroPreset( }, vercel: { skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED, - immutableStaticFiles: !!process.env.VERCEL_IMMUTABLE_STATIC_FILES_ENABLED, + immutableStaticFiles: !!process.env.NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED, }, output: { dir: "{{ rootDir }}/.vercel/output", @@ -150,9 +146,8 @@ const vercelStatic = defineNitroPreset( }, hooks: { "build:before": (nitro: Nitro) => { - if (nitro.options.vercel?.immutableStaticFiles) { - nitro.options.buildAssetsDir = immutableDir(nitro); - } + // Immutable static files (opt-in, guarded by Vercel platform support) + setupImmutableStaticFiles(nitro); }, "rollup:before": (nitro: Nitro) => { deprecateSWR(nitro); diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index ba9ed33f3b..0993d33896 100644 --- a/src/presets/vercel/types.ts +++ b/src/presets/vercel/types.ts @@ -142,7 +142,10 @@ export interface VercelOptions { * deployments to improve cross-deployment caching. Nitro also writes an * `immutable.json` manifest mapping each file to its full content hash. * - * Enabled by default. Set to `false` to opt out. + * Opt in by setting this to `true` or the `NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED` + * environment variable. When building on Vercel, immutable output is only + * produced if the deployment supports it (`VERCEL_IMMUTABLE_STATIC_FILES_ENABLED`); + * otherwise it is skipped with a warning. * * @default false */ From d60f63687d59164d2e06f0814128814085276459 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 22 Jul 2026 15:59:07 +0100 Subject: [PATCH 26/28] fix: apply useLongerAssetHashes to ssr env rather than all non-nitro vite envs --- src/build/vite/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build/vite/plugin.ts b/src/build/vite/plugin.ts index dfddec2859..e56a486b84 100644 --- a/src/build/vite/plugin.ts +++ b/src/build/vite/plugin.ts @@ -152,7 +152,7 @@ function nitroEnv(ctx: NitroPluginContext): VitePlugin { } const nitro = useNitro(ctx); - if (name !== "nitro" && nitro.options.buildAssetsDir) { + if (name === "ssr" && nitro.options.buildAssetsDir) { config.build!.assetsDir = nitro.options.buildAssetsDir; useLongerAssetHashes(config.build!, ctx._isRolldown, nitro.options.buildAssetsDir); } From e6b0e7108f733cc7be7e96399abafdb5fc4e47b5 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 22 Jul 2026 17:34:04 +0100 Subject: [PATCH 27/28] fix: set immutable assetsDir on all server envs without forcing entry/chunk layout --- src/build/vite/plugin.ts | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/build/vite/plugin.ts b/src/build/vite/plugin.ts index e56a486b84..15e7dd2978 100644 --- a/src/build/vite/plugin.ts +++ b/src/build/vite/plugin.ts @@ -151,10 +151,17 @@ function nitroEnv(ctx: NitroPluginContext): VitePlugin { return; } + // Server environments render public asset URLs (`?url` imports, font CSS) + // derived from their `assetsDir`, so it must point at the immutable base + // where the client build actually emits the files. Only asset naming is + // aligned (`assetsOnly`): entry/chunk filenames are left at their defaults + // so each service keeps a flat entry that frameworks import by path. const nitro = useNitro(ctx); - if (name === "ssr" && nitro.options.buildAssetsDir) { + if (name !== "nitro" && nitro.options.buildAssetsDir) { config.build!.assetsDir = nitro.options.buildAssetsDir; - useLongerAssetHashes(config.build!, ctx._isRolldown, nitro.options.buildAssetsDir); + useLongerAssetHashes(config.build!, ctx._isRolldown, nitro.options.buildAssetsDir, { + assetsOnly: true, + }); } // Skip if already registered as a service @@ -495,20 +502,27 @@ async function setupNitroContext( // or other plugins) are only touched to lengthen a bare `[hash]`; explicit // `[hash:n]` tokens and non-string patterns are left untouched. // -// Applied identically to the client and the server (SSR) environments so a -// shared asset resolves to the same filename on both sides. A user/framework -// that overrides `assetFileNames` is responsible for keeping the two in sync +// Applied to the client environment (all output) and the SSR service +// environment (`assetsOnly` — just `assetFileNames`) so a shared asset +// resolves to the same filename on both sides. The SSR bundle's own +// entry/chunks keep Vite's flat server layout. A user/framework that +// overrides `assetFileNames` is responsible for keeping the two in sync // (and such assets opt out of the `buildAssetsDir` immutable base). export function useLongerAssetHashes( build: NonNullable, isRolldown: boolean | undefined, - assetsDir: string + assetsDir: string, + opts?: { assetsOnly?: boolean } ): void { const options = ((build as any)[isRolldown ? "rolldownOptions" : "rollupOptions"] ??= {}); const outputs = Array.isArray(options.output) ? options.output : [(options.output ??= {})]; const defaults: Record = { - entryFileNames: `${assetsDir}/[name]-[hash:16].js`, - chunkFileNames: `${assetsDir}/[name]-[hash:16].js`, + ...(opts?.assetsOnly + ? {} + : { + entryFileNames: `${assetsDir}/[name]-[hash:16].js`, + chunkFileNames: `${assetsDir}/[name]-[hash:16].js`, + }), assetFileNames: `${assetsDir}/[name]-[hash:16][extname]`, }; for (const output of outputs) { From 06a2e654902f5c0c798eb2e6c7fed7fad3560462 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 24 Jul 2026 18:40:51 +0000 Subject: [PATCH 28/28] fix(vercel): guard immutable static files and normalize buildAssetsDir - disable immutable static files (with a warning) on a non-root baseURL, since they must be served from the reserved `/_vercel/immutable/` path - warn instead of silently writing an empty manifest when no assets are emitted under `buildAssetsDir` - normalize `buildAssetsDir` in config and when deriving the immutable dir - correct manifest comments and docs: values are file paths (explicitly allowed by the spec) and `VERCEL_HASH_SALT` is factored into the asset paths rather than the content hashes Co-Authored-By: Claude Opus 5 --- docs/2.deploy/20.providers/vercel.md | 6 ++- src/config/resolvers/url.ts | 14 +++++++ src/presets/vercel/immutable.ts | 59 ++++++++++++++++++++++------ src/presets/vercel/types.ts | 2 +- test/unit/build-assets-dir.test.ts | 20 ++++++++++ test/unit/vercel-immutable.test.ts | 41 +++++++++++++++++++ 6 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 test/unit/build-assets-dir.test.ts create mode 100644 test/unit/vercel-immutable.test.ts diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index 6343d7da53..dc36653869 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -253,7 +253,11 @@ export default defineConfig({ When enabled, Nitro emits content-addressed build assets under `/_vercel/immutable/`. ::note -The [`VERCEL_HASH_SALT`](https://vercel.com/docs/environment-variables/system-environment-variables) system environment variable is factored into the content hashes, providing a way to rotate the generated file names. +The [`VERCEL_HASH_SALT`](https://vercel.com/docs/environment-variables/system-environment-variables) system environment variable is factored into the generated asset paths, providing a way to rotate them. +:: + +::warning +Immutable static files must be served from the reserved `/_vercel/immutable/` path, so they are not supported when using a non-root `baseURL`. In that case Nitro skips the immutable output and warns during the build. :: ::note diff --git a/src/config/resolvers/url.ts b/src/config/resolvers/url.ts index 1025de9554..c48c5cc949 100644 --- a/src/config/resolvers/url.ts +++ b/src/config/resolvers/url.ts @@ -3,4 +3,18 @@ import { withLeadingSlash, withTrailingSlash } from "ufo"; export async function resolveURLOptions(options: NitroOptions) { options.baseURL = withLeadingSlash(withTrailingSlash(options.baseURL)); + + if (options.buildAssetsDir !== undefined) { + options.buildAssetsDir = normalizeBuildAssetsDir(options.buildAssetsDir) || undefined; + } +} + +// Normalize a build assets directory into a bare relative path, stripping +// leading, trailing and duplicate slashes as well as `.` / `..` segments, so it +// can be safely interpolated into bundler filename and glob patterns. +function normalizeBuildAssetsDir(dir: string): string { + return dir + .split("/") + .filter((segment) => segment && segment !== "." && segment !== "..") + .join("/"); } diff --git a/src/presets/vercel/immutable.ts b/src/presets/vercel/immutable.ts index 7eddea789d..40421bd718 100644 --- a/src/presets/vercel/immutable.ts +++ b/src/presets/vercel/immutable.ts @@ -15,8 +15,10 @@ import { writeFile } from "../_utils/fs.ts"; // Newer deployments must never overwrite an existing file with different content, so the file names embed a content hash. // // Alongside `.vercel/output/static`, we emit a `.vercel/output/immutable.json` -// manifest mapping each immutable static file to its full content hash (the file -// name may only contain a truncated hash). +// manifest mapping each immutable static file URL to its full content hash. The +// spec allows using the file path itself as the "full content hash" when the +// name already carries enough entropy, which is what we do: emitted file names +// embed a 16 character content hash (see `useLongerAssetHashes`). const IMMUTABLE_MANIFEST = "immutable.json"; @@ -30,8 +32,9 @@ interface ImmutableManifest { // Immutable static files are opt-in via `vercel.immutableStaticFiles` (defaulting // to the `NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED` env var). When running on // Vercel, the platform advertises support through `VERCEL_IMMUTABLE_STATIC_FILES_ENABLED`. -// If the feature is enabled but the current deployment does not support it, an -// immutable deploy would fail, so we disable it and warn instead of producing broken output. +// If the feature is enabled but the current deployment does not support it, or the +// app is served from a non-root `baseURL`, an immutable deploy would fail or silently +// not apply, so we disable it and warn instead of producing broken output. export function setupImmutableStaticFiles(nitro: Nitro) { if (!nitro.options.vercel?.immutableStaticFiles) { return; @@ -47,6 +50,19 @@ export function setupImmutableStaticFiles(nitro: Nitro) { return; } + // `publicDir` is nested under `baseURL`, so a non-root base would emit assets + // to `//_vercel/immutable/...` instead of the reserved `/_vercel/immutable/` + // namespace. Vercel would not treat those as immutable, so opt out instead. + if (nitro.options.baseURL !== "/") { + nitro.options.vercel.immutableStaticFiles = false; + nitro.logger + .withTag("vercel") + .warn( + `Immutable static files are not supported with a non-root \`baseURL\` (\`${nitro.options.baseURL}\`), since they must be served from the reserved \`/_vercel/immutable/\` path. Skipping immutable static files output.` + ); + return; + } + nitro.options.buildAssetsDir = immutableDir(nitro); } @@ -79,9 +95,17 @@ export async function generateImmutableManifest(nitro: Nitro) { JSON.stringify(manifest, null, 2) ); - nitro.logger - .withTag("vercel") - .info(`Generated immutable manifest (${Object.keys(manifest.hashes).length} files).`); + const logger = nitro.logger.withTag("vercel"); + if (files.length === 0) { + // Emitting no immutable files almost always means the client build did not + // pick up `buildAssetsDir` (only the Nitro + Vite integration wires it up + // automatically). Warn instead of silently writing an empty manifest. + logger.warn( + `Immutable static files are enabled but no assets were emitted under \`${nitro.options.buildAssetsDir}\`. Make sure your client build uses \`nitro.options.buildAssetsDir\` as its assets directory.` + ); + } else { + logger.info(`Generated immutable manifest (${files.length} files).`); + } } // Reserved Vercel namespace under which immutable static files are served. @@ -89,9 +113,22 @@ export async function generateImmutableManifest(nitro: Nitro) { // and referenced here. The path is namespaced by an optional hash salt and the // framework name to avoid cross-framework collisions. export function immutableDir(nitro: Nitro) { - return joinURL( - "_vercel/immutable", - process.env.VERCEL_HASH_SALT || "", - nitro.options.framework.name || "" + return normalizeBuildAssetsDir( + joinURL( + "_vercel/immutable", + process.env.VERCEL_HASH_SALT || "", + nitro.options.framework.name || "" + ) ); } + +// Normalize a build assets directory into a bare relative path, stripping +// leading, trailing and duplicate slashes as well as `.` / `..` segments (a +// hand-set `VERCEL_HASH_SALT` must not escape the reserved namespace). +// Mirrors the same normalization applied to user config in `resolveURLOptions`. +function normalizeBuildAssetsDir(dir: string): string { + return dir + .split("/") + .filter((segment) => segment && segment !== "." && segment !== "..") + .join("/"); +} diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index 0993d33896..a19a6e49ae 100644 --- a/src/presets/vercel/types.ts +++ b/src/presets/vercel/types.ts @@ -140,7 +140,7 @@ export interface VercelOptions { * Immutable assets are served from the reserved `/_vercel/immutable/` base * (without the deployment-scoped `?dpl` query), so they can be shared across * deployments to improve cross-deployment caching. Nitro also writes an - * `immutable.json` manifest mapping each file to its full content hash. + * `immutable.json` manifest listing each emitted immutable file. * * Opt in by setting this to `true` or the `NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED` * environment variable. When building on Vercel, immutable output is only diff --git a/test/unit/build-assets-dir.test.ts b/test/unit/build-assets-dir.test.ts new file mode 100644 index 0000000000..0ed6628308 --- /dev/null +++ b/test/unit/build-assets-dir.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { loadOptions } from "../../src/config/loader.ts"; + +describe("config: buildAssetsDir", () => { + it.each([ + ["assets", "assets"], + ["/assets/", "assets"], + ["//_vercel//immutable//", "_vercel/immutable"], + ["./assets", "assets"], + ["_vercel/immutable/../../etc/nitro", "_vercel/immutable/etc/nitro"], + ])("normalizes %j to %j", async (input, expected) => { + const options = await loadOptions({ buildAssetsDir: input }); + expect(options.buildAssetsDir).toBe(expected); + }); + + it.each(["/", "", "."])("is unset when nothing meaningful is left (%j)", async (input) => { + const options = await loadOptions({ buildAssetsDir: input }); + expect(options.buildAssetsDir).toBeUndefined(); + }); +}); diff --git a/test/unit/vercel-immutable.test.ts b/test/unit/vercel-immutable.test.ts new file mode 100644 index 0000000000..dc1e741b25 --- /dev/null +++ b/test/unit/vercel-immutable.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Nitro } from "nitro/types"; +import { setupImmutableStaticFiles } from "../../src/presets/vercel/immutable.ts"; + +function createNitro(options: { baseURL: string; immutableStaticFiles?: boolean }) { + const warn = vi.fn(); + const nitro = { + options: { + baseURL: options.baseURL, + framework: { name: "nitro" }, + vercel: { immutableStaticFiles: options.immutableStaticFiles ?? true }, + }, + logger: { withTag: () => ({ warn, info: vi.fn() }) }, + } as unknown as Nitro; + return { nitro, warn }; +} + +describe("vercel: setupImmutableStaticFiles", () => { + it("sets buildAssetsDir for a root baseURL", () => { + const { nitro, warn } = createNitro({ baseURL: "/" }); + setupImmutableStaticFiles(nitro); + expect(nitro.options.buildAssetsDir).toBe("_vercel/immutable/nitro"); + expect(nitro.options.vercel!.immutableStaticFiles).toBe(true); + expect(warn).not.toHaveBeenCalled(); + }); + + it("disables and warns for a non-root baseURL", () => { + const { nitro, warn } = createNitro({ baseURL: "/app/" }); + setupImmutableStaticFiles(nitro); + expect(nitro.options.vercel!.immutableStaticFiles).toBe(false); + expect(nitro.options.buildAssetsDir).toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("non-root `baseURL`")); + }); + + it("is a no-op when not enabled", () => { + const { nitro, warn } = createNitro({ baseURL: "/app/", immutableStaticFiles: false }); + setupImmutableStaticFiles(nitro); + expect(nitro.options.buildAssetsDir).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); +});