From 32998686aa17ea63f8e4611cbdce2d6eba66839e Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 12 Nov 2026 23:13:58 +0000 Subject: [PATCH 1/2] Defer to the runtime when the recorded Next config has no env On Vercel, Next drops env from required-server-files.json, so a frozen build was read as not frozen and exited at boot. Fixes #1280. --- .bumpy/nextjs-vercel-frozen-boot.md | 5 + .../nextjs/src/next-env-compat.ts | 9 +- .../nextjs/test/frozen-boot-detection.test.ts | 151 ++++++++++++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 .bumpy/nextjs-vercel-frozen-boot.md diff --git a/.bumpy/nextjs-vercel-frozen-boot.md b/.bumpy/nextjs-vercel-frozen-boot.md new file mode 100644 index 000000000..65a8454ee --- /dev/null +++ b/.bumpy/nextjs-vercel-frozen-boot.md @@ -0,0 +1,5 @@ +--- +"@varlock/nextjs-integration": patch +--- + +Fix frozen builds exiting at boot on Vercel, where Next.js leaves the config `env` out of the build output diff --git a/packages/integrations/nextjs/src/next-env-compat.ts b/packages/integrations/nextjs/src/next-env-compat.ts index 7fac4c634..4592db8f9 100644 --- a/packages/integrations/nextjs/src/next-env-compat.ts +++ b/packages/integrations/nextjs/src/next-env-compat.ts @@ -492,7 +492,8 @@ type LoadedEnvConfig = { * freeze-into-bundle), read from the next config recorded in the build output: the standalone * server passes it in `__NEXT_PRIVATE_STANDALONE_CONFIG`, `next start` reads * `.next/required-server-files.json` (a custom distDir is caught later by the config plugin). - * `null` when the build did not freeze, `undefined` when the build output cannot be read. + * `null` when the build did not freeze, `undefined` when the build output cannot be read or + * does not record the config's `env`. */ function readFrozenBuildEnv(dir: string): string | null | undefined { try { @@ -506,8 +507,10 @@ function readFrozenBuildEnv(dir: string): string | null | undefined { if (!fs.existsSync(source)) return undefined; config = JSON.parse(fs.readFileSync(source, 'utf8')).config; } - if (!config) return undefined; - const frozenEnv = config.env?.[FROZEN_BUILD_ENV_KEY] ?? null; + // A recorded config with no `env` can't tell us whether the build froze: Next drops it on + // Vercel (`experimental.runtimeServerDeploymentId`), so defer to the runtime prelude + if (!config?.env) return undefined; + const frozenEnv = config.env[FROZEN_BUILD_ENV_KEY] ?? null; // lets the config plugin check this was the build actually being served if (frozenEnv) process.env[FROZEN_BOOT_SOURCE_ENV_KEY] = source; return frozenEnv; diff --git a/packages/integrations/nextjs/test/frozen-boot-detection.test.ts b/packages/integrations/nextjs/test/frozen-boot-detection.test.ts index 9efbf47d4..ad5b971ae 100644 --- a/packages/integrations/nextjs/test/frozen-boot-detection.test.ts +++ b/packages/integrations/nextjs/test/frozen-boot-detection.test.ts @@ -4,6 +4,8 @@ import path from 'node:path'; import { describe, it, expect, beforeEach, afterEach, vi, } from 'vitest'; +import { encryptEnvBlobSync } from 'varlock/encrypt-env'; +import { STANDALONE_CONFIG_SOURCE } from '../src/freeze-into-bundle'; const execSyncVarlock = vi.fn(); vi.mock('varlock/exec-sync-varlock', () => ({ @@ -28,8 +30,11 @@ const ENV_KEYS = [ 'NEXT_PRIVATE_BUILD_WORKER', 'NEXT_PHASE', '__NEXT_PRIVATE_STANDALONE_CONFIG', + '_VARLOCK_ENV_KEY', ]; +const TEST_KEY = 'a'.repeat(64); + const graph = (value: string, extra?: object) => JSON.stringify({ basePath: '/proj', sources: [], settings: {}, config: { APP_VAR: { value, isSensitive: false } }, ...extra, }); @@ -100,3 +105,149 @@ describe('frozen boot detection', () => { expect(execSyncVarlock).not.toHaveBeenCalled(); }); }); + +describe('server boot without the varlock CLI', () => { + let exitSpy: ReturnType; + let errorSpy: ReturnType; + beforeEach(() => { + // build-time defines (see tsdown.config.mts) + vi.stubGlobal('__VARLOCK_INTEGRATION_NAME__', '@varlock/nextjs-integration'); + vi.stubGlobal('__VARLOCK_INTEGRATION_VERSION__', '0.0.0-test'); + process.argv = ['node', path.join(projectDir, 'server.js')]; + (process.env as Record).NODE_ENV = 'production'; + execSyncVarlock.mockImplementation(() => { + throw new Error('Unable to find varlock executable'); + }); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as any); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + afterEach(() => { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + vi.unstubAllGlobals(); + }); + + function writeRecordedConfig(config: object) { + fs.writeFileSync(path.join(projectDir, '.next', 'required-server-files.json'), JSON.stringify({ config })); + } + + it('exits with the hint when the recorded config shows the build did not freeze', async () => { + writeRecordedConfig({ env: { OTHER: 'x' } }); + + await expect(loadEnvConfigFresh()).rejects.toThrow('process.exit called'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy.mock.calls.flat().join('\n')).toContain('no resolved env reached this Next.js server'); + }); + + // Next drops `env` from required-server-files.json on Vercel + // (`experimental.runtimeServerDeploymentId`), so its absence says nothing about freezing + it('defers to the runtime when the recorded config has no env', async () => { + writeRecordedConfig({ distDir: '.next' }); + + const result = await loadEnvConfigFresh(); + expect(exitSpy).not.toHaveBeenCalled(); + expect(result.parsedEnv).toEqual({}); + expect(process.env.__VARLOCK_NEXT_FROZEN_BOOT_SOURCE).toBeUndefined(); + }); + + it('defers to the runtime when the standalone config has no env', async () => { + process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify({ distDir: '.next' }); + + const result = await loadEnvConfigFresh(); + expect(exitSpy).not.toHaveBeenCalled(); + expect(result.parsedEnv).toEqual({}); + }); + + // e.g. a custom distDir, which checkFrozenBootSource catches later + it('defers to the runtime when there is no recorded config', async () => { + fs.rmSync(path.join(projectDir, '.next', 'required-server-files.json')); + + const result = await loadEnvConfigFresh(); + expect(exitSpy).not.toHaveBeenCalled(); + expect(result.parsedEnv).toEqual({}); + }); + + it('defers to the runtime when the recorded config is unparseable', async () => { + fs.writeFileSync(path.join(projectDir, '.next', 'required-server-files.json'), '{ not json'); + + const result = await loadEnvConfigFresh(); + expect(exitSpy).not.toHaveBeenCalled(); + expect(result.parsedEnv).toEqual({}); + }); +}); + +describe('booting from a frozen build', () => { + let exitSpy: ReturnType; + let errorSpy: ReturnType; + let warnSpy: ReturnType; + beforeEach(() => { + process.argv = ['node', path.join(projectDir, 'server.js')]; + (process.env as Record).NODE_ENV = 'production'; + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as any); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + afterEach(() => { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + const loadedValue = () => JSON.parse(process.env.__VARLOCK_ENV!).config.APP_VAR.value; + + it('a standalone server boots from the frozen env in its config', async () => { + // required-server-files.json still holds an older frozen env; the standalone config wins + process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify({ + env: { _VARLOCK_FROZEN_BUNDLE_ENV: graph('standalone-value', { frozen: true }) }, + }); + + await loadEnvConfigFresh(); + expect(loadedValue()).toBe('standalone-value'); + expect(process.env.__VARLOCK_NEXT_FROZEN_BOOT_SOURCE).toBe(STANDALONE_CONFIG_SOURCE); + expect(execSyncVarlock).not.toHaveBeenCalled(); + }); + + it('decrypts an encrypted frozen env with _VARLOCK_ENV_KEY', async () => { + process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify({ + env: { _VARLOCK_FROZEN_BUNDLE_ENV: encryptEnvBlobSync(graph('secret-value', { frozen: true }), TEST_KEY) }, + }); + process.env._VARLOCK_ENV_KEY = TEST_KEY; + + await loadEnvConfigFresh(); + expect(loadedValue()).toBe('secret-value'); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('exits when the frozen env is encrypted and _VARLOCK_ENV_KEY is not set', async () => { + process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify({ + env: { _VARLOCK_FROZEN_BUNDLE_ENV: encryptEnvBlobSync(graph('secret-value', { frozen: true }), TEST_KEY) }, + }); + + await expect(loadEnvConfigFresh()).rejects.toThrow('process.exit called'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy.mock.calls.flat().join('\n')).toContain('_VARLOCK_ENV_KEY is not set'); + expect(execSyncVarlock).not.toHaveBeenCalled(); + }); + + it('ignores and warns about an ambient __VARLOCK_ENV from a different freeze', async () => { + process.env.__VARLOCK_ENV = graph('ambient-value', { freezeId: 'other' }); + + await loadEnvConfigFresh(); + expect(loadedValue()).toBe('old-build-value'); + expect(warnSpy.mock.calls.flat().join('\n')).toContain('__VARLOCK_ENV set in the environment is ignored'); + }); + + it('does not warn about an ambient __VARLOCK_ENV from the same freeze', async () => { + const frozen = graph('frozen-value', { frozen: true, freezeId: 'same' }); + process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify({ env: { _VARLOCK_FROZEN_BUNDLE_ENV: frozen } }); + process.env.__VARLOCK_ENV = frozen; + + await loadEnvConfigFresh(); + expect(loadedValue()).toBe('frozen-value'); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); From 3ae39aac4603a7828ccb6ca076562288fb7d689f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 13 Nov 2026 00:05:32 +0000 Subject: [PATCH 2/2] Update traced file hashes for rewritten Turbopack runtime files Turbopack records each traced file's content hash in its .nft.json traces before we inject the prelude, so the recorded hash of [turbopack]_runtime.js was the same across builds whose frozen env differed. Next's adapter passes those hashes to the platform (Vercel's .vc-config.json fileHashes). --- .bumpy/nextjs-vercel-frozen-boot.md | 2 +- .../nextjs/src/turbopack-runtime-inject.ts | 57 +++++++++++++ .../test/turbopack-runtime-inject.test.ts | 81 +++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 packages/integrations/nextjs/test/turbopack-runtime-inject.test.ts diff --git a/.bumpy/nextjs-vercel-frozen-boot.md b/.bumpy/nextjs-vercel-frozen-boot.md index 65a8454ee..1a727250e 100644 --- a/.bumpy/nextjs-vercel-frozen-boot.md +++ b/.bumpy/nextjs-vercel-frozen-boot.md @@ -2,4 +2,4 @@ "@varlock/nextjs-integration": patch --- -Fix frozen builds exiting at boot on Vercel, where Next.js leaves the config `env` out of the build output +Fix frozen builds exiting at boot on Vercel, where Next.js leaves the config `env` out of the build output. Rewritten Turbopack runtime files now also get matching content hashes in the build's file traces. diff --git a/packages/integrations/nextjs/src/turbopack-runtime-inject.ts b/packages/integrations/nextjs/src/turbopack-runtime-inject.ts index 4b4fbc58c..dfb6f5c7f 100644 --- a/packages/integrations/nextjs/src/turbopack-runtime-inject.ts +++ b/packages/integrations/nextjs/src/turbopack-runtime-inject.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { getRuntimePrelude } from './freeze-into-bundle'; @@ -8,6 +9,60 @@ function debug(...args: Array) { console.log('[varlock]', ...args); } +// rewritten file -> hash of its new contents +const injectedFileHashes = new Map(); + +/** Same width as Turbopack's own NFT hashes (128 bits, hex); consumers treat them as opaque */ +function contentHash(source: string) { + return createHash('sha256').update(source).digest('hex').slice(0, 32); +} + +function updateNftFile(nftPath: string) { + let nft: { files?: Array, fileHashes?: Array }; + try { + nft = JSON.parse(fs.readFileSync(nftPath, 'utf8')); + } catch { + return; + } + const { files, fileHashes } = nft; + if (!Array.isArray(files) || !Array.isArray(fileHashes)) return; + let changed = false; + files.forEach((file, i) => { + const newHash = injectedFileHashes.get(path.resolve(path.dirname(nftPath), file)); + if (newHash && fileHashes[i] !== newHash) { + fileHashes[i] = newHash; + changed = true; + } + }); + if (changed) { + fs.writeFileSync(nftPath, JSON.stringify(nft)); + debug(`updated traced file hashes in: ${nftPath}`); + } +} + +/** + * Turbopack records a content hash for every traced file in its `.nft.json` traces + * (`fileHashes`), and Next's adapter hands those to the deploy platform (Vercel writes them + * into each function's `.vc-config.json`). They are written at emit, before we rewrite the + * runtime files, so they describe the original file, which is identical across builds whose + * frozen env differs. Point them at the rewritten contents so nothing keyed on them can treat + * a new build's runtime file as an old one. + */ +function updateNftHashesForInjectedFiles(nextDirPath: string) { + if (!injectedFileHashes.size) return; + const walk = (dir: string) => { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + // the build cache holds no traces and can be large + if (entry.isDirectory()) { + if (entryPath !== path.join(nextDirPath, 'cache')) walk(entryPath); + } else if (entry.name.endsWith('.nft.json')) updateNftFile(entryPath); + } + }; + walk(nextDirPath); +} + /** * Inject varlock init bundle into turbopack runtime files (analogous to webpack runtime injection). * Turbopack writes `[turbopack]_runtime.js` files during compilation — these are loaded by @@ -71,8 +126,10 @@ export function injectVarlockInitIntoTurbopackRuntime(nextDirPath: string) { origSource, ].join('\n'); fs.writeFileSync(targetFile, updatedSource); + injectedFileHashes.set(path.resolve(targetFile), contentHash(updatedSource)); debug(`injected init bundle into: ${targetFile}`); } + updateNftHashesForInjectedFiles(nextDirPath); } export function isInjectedTurbopackRuntime() { diff --git a/packages/integrations/nextjs/test/turbopack-runtime-inject.test.ts b/packages/integrations/nextjs/test/turbopack-runtime-inject.test.ts new file mode 100644 index 000000000..8f8d2be95 --- /dev/null +++ b/packages/integrations/nextjs/test/turbopack-runtime-inject.test.ts @@ -0,0 +1,81 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + describe, it, expect, beforeEach, afterEach, vi, +} from 'vitest'; + +let prelude = '/* prelude */'; +vi.mock('../src/freeze-into-bundle', () => ({ getRuntimePrelude: () => prelude })); + +const RUNTIME_REL = '../chunks/ssr/[turbopack]_runtime.js'; +const ORIGINAL_RUNTIME_HASH = '14457deed6fc841610caa86a32f782bf'; +const OTHER_HASH = 'ef6ac6914a3f24c9f4a98105a3bdd310'; + +let nextDir: string; +beforeEach(() => { + nextDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-next-runtime-inject-')); + fs.mkdirSync(path.join(nextDir, 'server', 'chunks', 'ssr'), { recursive: true }); + fs.mkdirSync(path.join(nextDir, 'server', 'pages'), { recursive: true }); + fs.writeFileSync(path.join(nextDir, 'server', 'chunks', 'ssr', '[turbopack]_runtime.js'), '/* turbopack runtime */'); + fs.writeFileSync(path.join(nextDir, 'server', 'pages', 'other.js'), '/* other */'); +}); +afterEach(() => { + fs.rmSync(nextDir, { recursive: true, force: true }); +}); + +function writeTrace(name: string, trace: object) { + const tracePath = path.join(nextDir, 'server', 'pages', `${name}.js.nft.json`); + fs.writeFileSync(tracePath, JSON.stringify(trace)); + return tracePath; +} +const readTrace = (tracePath: string) => JSON.parse(fs.readFileSync(tracePath, 'utf8')); + +async function injectFresh() { + vi.resetModules(); + const { injectVarlockInitIntoTurbopackRuntime } = await import('../src/turbopack-runtime-inject'); + injectVarlockInitIntoTurbopackRuntime(nextDir); +} + +describe('traced file hashes of the rewritten turbopack runtime', () => { + it('replaces the stale hash of the runtime file and leaves other entries alone', async () => { + const tracePath = writeTrace('page', { + version: 1, + files: [RUNTIME_REL, 'other.js'], + fileHashes: [ORIGINAL_RUNTIME_HASH, OTHER_HASH], + }); + + await injectFresh(); + const trace = readTrace(tracePath); + expect(trace.files).toEqual([RUNTIME_REL, 'other.js']); + expect(trace.fileHashes[0]).toMatch(/^[0-9a-f]{32}$/); + expect(trace.fileHashes[0]).not.toBe(ORIGINAL_RUNTIME_HASH); + expect(trace.fileHashes[1]).toBe(OTHER_HASH); + }); + + // e.g. a redeploy where only an env value changed: Turbopack's original runtime file is + // identical, so only the injected prelude tells the builds apart + it('gives builds with different frozen envs different hashes', async () => { + const trace = { version: 1, files: [RUNTIME_REL], fileHashes: [ORIGINAL_RUNTIME_HASH] }; + + prelude = '/* frozen env A */'; + const tracePath = writeTrace('page', trace); + await injectFresh(); + const hashA = readTrace(tracePath).fileHashes[0]; + + fs.writeFileSync(path.join(nextDir, 'server', 'chunks', 'ssr', '[turbopack]_runtime.js'), '/* turbopack runtime */'); + prelude = '/* frozen env B */'; + writeTrace('page', trace); + await injectFresh(); + expect(readTrace(tracePath).fileHashes[0]).not.toBe(hashA); + }); + + it('leaves traces without fileHashes (webpack) untouched', async () => { + const original = JSON.stringify({ version: 1, files: [RUNTIME_REL] }); + const tracePath = path.join(nextDir, 'server', 'pages', 'page.js.nft.json'); + fs.writeFileSync(tracePath, original); + + await injectFresh(); + expect(fs.readFileSync(tracePath, 'utf8')).toBe(original); + }); +});