diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index f7d144c008..dc36653869 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -228,6 +228,44 @@ 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 + +::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. + +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"; + +export default defineConfig({ + vercel: { + immutableStaticFiles: true + } +}) +``` + +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 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 +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. +:: + ## 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. diff --git a/src/build/vite/plugin.ts b/src/build/vite/plugin.ts index 22456e6c8c..15e7dd2978 100644 --- a/src/build/vite/plugin.ts +++ b/src/build/vite/plugin.ts @@ -134,12 +134,36 @@ 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.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; } + // 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 !== "nitro" && nitro.options.buildAssetsDir) { + config.build!.assetsDir = nitro.options.buildAssetsDir; + useLongerAssetHashes(config.build!, ctx._isRolldown, nitro.options.buildAssetsDir, { + assetsOnly: true, + }); + } + // Skip if already registered as a service if (name === "nitro" || ctx.services[name]) { return; @@ -473,6 +497,48 @@ async function setupNitroContext( }); } +// 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. +// +// 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, + 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 = { + ...(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) { + 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; 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 new file mode 100644 index 0000000000..40421bd718 --- /dev/null +++ b/src/presets/vercel/immutable.ts @@ -0,0 +1,134 @@ +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"; + +// 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 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"; + +interface ImmutableManifest { + version: 1; + 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, 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; + } + + 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; + } + + // `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); +} + +export async function generateImmutableManifest(nitro: Nitro) { + // Skip unless immutable static files are enabled (`vercel.immutableStaticFiles`). + if (!nitro.options.vercel?.immutableStaticFiles) { + return; + } + + const publicDir = nitro.options.output.publicDir; + const files = await glob(`${nitro.options.buildAssetsDir}/**`, { + cwd: publicDir, + absolute: false, + dot: true, + }); + + 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), + JSON.stringify(manifest, null, 2) + ); + + 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. +// 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 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/preset.ts b/src/presets/vercel/preset.ts index c5e8288983..a4a1ffbd46 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -9,6 +9,7 @@ import { generateStaticFiles, resolveVercelRuntime, } from "./utils.ts"; +import { setupImmutableStaticFiles, generateImmutableManifest } from "./immutable.ts"; import { vercelDevModule } from "./dev.ts"; import type { VercelFunctionTrigger } from "./types.ts"; @@ -25,6 +26,7 @@ const vercel = defineNitroPreset( }, vercel: { skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED, + immutableStaticFiles: !!process.env.NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED, cronHandlerRoute: "/_vercel/cron", }, output: { @@ -40,6 +42,9 @@ const vercel = defineNitroPreset( "build:before": async (nitro: Nitro) => { const logger = nitro.logger.withTag("vercel"); + // Immutable static files + setupImmutableStaticFiles(nitro); + // Runtime const runtime = await resolveVercelRuntime(nitro); if (runtime.startsWith("bun") && !nitro.options.exportConditions!.includes("bun")) { @@ -112,6 +117,7 @@ const vercel = defineNitroPreset( }, async compiled(nitro: Nitro) { await generateFunctionFiles(nitro); + await generateImmutableManifest(nitro); }, }, }, @@ -129,6 +135,7 @@ const vercelStatic = defineNitroPreset( }, vercel: { skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED, + immutableStaticFiles: !!process.env.NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED, }, output: { dir: "{{ rootDir }}/.vercel/output", @@ -138,11 +145,16 @@ const vercelStatic = defineNitroPreset( preview: "npx serve ./static", }, hooks: { + "build:before": (nitro: Nitro) => { + // Immutable static files (opt-in, guarded by Vercel platform support) + setupImmutableStaticFiles(nitro); + }, "rollup:before": (nitro: Nitro) => { deprecateSWR(nitro); }, async compiled(nitro: Nitro) { await generateStaticFiles(nitro); + await generateImmutableManifest(nitro); }, }, }, diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index aab42fcb02..a19a6e49ae 100644 --- a/src/presets/vercel/types.ts +++ b/src/presets/vercel/types.ts @@ -134,6 +134,23 @@ 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 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 + * produced if the deployment supports it (`VERCEL_IMMUTABLE_STATIC_FILES_ENABLED`); + * otherwise it is skipped with a warning. + * + * @default false + */ + immutableStaticFiles?: 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 diff --git a/src/types/config.ts b/src/types/config.ts index 2841b548de..2fb04f6bd9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -200,6 +200,16 @@ export interface NitroOptions extends PresetOptions { publicDir: 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; 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..9256b6e68d 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,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. + 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 = TEST_HASH_SALT; + const ctx = await setupTest("vercel", { outDirSuffix: "-web", config: { @@ -32,6 +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 () => { @@ -454,6 +471,26 @@ 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 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", + TEST_HASH_SALT, + ctx.nitro!.options.framework.name || "" + ); + expect(ctx.nitro!.options.buildAssetsDir).toBe(expectedDir); + expect(expectedDir).toBe(`_vercel/immutable/${TEST_HASH_SALT}/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"), 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(); + }); +});