107 lines
3.9 KiB
JavaScript
107 lines
3.9 KiB
JavaScript
// Detects time/randomness primitives that destabilize `'use cache'` cache keys,
|
|
// which manifests as ISR write amplification when the cached output embeds a timestamp
|
|
// that changes per request.
|
|
//
|
|
// Triggers when a file contains the `'use cache'` directive AND uses `new Date(`,
|
|
// `Date.now(`, or `Math.random(` outside client-only hooks (useEffect / useCallback /
|
|
// useMemo). Replacing module-scope `new Date().getFullYear()` with a build-time
|
|
// `buildYear` constant, and removing dates passed as `'use cache'` function
|
|
// arguments, prevents repeated writes when the rendered output is otherwise stable.
|
|
|
|
import { lineOf } from '../util.mjs';
|
|
|
|
export const metadata = {
|
|
id: 'use-cache-date-stamp',
|
|
title: "new Date() / Date.now() / Math.random() inside a 'use cache' file",
|
|
severity: 'high',
|
|
billingDimension: 'isr',
|
|
trafficIndependent: false,
|
|
description:
|
|
"`'use cache'` memoizes by argument identity AND prerender output. A timestamp baked into the cached output (`new Date().getFullYear()` in a footer, `Date.now()` in a payload field) forces a fresh ISR write on every regeneration even when the underlying data is unchanged. Random values have the same failure mode.",
|
|
fix:
|
|
"Replace module-scope `new Date()` with a build-time constant (`const buildYear = new Date().getFullYear()`) or move per-request timestamps into a client component inside `useEffect`. Do not pass dates as arguments to `'use cache'` functions — they invalidate the cache every call.",
|
|
citations: [
|
|
'https://nextjs.org/docs/app/api-reference/directives/use-cache',
|
|
'https://nextjs.org/docs/app/api-reference/functions/cacheLife',
|
|
],
|
|
excludeGlobs: ['node_modules/**', '.next/**', 'dist/**', '__tests__/**', '**/*.test.*', '**/*.spec.*'],
|
|
includeGlobs: [
|
|
'**/page.{ts,tsx,js,jsx}',
|
|
'**/layout.{ts,tsx,js,jsx}',
|
|
'**/route.{ts,tsx,js,jsx}',
|
|
'**/lib/**/*.{ts,tsx,js,jsx}',
|
|
'**/app/**/*.{ts,tsx,js,jsx}',
|
|
'**/components/**/*.{ts,tsx,js,jsx}',
|
|
],
|
|
};
|
|
|
|
const USE_CACHE_RE = /^[\t ]*['"]use cache['"]/m;
|
|
const SUSPECT_RE = /\b(new Date\(|Date\.now\(|Math\.random\()/g;
|
|
// Client-only hooks that don't affect server-side cache keys.
|
|
const CLIENT_HOOK_RE = /\b(useEffect|useCallback|useMemo|useLayoutEffect)\s*\(/g;
|
|
|
|
export function scan({ files }) {
|
|
const out = [];
|
|
for (const { path, content } of files) {
|
|
if (!USE_CACHE_RE.test(content)) continue;
|
|
|
|
const clientHookRanges = collectRanges(content, CLIENT_HOOK_RE);
|
|
let match;
|
|
SUSPECT_RE.lastIndex = 0;
|
|
while ((match = SUSPECT_RE.exec(content)) !== null) {
|
|
if (isInsideAnyRange(match.index, clientHookRanges)) continue;
|
|
out.push({
|
|
pattern: metadata.id,
|
|
file: path,
|
|
line: lineOf(content, match.index),
|
|
evidence: match[0],
|
|
trafficIndependent: metadata.trafficIndependent,
|
|
subtype: classifySubtype(content, match.index),
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function collectRanges(content, hookRe) {
|
|
const ranges = [];
|
|
hookRe.lastIndex = 0;
|
|
let m;
|
|
while ((m = hookRe.exec(content)) !== null) {
|
|
const open = content.indexOf('(', m.index);
|
|
if (open < 0) continue;
|
|
const close = findMatchingParen(content, open);
|
|
if (close < 0) continue;
|
|
ranges.push([open, close]);
|
|
}
|
|
return ranges;
|
|
}
|
|
|
|
function findMatchingParen(content, openIdx) {
|
|
let depth = 0;
|
|
for (let i = openIdx; i < content.length; i++) {
|
|
const c = content[i];
|
|
if (c === '(') depth++;
|
|
else if (c === ')') {
|
|
depth--;
|
|
if (depth === 0) return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function isInsideAnyRange(idx, ranges) {
|
|
for (const [a, b] of ranges) {
|
|
if (idx >= a && idx <= b) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// `module-scope` if the suspect appears before the first function/class declaration.
|
|
// `in-cache-fn` otherwise (likely inside a render or helper function body).
|
|
function classifySubtype(content, idx) {
|
|
const head = content.slice(0, idx);
|
|
if (!/\bfunction\b|\bclass\b|=>\s*\{/.test(head)) return 'module-scope';
|
|
return 'in-cache-fn';
|
|
}
|