Files
civitai__civitai/scripts/ci/assert-next-svg-patch-applied.mjs
T
Zachary Lowden f419a64461 chore(deps): delete the inert next@16.3.1 patch and correct the comments about it (#4086)
Next 16.3.1 is the first release containing upstream's SVG-loader fix (vercel/next.js#96681) — it ships 'VipsForeignLoadSvg' in the sharp.unblock list itself, in both the CJS and ESM image-optimizer copies. Our local patch was therefore inert: the installed files are byte-identical (sha256) to the published tarball, and the patch does not even apply (its hunk context wants Png immediately followed by Tiff; 16.3.1 has Svg between them).

It looked healthy because pnpm silently no-ops an already-applied patch — install exits 0, prints no warning, and still creates a patch_hash= virtual-store directory.

Deletes the patch and its patchedDependencies entry (@mantine/hooks untouched), regenerates the lockfile, and corrects three comments that asserted upstream still lacked the entry.

The CI guard stays: it asserts the outcome (the installed Next unblocks the SVG loader), so it keeps protecting against a future Next regressing this the way 16.3.0 did. Verified post-removal: guard green, still goes red when the loader entry is stripped from both installed copies, and /api/og renders PNGs on both paths on the preview build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:20:51 -05:00

153 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Blocking guard: the INSTALLED Next unblocks the libvips SVG loader that `next/og` needs.
*
* WHY THIS EXISTS AS A SCRIPT AND NOT ONLY AS A TEST. The behavioural regression guard for
* the 2026-08-08 `/api/og` outage lives in `src/tests/api/og.image-optimizer-sharp.test.ts`,
* which runs in the `Unit tests` job — and that job is `continue-on-error: true`, so it
* reports without gating. A guard that cannot fail a build is a notification. This is the
* same structural assertion in a form a blocking job can run in ~50ms: no database, no
* sharp, no browser, no network, no Next module graph — one file read per installed copy.
*
* WHAT IT ASSERTS, AND WHY THAT IS THE RIGHT INVARIANT. Next 16.3.0 applies a process-global
* libvips block on first image-optimizer use and re-enables only six RASTER loaders; SVG is
* not among them, so every `ImageResponse` in the process throws
* `Input buffer contains unsupported image format` from then on. Upstream fixed that in
* vercel/next.js#96681, and 16.3.1 is the first release that CONTAINS the fix: it ships
* `'VipsForeignLoadSvg'` in the unblock list itself. We carried the one-line fix as a pnpm
* patch only while we were on 16.3.0; there is no Next patch now, and this guard needs none.
*
* So what this protects against today is a FUTURE Next regressing the loader entry again —
* which is exactly why the assertion is deliberately "the installed Next unblocks SVG", NOT
* "our patch file is on disk" and NOT "we are on any particular version":
*
* - reading `node_modules` rather than `patches/` is the whole point. It is the only form
* that is correct both now (upstream supplies the entry, no patch exists) and in the
* patched past (a patch that exists but did not APPLY — unregistered key, failed hunk, a
* stale lockfile hash — is exactly the silent failure this catches, and a patch-file grep
* would sail through it);
* - staying version-agnostic means the guard does not have to be retired, re-pinned or
* re-dated in the same commit that bumps Next. It keeps passing on any Next that unblocks
* the loader, and goes red the moment one does not — whoever hits that decides between
* re-introducing a patch and pinning back.
*
* 🔴 One occurrence per copy is NOT evidence about WHO supplied it. The count is the same
* whether upstream ships the entry or a patch inserted it, so it cannot distinguish the two —
* reading it as proof of a working patch is how the now-deleted 16.3.1 patch was carried for
* a while after it had gone inert (pnpm silently no-ops an already-applied patch: rc=0, no
* warning, and it still creates a `patch_hash=…` virtual-store dir, so it LOOKS applied).
* To attribute the entry, compare the installed file against the pristine published tarball —
* on 16.3.1 they are byte-identical, so upstream is the source.
*
* BOTH BUILDS, SKIPPING WHAT IS ABSENT. Next ships this module twice — `dist/server/` (CJS)
* and `dist/esm/server/`. A full `pnpm install` has both; the production standalone image has
* only the CJS copy, because `@vercel/nft` never traces the ESM twin into the output. So an
* absent copy is skipped rather than failed, or this check would be wrong in one of the two
* environments it has to be correct in. Zero copies found is a HARD failure: that is the
* shape a vacuous pass would take, and a guard that reports OK having read nothing is worse
* than no guard.
*
* Usage: node scripts/ci/assert-next-svg-patch-applied.mjs
* Exit: 0 = every installed copy unblocks SVG · 1 = a copy is missing it, or none was found
*/
import { existsSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join, relative } from 'node:path';
const REQUIRED_LOADER = 'VipsForeignLoadSvg';
// Relative to the installed `next` package root. Order is reporting order only.
const COPIES = [
{ label: 'cjs', path: 'dist/server/image-optimizer.js' },
{ label: 'esm', path: 'dist/esm/server/image-optimizer.js' },
];
// The `sharp.unblock({ operation: [...] })` call in `getSharp()`. If Next ever restructures
// this the regex stops matching, and that is reported as a failure rather than shrugged off:
// "the shape I knew how to check is gone" must not read the same as "the check passed".
const UNBLOCK_CALL = /unblock\(\{\s*operation:\s*\[([^\]]*)\]/;
/**
* @returns {{ ok: boolean, checked: number, results: Array<{label: string, file: string,
* state: 'ok' | 'missing-loader' | 'no-unblock-call' | 'absent'}>, error?: string }}
*/
function checkInstalledNext() {
const require = createRequire(import.meta.url);
let nextRoot;
try {
nextRoot = dirname(require.resolve('next/package.json'));
} catch (error) {
return {
ok: false,
checked: 0,
results: [],
error:
'could not resolve the installed `next` package. This check has measured nothing — ' +
`install dependencies before running it. (${error.message})`,
};
}
const results = COPIES.map(({ label, path }) => {
const file = join(nextRoot, path);
if (!existsSync(file)) return { label, file, state: 'absent' };
const match = UNBLOCK_CALL.exec(readFileSync(file, 'utf8'));
if (!match) return { label, file, state: 'no-unblock-call' };
return { label, file, state: match[1].includes(REQUIRED_LOADER) ? 'ok' : 'missing-loader' };
});
const checked = results.filter((r) => r.state !== 'absent').length;
return {
ok: checked > 0 && results.every((r) => r.state === 'ok' || r.state === 'absent'),
checked,
results,
};
}
/** Human-readable lines; stdout is what a failing test surfaces as its message. */
function formatReport(report) {
const lines = [];
for (const { label, file, state } of report.results) {
const shown = relative(process.cwd(), file);
if (state === 'ok') lines.push(` ok ${label} ${shown} unblocks ${REQUIRED_LOADER}`);
else if (state === 'absent') lines.push(` skip ${label} ${shown} is not installed`);
else if (state === 'no-unblock-call')
lines.push(` FAIL ${label} ${shown} has no sharp.unblock({ operation: [...] }) call`);
else lines.push(` FAIL ${label} ${shown} does NOT unblock ${REQUIRED_LOADER}`);
}
lines.push(`copies of image-optimizer.js read: ${report.checked}`);
if (report.error) lines.push(`FAIL: ${report.error}`);
return lines;
}
const FAILURE_EXPLANATION =
`FAIL: the installed Next does not unblock the libvips SVG loader (${REQUIRED_LOADER}).\n` +
'Every `next/og` ImageResponse — i.e. all of /api/og — will return 500 as soon as anything\n' +
'touches the image optimizer in that process.\n' +
'Next >= 16.3.1 ships this loader entry itself (vercel/next.js#96681), and we carry no Next\n' +
'patch, so this failing means the Next you installed has REGRESSED it — or dependencies are\n' +
'stale. Re-run `pnpm install` and re-check; if the installed Next genuinely lacks the entry,\n' +
'pin Next back to a release that unblocks the loader, or re-introduce a pnpm patch adding\n' +
`'${REQUIRED_LOADER}' to the sharp.unblock({ operation: [...] }) list in BOTH\n` +
'dist/server/image-optimizer.js and dist/esm/server/image-optimizer.js. This reads\n' +
'node_modules, not patches/, so it judges the artifact you actually installed however the\n' +
'entry got there. See src/tests/api/og.image-optimizer-sharp.test.ts for the outage.';
const NO_COPIES_EXPLANATION =
'FAIL: found no copy of next/dist/**/server/image-optimizer.js to read, so this check ' +
'proved nothing. Either dependencies are not installed or Next moved the module.';
// Nothing imports this — the unit test spawns it as a subprocess so that it exercises the
// exact command CI runs, exit code included. Keep it that way: an importable copy of the
// assertion is a second copy of the assertion.
const report = checkInstalledNext();
for (const line of formatReport(report)) console.log(line);
if (!report.ok) {
console.error(`\n${report.checked === 0 ? NO_COPIES_EXPLANATION : FAILURE_EXPLANATION}`);
process.exit(1);
}
console.log(`\nOK: all ${report.checked} installed copy/copies unblock ${REQUIRED_LOADER}.`);