Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255// Guards the app/(app)/(published) route group's cacheability invariant:// nothing in it may read request state (cookies/headers/identity) except the// explicitly identity-gated surfaces, which in turn must declare// `dynamic = "force-dynamic"` so a future revalidate/generateStaticParams on a// shared segment can't accidentally try to prerender them.//// Checks run here:// 1. No request-state read in the group's own route files (outside the gated// surfaces), and none in any module transitively reachable from a public// entry file — a helper under src/ or components/ that reads cookies is a// 500 on a prerendered route, and nothing else in CI would catch it.// 2. Every public page still opts into ISR (revalidate + generateStaticParams);// `revalidate` alone leaves the route fully dynamic, and losing either one// silently un-caches the page rather than failing anything.// 3. The gated surfaces declare force-dynamic and carry no caching config.// There are no known exceptions: members-only posts render their gated variant// for every viewer and entitled readers unlock the tail through the// getUnlockedPost action (a POST, not a render).import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";import { join, relative, dirname, resolve } from "node:path";
const ROOT = new URL("..", import.meta.url).pathname;const PUBLISHED = join(ROOT, "app/(app)/(published)");const PUB_DIR = "lish/[did]/[publication]";
// Route files allowed to read request state. Directories cover their subtree.const IDENTITY_GATED = [ `${PUB_DIR}/dashboard`, `${PUB_DIR}/edit`, `${PUB_DIR}/theme-settings`, `${PUB_DIR}/join`, `${PUB_DIR}/contributor_accept`, "lish/createPub", "lish/sorry-boris",];// Entry files of those surfaces, which must declare force-dynamic themselves.const MUST_FORCE_DYNAMIC = [ `${PUB_DIR}/dashboard/layout.tsx`, `${PUB_DIR}/edit/page.tsx`, `${PUB_DIR}/theme-settings/page.tsx`, `${PUB_DIR}/join/page.tsx`, `${PUB_DIR}/contributor_accept/page.tsx`, "lish/createPub/page.tsx", "lish/sorry-boris/page.tsx",];
const DYNAMIC_READS = /\bcookies\(|\bheaders\(|\bdraftMode\(|\bgetIdentityData\b|\bgetAuthIdentity\b|\bgetViewerIdentity\b|\bgetHomeLeaflet\b/;const CACHING_CONFIG = /export (const (revalidate|dynamicParams)\b|(async )?function generateStaticParams\b|const generateStaticParams\b)/;
let failures = [];
function* walk(dir) { for (const entry of readdirSync(dir)) { const p = join(dir, entry); if (statSync(p).isDirectory()) yield* walk(p); else if (/\.(ts|tsx)$/.test(entry)) yield p; }}
for (const file of walk(PUBLISHED)) { const rel = relative(PUBLISHED, file); const gated = IDENTITY_GATED.some( (d) => rel === d || rel.startsWith(d + "/"), ); const src = readFileSync(file, "utf8");
// Server-action modules run on POST, not during page render — identity // reads there don't affect cacheability. if (/^\s*["']use server["']/.test(src)) continue;
if (!gated && DYNAMIC_READS.test(src)) { for (const [i, line] of src.split("\n").entries()) { if (DYNAMIC_READS.test(line) && !line.trim().startsWith("//")) failures.push( `${rel}:${i + 1} reads request state in the (published) group: ${line.trim()}`, ); } }
// useSearchParams in a client component strips everything up to the nearest // Suspense boundary from statically-rendered HTML (and 500s without one). // Published-tree components read the query string via // src/hooks/useClientSearchParams instead, which keeps SSR param-free. if (!gated && /\buseSearchParams\b/.test(src)) { for (const [i, line] of src.split("\n").entries()) { if (/\buseSearchParams\b/.test(line) && !line.trim().startsWith("//")) failures.push( `${rel}:${i + 1} uses useSearchParams in the (published) group (use useClientSearchParams): ${line.trim()}`, ); } }
if (gated && CACHING_CONFIG.test(src)) failures.push( `${rel} declares caching config on an identity-gated surface`, );}
for (const rel of MUST_FORCE_DYNAMIC) { const src = readFileSync(join(PUBLISHED, rel), "utf8"); if (!/export const dynamic = "force-dynamic"/.test(src)) failures.push(`${rel} must declare dynamic = "force-dynamic"`);}
// The shared publication layout wraps identity-gated children; caching config// there would propagate to them.const sharedLayout = readFileSync( join(PUBLISHED, `${PUB_DIR}/layout.tsx`), "utf8",);if ( CACHING_CONFIG.test(sharedLayout) || /export const dynamic\b/.test(sharedLayout)) failures.push( `${PUB_DIR}/layout.tsx must not declare caching config (it propagates to identity-gated children); put revalidate/generateStaticParams on individual public pages`, );
// --- Every public page must still opt into ISR ---------------------------// `revalidate` on its own leaves a dynamic-param route fully dynamic, so both// halves have to be present. Only page.tsx: route handlers set their own cache// headers, and layouts must not carry caching config at all (checked above).for (const file of walk(PUBLISHED)) { const rel = relative(PUBLISHED, file); if (!/(^|\/)page\.tsx$/.test(rel)) continue; if (IDENTITY_GATED.some((d) => rel === d || rel.startsWith(d + "/"))) continue; const src = readFileSync(file, "utf8"); if (!/export const revalidate\b/.test(src)) failures.push( `${rel} is a public page but declares no revalidate (not ISR)`, ); if (!/generateStaticParams\b/.test(src)) failures.push( `${rel} is a public page but declares no generateStaticParams — revalidate alone leaves it dynamic`, );}
// --- Transitive reachability of request-state reads ----------------------// The per-file scan above only sees the route files themselves. A cookies()// read behind two levels of import is just as fatal on a prerendered route, so// walk the import graph from every public entry file.const SOURCE_ROOTS = [ "app", "components", "src", "actions", "contexts", "lexicons", "supabase",];const resolveCache = new Map();
function resolveImport(spec, fromFile) { const key = `${spec}\0${fromFile}`; if (resolveCache.has(key)) return resolveCache.get(key); let base; if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec); else if (SOURCE_ROOTS.some((r) => spec === r || spec.startsWith(r + "/"))) base = join(ROOT, spec); else base = null; // node_modules / unresolvable — not ours to check let found = null; if (base) { for (const cand of [ base, `${base}.ts`, `${base}.tsx`, join(base, "index.ts"), join(base, "index.tsx"), ]) { if (existsSync(cand) && statSync(cand).isFile()) { found = cand; break; } } } resolveCache.set(key, found); return found;}
// `import type` is erased at build time and carries no runtime reachability —// following it would pull in, say, every RPC handler via the one `Env` type// that get_standard_site_posts imports from its route module.const IMPORT_RE = /(?:^|\n)\s*(?:import|export)\s+(type\s+)?([\s\S]*?)from\s*["']([^"']+)["']/g;function importsOf(src, file) { const out = []; for (const m of src.matchAll(IMPORT_RE)) { if (m[1]) continue; const target = resolveImport(m[3], file); if (target) out.push(target); } return out;}
// Entry files of the public (non-gated) routes.const ENTRY_RE = /(page|layout|route|opengraph-image|icon|not-found|loading|sitemap|robots)\.(ts|tsx)$/;const entries = [...walk(PUBLISHED)].filter((f) => { const rel = relative(PUBLISHED, f); if (IDENTITY_GATED.some((d) => rel === d || rel.startsWith(d + "/"))) return false; return ENTRY_RE.test(rel);});
const visited = new Map(); // file -> chain that first reached itfor (const entry of entries) { const stack = [[entry, [relative(ROOT, entry)]]]; while (stack.length) { const [file, chain] = stack.pop(); if (visited.has(file)) continue; visited.set(file, chain); const src = readFileSync(file, "utf8"); // Two boundaries end the walk, because past either one nothing runs during // the server render of this page: // - "use server": the module is invoked as an RPC on POST. // - "use client": the module runs in the browser. cookies()/headers() are // not even callable there, and its identity reads are the client-side // SWR fetches this whole split is built on. Walking through client // components would drag in every server action they can call. if (/^\s*["']use (server|client)["']/m.test(src)) continue; const lines = src.split("\n"); for (const [i, line] of lines.entries()) { if (!DYNAMIC_READS.test(line) || line.trim().startsWith("//")) continue; // Reachability here is module-level: importing a module never runs it. // When the read lives in a function no published page calls, say so on // the line (or the one above) and why. if ( /published-purity-ok:/.test( lines.slice(Math.max(0, i - 3), i + 1).join("\n"), ) ) continue; failures.push( `${relative(ROOT, file)}:${i + 1} reads request state and is reachable from a public (published) page` + `\n via ${chain.join(" -> ")}\n ${line.trim()}` + `\n (if no published page can call this, annotate the line: published-purity-ok: <reason>)`, ); } for (const next of importsOf(src, file)) if (!visited.has(next)) stack.push([next, [...chain, relative(ROOT, next)]]); }}
if (failures.length) { console.error("published-purity check failed:\n" + failures.join("\n")); process.exit(1);}console.log( `published-purity check passed (${entries.length} public entries, ${visited.size} modules walked)`,);