mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
200 lines
6.6 KiB
JavaScript
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();
|