playbook/antigravity-awesome-skills/skills/vercel-optimize/lib/scanners/large-static-asset.mjs

93 lines
3.2 KiB
JavaScript

// Flag oversized assets under public/. Pure fs.stat — no parsing, no LLM.
// 500 KB threshold is where bandwidth/first-paint start to bite (Vercel
// Doctor's 4 KB triggers on favicons).
import { readdir, stat } from 'node:fs/promises';
import { join, relative, extname } from 'node:path';
const THRESHOLD_BYTES = 500_000;
const TOP_N = 20;
const SKIP_EXTENSIONS = new Set(['.html', '.txt', '.xml', '.json', '.webmanifest', '.ico']);
const SKIP_PATH_PREFIXES = ['.well-known/'];
export const metadata = {
id: 'large-static-asset',
title: 'Large file in public/',
severity: 'medium',
billingDimension: 'bandwidth',
trafficIndependent: true,
description:
'Static assets in `public/` over 500 KB ship as-is from the CDN. Whether the cost is meaningful depends on traffic, but the candidate is binary — the file is either needed at that size or it can be optimized (compressed image, video transcode, or moved off the critical path).',
fix:
'Verify the asset is reachable on the customer-facing hot path. Then choose: (a) compress (convert PNG → AVIF/WebP; transcode MP4 to lower bitrate); (b) host externally (Vercel Blob, S3, or a media CDN with per-asset signed URLs); (c) lazy-load (defer to client-side fetch instead of bundling into initial HTML).',
citations: [
'https://vercel.com/docs/manage-cdn-usage',
'https://vercel.com/docs/image-optimization',
],
excludeGlobs: ['node_modules/**', '.next/**', 'dist/**', '__tests__/**'],
includeGlobs: ['public/**/*'],
};
// Walks public/ directly because collectFiles only emits text-readable
// extensions — binary assets never reach the shared `files` array.
export async function scan({ rootDir }) {
if (!rootDir) return [];
const root = join(rootDir, 'public');
const out = [];
try {
for await (const entry of walk(root)) {
if (shouldSkip(entry.relPath)) continue;
if (entry.size < THRESHOLD_BYTES) continue;
out.push({
pattern: metadata.id,
file: join('public', entry.relPath),
line: 1,
evidence: `${formatBytes(entry.size)} (${extname(entry.relPath) || 'no-ext'})`,
trafficIndependent: metadata.trafficIndependent,
sizeBytes: entry.size,
});
}
} catch {
return [];
}
out.sort((a, b) => b.sizeBytes - a.sizeBytes);
return out.slice(0, TOP_N);
}
async function* walk(dir, base = '') {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = join(dir, e.name);
const rel = base ? `${base}/${e.name}` : e.name;
if (e.isDirectory()) {
yield* walk(full, rel);
continue;
}
if (!e.isFile()) continue;
try {
const s = await stat(full);
yield { relPath: rel, size: s.size };
} catch { /* skip unreadable */ }
}
}
function shouldSkip(relPath) {
if (SKIP_PATH_PREFIXES.some((p) => relPath.startsWith(p))) return true;
const ext = extname(relPath).toLowerCase();
if (SKIP_EXTENSIONS.has(ext)) return true;
return false;
}
function formatBytes(b) {
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB';
if (b >= 1e6) return (b / 1e6).toFixed(2) + ' MB';
if (b >= 1e3) return (b / 1e3).toFixed(1) + ' KB';
return b + ' B';
}