stack/scripts/postinstall-patch-next-async-debug-info.mjs
Konstantin Wohlwend 560ee4c16e Fix memory leak
2026-04-18 22:21:05 -07:00

200 lines
6.6 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Why this script exists:
* - Next.js dev app-page runtimes (app-page*.runtime.dev.js) include an async debug hook
* (`async_hooks.createHook`) that captures async stack-trace metadata in hot paths.
* - In our backend dev workload (notably repeated email-queue-step requests), this hook
* causes measurable heap growth/retention in dev mode.
* - We disable that hook when STACK_DISABLE_REACT_ASYNC_DEBUG_INFO=true.
*
* Why we do this in postinstall:
* - The equivalent pnpm patch touched minified one-line bundles and produced a multi-MB
* patch file that is hard to review and noisy in diffs.
* - A strict install-time rewrite keeps the repo clean while still being deterministic:
* if assumptions no longer hold, we fail loudly instead of silently continuing.
*/
const LOG_PREFIX = "[patch-next-async-debug-info]";
const MIN_TARGET_NEXT_MAJOR = 16;
// We only patch app-page dev runtimes where this hook is present and relevant.
const APP_PAGE_RUNTIME_FILE_REGEX = /^app-page(?:-turbo)?(?:-experimental)?\.runtime\.dev\.js$/;
const HOOK_NEEDLE = "doNotLimit=new WeakSet;async_hooks.createHook(";
const GUARDED_HOOK =
"doNotLimit=new WeakSet,shouldEnableAsyncDebugInfo=\"true\"!==process.env.STACK_DISABLE_REACT_ASYNC_DEBUG_INFO;shouldEnableAsyncDebugInfo&&async_hooks.createHook(";
// Extra fingerprints reduce the chance of accidentally patching unrelated files.
const RUNTIME_FINGERPRINTS = [
"collectStackTracePrivate(",
"pendingOperations",
];
function fail(message) {
throw new Error(`${LOG_PREFIX} ${message}`);
}
function hasAllRuntimeFingerprints(content) {
return RUNTIME_FINGERPRINTS.every((fingerprint) => content.includes(fingerprint));
}
function patchRuntimeFile(filePath) {
const content = fs.readFileSync(filePath, "utf8");
const hasNeedle = content.includes(HOOK_NEEDLE);
const hasGuard = content.includes(GUARDED_HOOK);
if (!hasAllRuntimeFingerprints(content)) {
return { status: "ignored" };
}
if (hasNeedle && hasGuard) {
fail(`File ${filePath} contains both guarded and unguarded markers; refusing to continue.`);
}
if (!hasNeedle && !hasGuard) {
fail(`File ${filePath} no longer contains the expected async debug marker. Next.js internals likely changed.`);
}
// Already guarded => idempotent no-op.
if (hasGuard) {
return { status: "already" };
}
const needleCount = content.split(HOOK_NEEDLE).length - 1;
if (needleCount !== 1) {
fail(`File ${filePath} matched ${needleCount} unguarded markers (expected exactly 1).`);
}
const patchedContent = content.replace(HOOK_NEEDLE, GUARDED_HOOK);
if (patchedContent === content) {
fail(`File ${filePath} did not change after replacement.`);
}
if (patchedContent.includes(HOOK_NEEDLE)) {
fail(`File ${filePath} still contains unguarded marker after patch.`);
}
if (!patchedContent.includes(GUARDED_HOOK)) {
fail(`File ${filePath} is missing guarded marker after patch.`);
}
fs.writeFileSync(filePath, patchedContent);
return { status: "patched" };
}
function listInstalledNextServerDirs(repoRoot) {
const pnpmVirtualStoreDir = path.join(repoRoot, "node_modules", ".pnpm");
if (!fs.existsSync(pnpmVirtualStoreDir)) {
fail(`Missing ${pnpmVirtualStoreDir}. Run pnpm install before applying this patch.`);
}
const dirEntries = fs.readdirSync(pnpmVirtualStoreDir, { withFileTypes: true });
const nextServerDirs = dirEntries
.filter((entry) => entry.isDirectory() && entry.name.startsWith("next@"))
.map((entry) => {
const versionMatch = entry.name.match(/^next@(\d+)\./);
if (!versionMatch) {
return null;
}
const majorVersion = Number(versionMatch[1]);
// This guard targets current Next 16 dev runtimes only; older installed versions
// (e.g. transitive Next 14) may not contain the same runtime structure.
if (majorVersion < MIN_TARGET_NEXT_MAJOR) {
return null;
}
const nextServerDir = path.join(
pnpmVirtualStoreDir,
entry.name,
"node_modules",
"next",
"dist",
"compiled",
"next-server",
);
return fs.existsSync(nextServerDir) ? nextServerDir : null;
})
.filter((nextServerDir) => nextServerDir !== null);
if (nextServerDirs.length === 0) {
fail(`No installed Next.js runtimes with major >= ${MIN_TARGET_NEXT_MAJOR} found in node_modules/.pnpm.`);
}
return nextServerDirs;
}
function patchAllNextRuntimeDirs(repoRoot) {
const nextServerDirs = listInstalledNextServerDirs(repoRoot);
const summary = {
nextServerDirs: nextServerDirs.length,
candidateFiles: 0,
fingerprintedFiles: 0,
patchedFiles: 0,
alreadyPatchedFiles: 0,
};
for (const nextServerDir of nextServerDirs) {
const runtimeFiles = fs.readdirSync(nextServerDir)
.filter((fileName) => APP_PAGE_RUNTIME_FILE_REGEX.test(fileName))
.map((fileName) => path.join(nextServerDir, fileName));
if (runtimeFiles.length === 0) {
fail(`No app-page*.runtime.dev.js files found in ${nextServerDir}.`);
}
summary.candidateFiles += runtimeFiles.length;
let touchedFingerprintFileInDir = 0;
for (const runtimeFile of runtimeFiles) {
const result = patchRuntimeFile(runtimeFile);
if (result.status === "ignored") {
continue;
}
touchedFingerprintFileInDir += 1;
summary.fingerprintedFiles += 1;
if (result.status === "patched") {
summary.patchedFiles += 1;
} else if (result.status === "already") {
summary.alreadyPatchedFiles += 1;
} else {
fail(`Unexpected patch status "${result.status}" for ${runtimeFile}.`);
}
}
if (touchedFingerprintFileInDir === 0) {
fail(`Found app-page runtimes in ${nextServerDir}, but none matched expected async debug fingerprints.`);
}
}
if (summary.fingerprintedFiles === 0) {
fail("No runtime files matched expected async debug fingerprints.");
}
if (summary.patchedFiles === 0 && summary.alreadyPatchedFiles === 0) {
fail("Patch script completed without touching any files.");
}
return summary;
}
function main() {
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
const summary = patchAllNextRuntimeDirs(repoRoot);
// Emit a compact machine-readable summary for local debugging and CI logs.
console.log(
`${LOG_PREFIX} patched=${summary.patchedFiles} alreadyPatched=${summary.alreadyPatchedFiles} ` +
`fingerprinted=${summary.fingerprintedFiles} candidates=${summary.candidateFiles} nextDirs=${summary.nextServerDirs}`,
);
}
main();