diff --git a/AGENTS.md b/AGENTS.md index 87864950b1315..d00760084ff7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -367,8 +367,7 @@ When running Next.js integration tests, you must rebuild if source files have ch - **First run after branch switch/bootstrap (or if unsure)?** → `pnpm build` - **Edited only core Next.js files (`packages/next/**`) after bootstrap?** → `pnpm --filter=next build` -- **Edited Turbopack (Rust)?** → `pnpm swc-build-native` -- **Edited both?** → `pnpm turbo build build-native` +- **Edited Next.js code or Turbopack (Rust)?** → `pnpm build` ## Development Anti-Patterns diff --git a/packages/next-swc/maybe-build-native.mjs b/packages/next-swc/maybe-build-native.mjs new file mode 100644 index 0000000000000..71dcd01c1e48c --- /dev/null +++ b/packages/next-swc/maybe-build-native.mjs @@ -0,0 +1,110 @@ +import { execSync } from 'child_process' +import { readdirSync, rmSync } from 'fs' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PKG_DIR = __dirname +const ROOT_DIR = join(__dirname, '../..') +const NATIVE_DIR = join(PKG_DIR, 'native') + +function hasExistingNativeBinary() { + try { + const files = readdirSync(NATIVE_DIR) + return files.some((f) => f.endsWith('.node')) + } catch { + return false + } +} + +function clearNativeBinaries() { + try { + const files = readdirSync(NATIVE_DIR) + for (const f of files) { + if (f.endsWith('.node')) { + rmSync(join(NATIVE_DIR, f)) + } + } + } catch { + // directory doesn't exist, nothing to clear + } +} + +function getVersionBumpCommit() { + try { + return ( + execSync( + `git log -1 --format=%H -G '"version":' -- packages/next/package.json`, + { cwd: ROOT_DIR, encoding: 'utf8' } + ).trim() || null + ) + } catch { + return null + } +} + +function hasRustChanges(sinceCommit) { + try { + // Omit HEAD to compare against the working tree, which includes + // committed, staged, and unstaged changes. + const diff = execSync( + `git diff --name-only ${sinceCommit} -- ':(glob)**/*.rs' ':(glob)**/*.toml' ':(glob).cargo/**' Cargo.lock rust-toolchain`, + { cwd: ROOT_DIR, encoding: 'utf8' } + ).trim() + return diff.length > 0 + } catch { + // If we can't determine whether changes occurred, assume they did + return true + } +} + +function buildNative() { + console.log('Running swc-build-native...') + execSync('pnpm run swc-build-native', { + cwd: ROOT_DIR, + stdio: 'inherit', + env: { + ...process.env, + CARGO_TERM_COLOR: 'always', + TTY: '1', + }, + }) +} + +function main() { + if (process.env.CI) { + console.log('Skipping swc-build-native in CI') + return + } + + const versionBumpCommit = getVersionBumpCommit() + + if (!versionBumpCommit) { + console.log( + 'Could not determine version bump commit (shallow clone?), building native to be safe...' + ) + buildNative() + return + } + + if (hasRustChanges(versionBumpCommit)) { + console.log( + 'Rust source files changed since last version bump, building native...' + ) + buildNative() + return + } + + // No Rust changes from the release version — clear any stale native build + // so the prebuilt @next/swc-* npm packages are used instead. + if (hasExistingNativeBinary()) { + console.log( + 'No Rust changes since last version bump, clearing stale native binary...' + ) + clearNativeBinaries() + } + + console.log('Skipping swc-build-native (no Rust changes since version bump)') +} + +main() diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index b93d3d64cfb2d..d321f237c5093 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -6,6 +6,7 @@ "native/" ], "scripts": { + "build": "node maybe-build-native.mjs", "clean": "node ../../scripts/rm.mjs native", "build-native": "napi build --platform -p next-napi-bindings --cargo-cwd ../../ --cargo-name next_napi_bindings --features plugin,image-extended --js false native", "build-native-release": "napi build --platform -p next-napi-bindings --cargo-cwd ../../ --cargo-name next_napi_bindings --release --features plugin,image-extended,tracing/release_max_level_trace --js false native", diff --git a/packages/next-swc/turbo.json b/packages/next-swc/turbo.json index dde214ce4d766..cdf18b5c8335d 100644 --- a/packages/next-swc/turbo.json +++ b/packages/next-swc/turbo.json @@ -2,6 +2,23 @@ "$schema": "https://turborepo.org/schema.json", "extends": ["//"], "tasks": { + "build": { + "inputs": [ + "../../.cargo/**", + "../../crates/**", + "../../turbopack/crates/**", + "../../Cargo.toml", + "../../Cargo.lock", + "../../.github/workflows/build_and_deploy.yml", + "../../rust-toolchain" + ], + "env": ["CI"], + "outputs": [ + "native/*.node", + "native/index.d.ts", + "../../packages/next/src/build/swc/generated-native.d.ts" + ] + }, "build-native": { "inputs": [ "../../.cargo/**", @@ -13,7 +30,7 @@ "../../rust-toolchain" ], "env": ["CI"], - "outputs": ["native/*.node"] + "outputs": ["native/*.node", "native/index.d.ts"] }, "build-native-release": { "inputs": [ @@ -26,7 +43,7 @@ "../../rust-toolchain" ], "env": ["CI"], - "outputs": ["native/*.node"] + "outputs": ["native/*.node", "native/index.d.ts"] }, "build-native-release-with-assertions": { "inputs": [ @@ -39,7 +56,7 @@ "../../rust-toolchain" ], "env": ["CI"], - "outputs": ["native/*.node"] + "outputs": ["native/*.node", "native/index.d.ts"] }, "build-native-no-plugin": { "inputs": [ @@ -52,7 +69,7 @@ "../../rust-toolchain" ], "env": ["CI"], - "outputs": ["native/*.node"] + "outputs": ["native/*.node", "native/index.d.ts"] }, "build-native-no-plugin-release": { "inputs": [ @@ -65,7 +82,7 @@ "../../rust-toolchain" ], "env": ["CI"], - "outputs": ["native/*.node"] + "outputs": ["native/*.node", "native/index.d.ts"] }, "build-wasm": { "inputs": [ @@ -104,7 +121,7 @@ "../../rust-toolchain" ], "env": ["CI"], - "outputs": ["native/*.node"] + "outputs": ["native/*.node", "native/index.d.ts"] }, "rust-check": { "dependsOn": ["rust-check-fmt", "rust-check-clippy", "rust-check-napi"] diff --git a/packages/next/cache.js b/packages/next/cache.js index d78dd4238f317..6df9e6531d5f2 100644 --- a/packages/next/cache.js +++ b/packages/next/cache.js @@ -1,22 +1,53 @@ -const cacheExports = { - unstable_cache: require('next/dist/server/web/spec-extension/unstable-cache') - .unstable_cache, +let cacheExports - updateTag: require('next/dist/server/web/spec-extension/revalidate') - .updateTag, +if (process.env.NEXT_RUNTIME === '') { + const notAvailableInClient = (name) => { + return function notAvailable() { + throw new Error(`\`${name}\` is only available in a Server Component.`) + } + } + + cacheExports = { + unstable_cache: function unstable_cache(cb) { + // Legacy behavior: allow importing/using unstable_cache from client bundles + // without pulling in server internals. + if (typeof cb !== 'function') return cb + return function cached() { + return cb.apply(this, arguments) + } + }, + unstable_noStore: function unstable_noStore() {}, + + updateTag: notAvailableInClient('updateTag'), + revalidateTag: notAvailableInClient('revalidateTag'), + revalidatePath: notAvailableInClient('revalidatePath'), + refresh: notAvailableInClient('refresh'), + cacheLife: notAvailableInClient('cacheLife'), + cacheTag: notAvailableInClient('cacheTag'), + } +} else { + // Keep server requires in this branch so browser builds can DCE them. + cacheExports = { + unstable_cache: + require('next/dist/server/web/spec-extension/unstable-cache') + .unstable_cache, + + updateTag: require('next/dist/server/web/spec-extension/revalidate') + .updateTag, - revalidateTag: require('next/dist/server/web/spec-extension/revalidate') - .revalidateTag, - revalidatePath: require('next/dist/server/web/spec-extension/revalidate') - .revalidatePath, + revalidateTag: require('next/dist/server/web/spec-extension/revalidate') + .revalidateTag, + revalidatePath: require('next/dist/server/web/spec-extension/revalidate') + .revalidatePath, - refresh: require('next/dist/server/web/spec-extension/revalidate').refresh, + refresh: require('next/dist/server/web/spec-extension/revalidate').refresh, - unstable_noStore: - require('next/dist/server/web/spec-extension/unstable-no-store') - .unstable_noStore, - cacheLife: require('next/dist/server/use-cache/cache-life').cacheLife, - cacheTag: require('next/dist/server/use-cache/cache-tag').cacheTag, + unstable_noStore: + require('next/dist/server/web/spec-extension/unstable-no-store') + .unstable_noStore, + cacheLife: require('next/dist/server/use-cache/cache-life').cacheLife, + cacheTag: require('next/dist/server/use-cache/cache-tag').cacheTag, + } } let didWarnCacheLife = false diff --git a/packages/next/src/server/stream-utils/uint8array-helpers.ts b/packages/next/src/server/stream-utils/uint8array-helpers.ts index 4d581f1e52dfc..48a05470b46e8 100644 --- a/packages/next/src/server/stream-utils/uint8array-helpers.ts +++ b/packages/next/src/server/stream-utils/uint8array-helpers.ts @@ -5,6 +5,14 @@ export function indexOfUint8Array(a: Uint8Array, b: Uint8Array) { if (b.length === 0) return 0 if (a.length === 0 || b.length > a.length) return -1 + // Use Node's native implementation when available. + if (typeof Buffer !== 'undefined') { + const haystack = Buffer.isBuffer(a) + ? a + : Buffer.from(a.buffer, a.byteOffset, a.byteLength) + return haystack.indexOf(b) + } + // start iterating through `a` for (let i = 0; i <= a.length - b.length; i++) { let completeMatch = true @@ -50,8 +58,8 @@ export function removeFromUint8Array(a: Uint8Array, b: Uint8Array) { if (tagIndex === 0) return a.subarray(b.length) if (tagIndex > -1) { const removed = new Uint8Array(a.length - b.length) - removed.set(a.slice(0, tagIndex)) - removed.set(a.slice(tagIndex + b.length), tagIndex) + removed.set(a.subarray(0, tagIndex)) + removed.set(a.subarray(tagIndex + b.length), tagIndex) return removed } else { return a diff --git a/scripts/build-native.ts b/scripts/build-native.ts index 6f3b757b6126e..734b01fc80798 100644 --- a/scripts/build-native.ts +++ b/scripts/build-native.ts @@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs' import path from 'node:path' import url from 'node:url' import execa from 'execa' -import { NEXT_DIR, logCommand, execFn } from './pack-util' +import { NEXT_DIR, logCommand } from './pack-util' const nextSwcDir = path.join(NEXT_DIR, 'packages/next-swc') @@ -25,10 +25,7 @@ export default async function buildNative( stdio: 'inherit', }) - await execFn( - 'Copy generated types to `next/src/build/swc/generated-native.d.ts`', - () => writeTypes() - ) + await writeTypes() } // Check if this file is being run directly @@ -56,17 +53,31 @@ async function writeTypes() { const generatedTypes = await fs.readFile(generatedTypesPath, 'utf8') let vendoredTypes = await fs.readFile(vendoredTypesPath, 'utf8') + const existingContent = vendoredTypes vendoredTypes = vendoredTypes.split(generatedTypesMarker)[0] vendoredTypes = vendoredTypes + generatedTypesMarker + generatedNotice + generatedTypes - await fs.writeFile(vendoredTypesPath, vendoredTypes) - - const prettifyCommand = ['prettier', '--write', vendoredTypesPath] + const prettifyCommand = ['prettier', '--stdin-filepath', vendoredTypesPath] logCommand('Prettify generated types', prettifyCommand) - await execa(prettifyCommand[0], prettifyCommand.slice(1), { - cwd: NEXT_DIR, - stdio: 'inherit', - preferLocal: true, - }) + const prettierResult = await execa( + prettifyCommand[0], + prettifyCommand.slice(1), + { + cwd: NEXT_DIR, + input: vendoredTypes, + preferLocal: true, + } + ) + vendoredTypes = prettierResult.stdout + if (!vendoredTypes.endsWith('\n')) { + vendoredTypes += '\n' + } + + if (vendoredTypes === existingContent) { + return + } + + logCommand('Write generated types', `write file`) + await fs.writeFile(vendoredTypesPath, vendoredTypes) } diff --git a/test/production/app-dir/browser-chunks/app/cache-client.tsx b/test/production/app-dir/browser-chunks/app/cache-client.tsx new file mode 100644 index 0000000000000..eb975b4772547 --- /dev/null +++ b/test/production/app-dir/browser-chunks/app/cache-client.tsx @@ -0,0 +1,14 @@ +'use client' + +import { unstable_cache } from 'next/cache' + +// Importing next/cache in a Client Component should not pull server internals +// into browser chunks. The bundler sets NEXT_RUNTIME='' for client builds, +// which allows cache.js to DCE the server require() branch. +const getCachedData = unstable_cache(async () => { + return { data: 'hello' } +}) + +export function CacheClient() { + return +} diff --git a/test/production/app-dir/browser-chunks/app/page.tsx b/test/production/app-dir/browser-chunks/app/page.tsx index ff7159d9149fe..334edb3a114af 100644 --- a/test/production/app-dir/browser-chunks/app/page.tsx +++ b/test/production/app-dir/browser-chunks/app/page.tsx @@ -1,3 +1,10 @@ +import { CacheClient } from './cache-client' + export default function Page() { - return

hello world

+ return ( +
+

hello world

+ +
+ ) } diff --git a/test/production/app-dir/browser-chunks/browser-chunks.test.ts b/test/production/app-dir/browser-chunks/browser-chunks.test.ts index 74b0d7a903170..44f1ba533694b 100644 --- a/test/production/app-dir/browser-chunks/browser-chunks.test.ts +++ b/test/production/app-dir/browser-chunks/browser-chunks.test.ts @@ -1,4 +1,25 @@ import { nextTestSetup } from 'e2e-utils' +import { join } from 'path' +import { readdir, readFile } from 'fs/promises' + +async function readFilesRecursive( + dir: string, + predicate: (filename: string) => boolean +): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const results: string[] = [] + + for (const entry of entries) { + const fullPath = join(dir, entry.name) + if (entry.isDirectory()) { + results.push(...(await readFilesRecursive(fullPath, predicate))) + } else if (predicate(entry.name)) { + results.push(await readFile(fullPath, 'utf8')) + } + } + + return results +} describe('browser-chunks', () => { const { next } = nextTestSetup({ @@ -6,14 +27,21 @@ describe('browser-chunks', () => { skipDeployment: true, }) - let sources = [] + let sources: string[] = [] + let jsContents: string[] = [] beforeAll(async () => { - const sourcemaps = await next.readFiles('.next/static/chunks', (filename) => + const chunksDir = join(next.testDir, '.next/static/chunks') + + const sourcemaps = await readFilesRecursive(chunksDir, (filename) => filename.endsWith('.js.map') ) - sources = sourcemaps.flatMap((sourcemap) => JSON.parse(sourcemap).sources) + + jsContents = await readFilesRecursive(chunksDir, (filename) => + filename.endsWith('.js') + ) }) + it('must not bundle any server modules into browser chunks', () => { const serverSources = sources.filter( (source) => @@ -62,4 +90,29 @@ describe('browser-chunks', () => { ) } }) + + it('must not pull server internals from next/cache into browser chunks', () => { + // When a Client Component imports from next/cache, the bundler should + // DCE the server require() branch (via process.env.NEXT_RUNTIME === '') + // and only include lightweight client stubs. Pre-compiled dist/ modules + // don't appear in sourcemaps, so we check the actual JS content. + const serverOnlyPatterns = [ + // IncrementalCache is a class from next/dist/server used by unstable_cache + 'IncrementalCache', + ] + + for (const pattern of serverOnlyPatterns) { + const chunksWithPattern = jsContents.filter((content) => + content.includes(pattern) + ) + + if (chunksWithPattern.length > 0) { + throw new Error( + `Found server-only pattern "${pattern}" in ${chunksWithPattern.length} browser chunk(s). ` + + `This likely means next/cache is pulling server internals into the client bundle. ` + + `Ensure the server require() calls in packages/next/cache.js are behind a DCE-able branch.` + ) + } + } + }) }) diff --git a/test/unit/stream-utils/uint8array-helpers.test.ts b/test/unit/stream-utils/uint8array-helpers.test.ts new file mode 100644 index 0000000000000..4705aa738af77 --- /dev/null +++ b/test/unit/stream-utils/uint8array-helpers.test.ts @@ -0,0 +1,42 @@ +import { + indexOfUint8Array, + isEquivalentUint8Arrays, + removeFromUint8Array, +} from 'next/dist/server/stream-utils/uint8array-helpers' + +describe('uint8array-helpers', () => { + it('finds the start index of a nested sequence', () => { + const haystack = new Uint8Array([1, 2, 3, 4, 5, 6]) + const needle = new Uint8Array([3, 4, 5]) + expect(indexOfUint8Array(haystack, needle)).toBe(2) + }) + + it('handles Buffer input as Uint8Array', () => { + const haystack = Buffer.from([9, 8, 7, 6, 5, 4]) + const needle = new Uint8Array([7, 6]) + expect(indexOfUint8Array(haystack, needle)).toBe(2) + }) + + it('returns -1 when not found', () => { + const haystack = new Uint8Array([1, 2, 3, 4]) + const needle = new Uint8Array([4, 5]) + expect(indexOfUint8Array(haystack, needle)).toBe(-1) + }) + + it('removes a matching sequence from the middle', () => { + const haystack = new Uint8Array([10, 20, 30, 40, 50]) + const needle = new Uint8Array([30, 40]) + + const result = removeFromUint8Array(haystack, needle) + + expect(isEquivalentUint8Arrays(result, new Uint8Array([10, 20, 50]))).toBe( + true + ) + }) + + it('returns original reference when no match exists', () => { + const haystack = new Uint8Array([1, 2, 3]) + const needle = new Uint8Array([4, 5]) + expect(removeFromUint8Array(haystack, needle)).toBe(haystack) + }) +}) diff --git a/turbopack/crates/turbopack-core/src/module.rs b/turbopack/crates/turbopack-core/src/module.rs index 9684ae1136b36..c667a1868e90b 100644 --- a/turbopack/crates/turbopack-core/src/module.rs +++ b/turbopack/crates/turbopack-core/src/module.rs @@ -15,11 +15,17 @@ pub enum StyleType { pub enum ModuleSideEffects { /// Analysis determined that the module evaluation is side effect free /// the module may still be side effectful based on its imports. + /// + /// This module might not be chunked after Turbopack performed a global analysis on the module + /// graph. ModuleEvaluationIsSideEffectFree, /// Is known to be side effect free either due to static analysis or some kind of configuration. /// ```js /// "use turbopack no side effects" /// ``` + /// + /// This module might not even be parsed (and thus chunked) if no other module depends on any of + /// its exports. SideEffectFree, // Neither of the above, so we should assume it has side effects. SideEffectful,