Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/nextjs-vercel-frozen-boot.md
Original file line number Diff line number Diff line change
@@ -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. Rewritten Turbopack runtime files now also get matching content hashes in the build's file traces.
9 changes: 6 additions & 3 deletions packages/integrations/nextjs/src/next-env-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
57 changes: 57 additions & 0 deletions packages/integrations/nextjs/src/turbopack-runtime-inject.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,6 +9,60 @@ function debug(...args: Array<any>) {
console.log('[varlock]', ...args);
}

// rewritten file -> hash of its new contents
const injectedFileHashes = new Map<string, string>();

/** 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<string>, fileHashes?: Array<string | null> };
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
Expand Down Expand Up @@ -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() {
Expand Down
151 changes: 151 additions & 0 deletions packages/integrations/nextjs/test/frozen-boot-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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,
});
Expand Down Expand Up @@ -100,3 +105,149 @@ describe('frozen boot detection', () => {
expect(execSyncVarlock).not.toHaveBeenCalled();
});
});

describe('server boot without the varlock CLI', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
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<string, string>).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<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
process.argv = ['node', path.join(projectDir, 'server.js')];
(process.env as Record<string, string>).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();
});
});
81 changes: 81 additions & 0 deletions packages/integrations/nextjs/test/turbopack-runtime-inject.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading