├── .npmrc ├── .eslintignore ├── tsconfig.json ├── playground ├── tsconfig.json ├── server │ └── tsconfig.json ├── nuxt.config.ts ├── app.vue ├── components │ └── Time.vue └── package.json ├── .eslintrc ├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── src ├── module.ts ├── utils.ts └── cache.ts ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | playground 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["@nuxt/eslint-config"] 4 | } 5 | -------------------------------------------------------------------------------- /playground/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | modules: ["../src/module"], 3 | }); 4 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /playground/components/Time.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "nuxt-build-cache-playground", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "build": "nuxi build", 8 | "generate": "nuxi generate" 9 | }, 10 | "devDependencies": { 11 | "nuxt": "latest" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .data 23 | .vercel_build_output 24 | .build-* 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | 58 | # Why not? 59 | .next 60 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## v0.1.1 5 | 6 | 7 | ### 🚀 Enhancements 8 | 9 | - Content hash ([3950b16](https://github.com/pi0/nuxt-build-cache/commit/3950b16)) 10 | 11 | ### 🩹 Fixes 12 | 13 | - Cache hashes ([b070c30](https://github.com/pi0/nuxt-build-cache/commit/b070c30)) 14 | - Use content based hashing ([c4ee569](https://github.com/pi0/nuxt-build-cache/commit/c4ee569)) 15 | 16 | ### 💅 Refactors 17 | 18 | - Split cache utils ([87ff033](https://github.com/pi0/nuxt-build-cache/commit/87ff033)) 19 | - Move cf pages hack to cache utils ([a4b11f0](https://github.com/pi0/nuxt-build-cache/commit/a4b11f0)) 20 | 21 | ### 🏡 Chore 22 | 23 | - Update readme ([be2b420](https://github.com/pi0/nuxt-build-cache/commit/be2b420)) 24 | - Add small docs ([8f056a7](https://github.com/pi0/nuxt-build-cache/commit/8f056a7)) 25 | - Eslint ignore playground ([191898d](https://github.com/pi0/nuxt-build-cache/commit/191898d)) 26 | - Disable test ([0e73723](https://github.com/pi0/nuxt-build-cache/commit/0e73723)) 27 | 28 | ### ❤️ Contributors 29 | 30 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 31 | 32 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtModule } from "@nuxt/kit"; 2 | import { consola } from "./utils"; 3 | import { collectBuildCache, restoreBuildCache } from "./cache"; 4 | 5 | export default defineNuxtModule({ 6 | async setup(_, nuxt) { 7 | if ( 8 | nuxt.options._prepare || 9 | nuxt.options.dev || 10 | process.env.NUXT_DISABLE_BUILD_CACHE 11 | ) { 12 | return; 13 | } 14 | 15 | // Setup hooks 16 | nuxt.hook("build:before", async () => { 17 | // Try to restore 18 | const restored = process.env.NUXT_IGNORE_BUILD_CACHE 19 | ? undefined 20 | : await restoreBuildCache(nuxt); 21 | if (restored) { 22 | // Skip build since it's restored 23 | nuxt.options.builder = { 24 | bundle() { 25 | consola.info("skipping build"); 26 | return Promise.resolve(); 27 | }, 28 | }; 29 | } else { 30 | // Collect build cache this time 31 | if (!process.env.SKIP_NUXT_BUILD_CACHE_COLLECT) { 32 | nuxt.hook("close", async () => { 33 | await collectBuildCache(nuxt); 34 | }); 35 | } 36 | } 37 | }); 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-build-cache", 3 | "version": "0.1.1", 4 | "description": "experimental build cache module for Nuxt 3", 5 | "repository": "pi0/nuxt-build-cache", 6 | "license": "MIT", 7 | "type": "module", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/types.d.ts", 11 | "import": "./dist/module.mjs", 12 | "require": "./dist/module.cjs" 13 | } 14 | }, 15 | "main": "./dist/module.cjs", 16 | "types": "./dist/types.d.ts", 17 | "files": [ 18 | "dist" 19 | ], 20 | "scripts": { 21 | "prepack": "nuxt-module-build build", 22 | "dev": "nuxi dev playground", 23 | "dev:build": "nuxi build playground", 24 | "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground", 25 | "release": "npm run lint && npm run prepack && changelogen --release && npm publish && git push --follow-tags", 26 | "lint": "eslint ." 27 | }, 28 | "dependencies": { 29 | "@nuxt/kit": "^3.10.1", 30 | "consola": "^3.2.3", 31 | "globby": "^14.0.0", 32 | "nanotar": "^0.1.1", 33 | "nypm": "^0.3.6", 34 | "ohash": "^1.1.3", 35 | "pkg-types": "^1.0.3", 36 | "std-env": "^3.7.0" 37 | }, 38 | "devDependencies": { 39 | "@nuxt/devtools": "latest", 40 | "@nuxt/eslint-config": "^0.2.0", 41 | "@nuxt/module-builder": "^0.5.5", 42 | "@nuxt/schema": "^3.10.1", 43 | "@types/node": "^20.11.13", 44 | "automd": "^0.2.0", 45 | "changelogen": "^0.5.5", 46 | "eslint": "^8.56.0", 47 | "nuxt": "^3.10.1" 48 | } 49 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { readFile, stat } from "node:fs/promises"; 2 | import { resolve } from "node:path"; 3 | import type { TarFileInput } from "nanotar"; 4 | import { globby } from "globby"; 5 | import _consola from "consola"; 6 | 7 | export const consola = _consola.withTag("nuxt-build-cache"); 8 | 9 | export type FileWithMeta = TarFileInput; 10 | 11 | export async function readFilesRecursive( 12 | dir: string | string[], 13 | opts: { 14 | shouldIgnore?: (name: string) => boolean; 15 | noData?: boolean; 16 | patterns?: string[]; 17 | } = {} 18 | ): Promise { 19 | if (Array.isArray(dir)) { 20 | return ( 21 | await Promise.all(dir.map((d) => readFilesRecursive(d, opts))) 22 | ).flat(); 23 | } 24 | 25 | const files = await globby( 26 | [...(opts.patterns || ["**/*"]), "!node_modules/**"], 27 | { 28 | cwd: dir, 29 | } 30 | ); 31 | 32 | const fileEntries = await Promise.all( 33 | files.map(async (fileName) => { 34 | if (opts.shouldIgnore?.(fileName)) { 35 | return; 36 | } 37 | return readFileWithMeta(dir, fileName, opts.noData); 38 | }) 39 | ); 40 | 41 | return fileEntries.filter(Boolean) as FileWithMeta[]; 42 | } 43 | 44 | export async function readFileWithMeta( 45 | dir: string, 46 | fileName: string, 47 | noData?: boolean 48 | ): Promise { 49 | try { 50 | const filePath = resolve(dir, fileName); 51 | 52 | const stats = await stat(filePath); 53 | if (!stats?.isFile()) { 54 | return; 55 | } 56 | 57 | return { 58 | name: fileName, 59 | data: noData ? undefined : await readFile(filePath), 60 | attrs: { 61 | mtime: stats.mtime.getTime(), 62 | size: stats.size, 63 | }, 64 | }; 65 | } catch (err) { 66 | console.warn( 67 | `[nuxt-build-cache] Failed to read file \`${fileName}\`:`, 68 | err 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ▣ Nuxt Build Cache 2 | 3 | > [!IMPORTANT] 4 | > This is a highly experimental project. Use at your own risk in production! 5 | 6 | ## ✨ Quick Start 7 | 8 | ```sh 9 | npx nuxi module add nuxt-build-cache 10 | ``` 11 | 12 | ## 💡 What does it do? 13 | 14 | By enabling this module, after a `nuxt build`, Nuxt collects build artifacts from `.nuxt/` dir into a tar file. On subsequent builds, if none of the relevant dependencies or your codes change, Nuxt will avoid the Vite/Webpack build step and simply restore the previous build results. 15 | 16 | This is particularly useful to speed up the CI/CD process when only prerendered content (from a CMS for example) or server routes are changed and can significantly speed up build speeds ([up to 2x!](https://twitter.com/_pi0_/status/1755333805349507100)). This is a similar feature [we introduced in Nuxt 2](https://nuxt.com/blog/nuxt-static-improvements). 17 | 18 | ### How does the module determine if a new build is required? 19 | 20 | We generate a hash of the current state during the build from various sources using [unjs/ohash](https://github.com/unjs/ohash) and then use this hash to store the build artifacts. (By default in `node_modules/.cache/nuxt/build/{hash}/`). This way each cache is unique to the project state it was built from. 21 | 22 | The hash is generated from your code and all Nuxt layers (that are not in `node_modules`): 23 | 24 | - Loaded config 25 | - Files in known Nuxt directories (`pages/`, `layouts/`, `app.vue`, ...) 26 | - Known project root files (`package.json`, `.nuxtrc`, `.npmrc`, package manager lock-file, ...) 27 | 28 | > [!NOTE] 29 | > File hashes are based on their size and content digest (murmurHash v3) 30 | 31 | > [!IMPORTANT] 32 | > Config layer hashes will be generated from the loaded value. 33 | > If you have a config like `{ date: new Date() }`, the cache will not work! But if you update a runtime value in `nuxt.config` (like an environment variable), it will be used as a source for your build hashes 👍 34 | 35 | ## ⚙️ Environment variables 36 | 37 | - `NUXT_DISABLE_BUILD_CACHE`: Disable the module entirely 38 | - `NUXT_IGNORE_BUILD_CACHE`: Skip restoring cache even if it exists 39 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile, mkdir, stat } from "node:fs/promises"; 2 | import { join } from "node:path"; 3 | import { existsSync } from "node:fs"; 4 | import { colorize } from "consola/utils"; 5 | import { isIgnored } from "@nuxt/kit"; 6 | import type { Nuxt } from "@nuxt/schema"; 7 | import { createTar, parseTar } from "nanotar"; 8 | import { hash, murmurHash, objectHash } from "ohash"; 9 | import { consola, readFilesRecursive } from "./utils"; 10 | import { provider, type ProviderName } from "std-env"; 11 | 12 | type HashSource = { name: string; data: any }; 13 | type Hashes = { hash: string; sources: HashSource[] }; 14 | 15 | const cacheDirs: Partial> & { default: string } = { 16 | default: "node_modules/.cache/nuxt/builds", 17 | cloudflare_pages: ".next/cache/nuxt", 18 | }; 19 | 20 | export async function getHashes(nuxt: Nuxt): Promise { 21 | if ((nuxt as any)._buildHash) { 22 | return (nuxt as any)._buildHash; 23 | } 24 | 25 | const hashSources: HashSource[] = []; 26 | 27 | // Layers 28 | let layerCtr = 0; 29 | for (const layer of nuxt.options._layers) { 30 | if (layer.cwd.includes("node_modules")) { 31 | continue; 32 | } 33 | const layerName = `layer#${layerCtr++}`; 34 | hashSources.push({ 35 | name: `${layerName}:config`, 36 | data: objectHash(layer.config), 37 | }); 38 | 39 | const normalizeFiles = ( 40 | files: Awaited> 41 | ) => 42 | files.map((f) => ({ 43 | name: f.name, 44 | size: (f.attrs as any)?.size, 45 | data: murmurHash(f.data as any /* ArrayBuffer */), 46 | })); 47 | 48 | const sourceFiles = await readFilesRecursive(layer.config?.srcDir, { 49 | shouldIgnore: isIgnored, // TODO: Validate if works with absolute paths 50 | patterns: [ 51 | ...Object.values({ 52 | ...nuxt.options.dir, 53 | ...layer.config.dir, 54 | }).map((dir) => `${dir}/**`), 55 | "app.{vue,js,ts,cjs,mjs}", 56 | "App.{vue,js,ts,cjs,mjs}", 57 | ], 58 | }); 59 | 60 | hashSources.push({ 61 | name: `${layerName}:src`, 62 | data: normalizeFiles(sourceFiles), 63 | }); 64 | 65 | const rootFiles = await readFilesRecursive( 66 | layer.config?.rootDir || layer.cwd, 67 | { 68 | shouldIgnore: isIgnored, // TODO: Validate if works with absolute paths 69 | patterns: [ 70 | ".nuxtrc", 71 | ".npmrc", 72 | "package.json", 73 | "package-lock.json", 74 | "yarn.lock", 75 | "pnpm-lock.yaml", 76 | "tsconfig.json", 77 | "bun.lockb", 78 | ], 79 | } 80 | ); 81 | 82 | hashSources.push({ 83 | name: `${layerName}:root`, 84 | data: normalizeFiles(rootFiles), 85 | }); 86 | } 87 | 88 | const res = ((nuxt as any)._buildHash = { 89 | hash: hash(hashSources), 90 | sources: hashSources, 91 | }); 92 | 93 | return res; 94 | } 95 | 96 | export async function getCacheStore(nuxt: Nuxt) { 97 | const hashes = await getHashes(nuxt); 98 | const cacheDir = join( 99 | nuxt.options.workspaceDir, 100 | cacheDirs[provider] || cacheDirs.default, 101 | hashes.hash 102 | ); 103 | const cacheFile = join(cacheDir, "nuxt.tar"); 104 | return { 105 | hashes, 106 | cacheDir, 107 | cacheFile, 108 | }; 109 | } 110 | 111 | export async function collectBuildCache(nuxt: Nuxt) { 112 | const { cacheDir, cacheFile, hashes } = await getCacheStore(nuxt); 113 | await mkdir(cacheDir, { recursive: true }); 114 | await writeFile( 115 | join(cacheDir, "hashes.json"), 116 | JSON.stringify(hashes, undefined, 2) 117 | ); 118 | 119 | const start = Date.now(); 120 | consola.start( 121 | `Collecting nuxt build cache \n - from \`${nuxt.options.buildDir}\`` 122 | ); 123 | const fileEntries = await readFilesRecursive(nuxt.options.buildDir, { 124 | patterns: ["**/*", "!analyze/**"], 125 | }); 126 | const tarData = await createTar(fileEntries); 127 | await _cfPagesHack(nuxt.options.workspaceDir); 128 | await writeFile(cacheFile, tarData); 129 | consola.success( 130 | `Nuxt build cache collected in \`${ 131 | Date.now() - start 132 | }ms\` \n - to \`${cacheDir}\`\n` + 133 | colorize("gray", fileEntries.map((e) => ` ▣ ${e.name}`).join("\n")) 134 | ); 135 | } 136 | 137 | export async function restoreBuildCache(nuxt: Nuxt): Promise { 138 | const { cacheFile, cacheDir } = await getCacheStore(nuxt); 139 | if (!existsSync(cacheFile)) { 140 | consola.info(`No build cache found \n - in \`${cacheFile}\``); 141 | return false; 142 | } 143 | const start = Date.now(); 144 | consola.start(`Restoring nuxt from build cache \n - from: \`${cacheDir}\``); 145 | const files = parseTar(await readFile(cacheFile)); 146 | for (const file of files) { 147 | const filePath = join(nuxt.options.buildDir, file.name); 148 | if (existsSync(filePath)) { 149 | const stats = await stat(filePath); 150 | if (stats.mtime.getTime() >= (file.attrs?.mtime || 0)) { 151 | consola.debug( 152 | `Skipping \`${file.name}\` (up to date or newer than cache)` 153 | ); 154 | continue; 155 | } 156 | } 157 | await mkdir(join(filePath, ".."), { recursive: true }); 158 | await writeFile(filePath, file.data!); 159 | } 160 | consola.success( 161 | `Nuxt build cache restored in \`${Date.now() - start}ms\` \n - into: \`${ 162 | nuxt.options.buildDir 163 | }\`` 164 | ); 165 | return true; 166 | } 167 | 168 | async function _cfPagesHack(dir: string) { 169 | // Hack clouflare pages while Nuxt is not supported 170 | if (provider === "cloudflare_pages") { 171 | const { readPackageJSON, writePackageJSON } = await import("pkg-types"); 172 | const pkg = await readPackageJSON(dir).catch(() => undefined); 173 | await writePackageJSON(join(dir, "package.json"), { 174 | ...pkg, 175 | devDependencies: { 176 | ...pkg?.devDependencies, 177 | next: "npm:just-a-placeholder@0.0.0", 178 | }, 179 | }); 180 | } 181 | } 182 | --------------------------------------------------------------------------------