SSG / e2e Test-Server Mistakes
Generated from
.claude/rules/anti-patterns.md(the same source as MCPget_anti_patterns). Each entry is a real mistake + its fix; where a detector code is listed, the linter /pyreon doctor/ MCPvalidatecatches it automatically.
vite preview for SSG e2e gates that read per-route prerendered HTML
vite preview does SPA fallback — for ANY URL that doesn't map to a literal file in dist/, it serves dist/index.html. Correct for SPA builds; wrong for SSG. A test that page.goto('/cs/posts') then asserts on prerendered content (post-items rendered by the loader at SSG time, inline window.__PYREON_LOADER_DATA__ script, per-route meta tags, anything emitted into the route-specific HTML) will silently read the HOME page HTML instead, because vite preview routes the request to dist/index.html. Symptoms look like genuine framework bugs: "useLoaderData returns undefined post-hydration", "meta tags missing", "post-items not rendering" — the data IS in the prerendered file, the framework hydration IS correct, but the test never sees that file. PR #516 documented this as a "loader-data hydration known gap" for two weeks before investigation revealed the actual bug was vite preview's SPA fallback. Fix: use scripts/serve-ssg.ts (directory-rewriting static server matching how real static hosts serve SSG output: /cs/posts → dist/cs/posts/index.html). Real static hosts (Netlify, Cloudflare Pages, GitHub Pages, S3+CloudFront, nginx + try_files) do this rewriting natively; vite preview does not. Apply this rule to any SSG e2e gate that exercises content under a non-root prefix — i18n route duplication, dynamic-route enumeration, subpath deploys (the existing ssg-subpath gate is exempt because its specs only check RouterLink-href + asset-URL prefixing, both of which vite preview serves correctly). Reference: scripts/serve-ssg.ts + e2e-configs/ssg-i18n.config.ts. The chain bun run --filter=… build && bun scripts/serve-ssg.ts <dist> <port> is the canonical Playwright webServer shape for these gates.
Keying an immutable cache on file EXTENSION instead of a hashed-asset PATH
Cache-Control: public, max-age=31536000, immutable is ONLY safe for content-hashed files (Vite renames them on every change, so a URL is permanently pinned to one byte-content). Vite content-hashes only files it emits under /assets/. Keying immutability on the EXTENSION (ext === '.js' || ext === '.css') instead of the path 1-year-immutable-caches a NON-hashed root file — public/sw.js (service worker), public/config.js, any unhashed .css — making a stale copy unevictable (a poisoned service worker is the classic deploy-breaker). This bit nodeAdapter / bunAdapter's emitted handlers from inception; the platform adapters (vercel/netlify) were already correct because they scope to /assets/(.*). Defense: immutable ONLY for pathname.startsWith('/assets/') (or the configured build.assetsDir); *.html → max-age=0, must-revalidate (prerendered pages change every deploy); everything else → a short revalidatable default. General rule: never infer "this file is content-hashed and safe to cache forever" from its extension — only from the fact that it lives in the build's hashed-output directory. The existing test CODIFIED the bug (asserted a root /app.js was immutable) — when a regression test encodes a behavior, double-check the behavior is actually correct before trusting it. Reference: packages/zero/zero/src/adapters/{node,bun}.ts; bisect-locked by the node + bun spawn-and-curl runtime-contract tests in adapters.test.ts (/assets/*.js immutable, root /sw.js NOT).
A shared-cache cacheability guard that keys ONLY on RESPONSE signals misses the confidentiality hazard that lives in the REQUEST (the ISR fail-safe-default instance, PR-S6)
createISRHandler (@pyreon/zero/server) defaults to keying its in-memory LRU by url.pathname + url.search. PR-S4 hardened isCacheable(res) to refuse Set-Cookie / Cache-Control: private|no-store|no-cache / Authorization response header / Vary: Cookie|Authorization — all RESPONSE signals. But a loader that READS the request's Cookie / Authorization and renders per-user HTML can return a plain 200 text/html with NONE of those markers (no Set-Cookie, no Vary) — so it was judged cacheable and stored under the URL alone → User A's personalized page served to the next anonymous visitor (the framework's own redirect() auth-gate loader example produces exactly this shape). The response-header checks are structurally blind to it because the hazard is that the REQUEST arrived with credentials, not any header the response set. Amplifier: background revalidation re-renders with the TRIGGERING user's cookies and store.sets under the shared key — an authed re-render poisons an anon entry. Fix (fail-safe default): thread the REQUEST into isCacheable(res, req); under the default / 'path-only' key (!hasCacheKey), refuse to cache when the request carried Cookie/Authorization UNLESS the response opts in with a whole-token Cache-Control: public. A truly-public page (no request credentials) still caches — that's ISR's whole point, and the escape hatches are an explicit cacheKey function (developer owns per-user keying → refusal skipped) or Cache-Control: public. Threading the request into the REVALIDATE call site too (isCacheable(finalRes, originalReq)) closes the amplifier: a credentialed background re-render is refused, so it can't overwrite the anon entry. General rule: a shared-cache cacheability decision must consider whether the REQUEST carried credentials, not only what the RESPONSE declared — a per-user render with a plain-200 response is the exact leak an all-response-signal guard cannot see (mirrors RFC 7234 §3.5: a credentialed request is private to a shared cache absent an explicit public marker). Companion: the auth-refusal warning fires ONCE per handler in production too (module-level WeakSet keyed on deriveKey, not NODE_ENV-gated — a live misconfiguration a CMS/webhook operator must see; the anti-patterns "production-only code paths must warn regardless of NODE_ENV" rule). The separate handler-init "no cacheKey configured" teaching warning stays dev-only. Reference: packages/zero/zero/src/isr.ts:isCacheable (the reqHasCredentials && !isExplicitlyPublic guard) + deriveKey + ISRConfig.cacheKey JSDoc; bisect-verified in tests/isr.test.ts "PR-S6: request-credential-aware fail-safe default" (revert the guard → Alice's HTML cached + served to anon; the amplifier spec → the anon entry reads Welcome alice). Original URL-only leak caught in M1.1; response-header hardening in PR-S4; request-credential fail-safe in PR-S6.
cp(src, dest) where dest is the same as, or a subdirectory of, src
Node's fs.cp throws ERR_FS_CP_EINVAL ("cannot copy … into itself" / "src and dest cannot be the same"). This bit ALL SIX zero deploy adapters (node/bun/static/vercel/netlify/cloudflare): the SSR plugin passes clientOutDir === outDir === distDir with the server bundle already at distDir/server, and every adapter did cp(clientOutDir, outDir/<subdir>) (a copy into its own subtree) + the node/bun cp(distDir/server, distDir/server) (same dir). Because the SSR plugin CATCHES adapter throws and does NOT rethrow (so a buggy adapter can't hide a successful bundle from CI), the failure was SILENT — the deploy artifact was never staged and node dist/index.js never existed, making mode: 'ssr' | 'isr' unrunnable end-to-end. Why it shipped: the adapter tests used a mock client dir DISTINCT from outDir, so they only ever hit the disjoint-cp branch — never the clientOutDir === outDir shape the real plugin produces. Defense: a materialize(src, dest, { preserve }) helper that branches on same-dir (no-op), dest-inside-src (copy top-level entries INDIVIDUALLY — each entry's source/dest are disjoint subtrees so no copy-into-self — preserving the originals so the flat outDir still serves under vite preview), disjoint (whole-dir copy); + a same-dir regression test for every adapter. General rule: any cp whose dest could be inside src (or equal) MUST resolve both paths and branch — a directory-tree copy where the destination is reachable from the source is never valid. And: a test that constructs adapter inputs with DISTINCT dirs when production passes the SAME dir tests the wrong shape. Reference: packages/zero/zero/src/adapters/stage.ts:materialize + tests/{stage,adapters}.test.ts (same-dir block). Caught when the artifact was finally run end-to-end.
Production SSR shipping the DEV client entry (/src/entry-client.ts)
createHandler defaults clientEntry to /src/entry-client.ts (the dev path Vite serves) + DEFAULT_TEMPLATE (no hashed <script>, no CSS <link>). A production server entry that calls createServer({ routes }) with no template/clientEntry therefore server-renders correctly but ships HTML referencing /src/entry-client.ts — which 404s in production (no Vite) → the page NEVER hydrates and has no styles. This was latent behind the cp-EINVAL bug (nobody could run the artifact). Defense: the SSR build copies the built client index.html (which carries the hashed <script> + CSS <link> + injection placeholders) → dist/server/template.html; createServer reads that sibling at runtime (new URL('./template.html', import.meta.url)) as the production template, and suppresses the dev client-entry injection via a new clientEntry: false handler option (the built template already references the hashed entry). Adapters copy the whole server dir, so the template travels everywhere. General rule: a production SSR handler's default client-entry / template are DEV values — the production build must supply the built template (with hashed asset refs) or the page server-renders but can't hydrate. "Server-renders" ≠ "works" — always verify hydration end-to-end (load the EMITTED server in a real browser + assert a JS-driven interaction), not just that the SSR HTML looks right. Reference: packages/zero/zero/src/entry-server.ts:readBuiltTemplate + packages/core/server/src/handler.ts (clientEntry: string | false); the ssr-node e2e gate runs node dist/index.js in real Chromium.
Silent SSG path collisions
ssgPlugin (@pyreon/zero/server) used to dedupe path collisions silently via the writtenPaths Set — two routes producing the same URL (static route overlapping getStaticPaths enumeration, two enumerators producing the same slug, etc.) appeared as "missing pages" in dist/ with no error and no signal pointing at the cause. The user's mental model said "I have a route for X" while the build silently dropped one of the duplicates. Defense: detectPathCollisions(paths) runs after resolvePaths; the build throws [Pyreon] SSG path collision — N URL(s) resolved by multiple routes: ... listing every collision sorted. General rule: any aggregation step that can produce duplicates (file enumeration, slug generation, URL resolution) MUST either (a) treat duplicates as a structural error and bail loudly, or (b) document the deduplication semantics + emit a manifest of dropped entries. Silent dedup is the worst-of-both — hides bugs AND looks like correct behavior. Reference: packages/zero/zero/src/ssg-plugin.ts:detectPathCollisions + formatPathCollisionError. Caught in M1.4.
Vite-plugin source changes invisible to running dev server
Vite's own config bundler hardcodes conditions: ["node"] and resolves Pyreon plugin packages (@pyreon/zero, @pyreon/vite-plugin) via the node condition → lib/. So src/vite-plugin.ts edits are INVISIBLE to the running dev server until lib/ is rebuilt — even though user runtime code (@pyreon/zero/server etc.) IS read from src/ via the bun condition through ssrLoadModule. This trap surfaced TWICE: (1) PR with route-filter fix where verify-modes failed with MISSING_EXPORT from a stale lib/ (closed by mtime-detection in scripts/bootstrap.ts), (2) M1.2 bisect verification where a stash-then-run produced "test still passes" results 3× before realizing the dev server was still serving the pre-stash lib/. Defense for bisect cycles (see .claude/rules/testing.md "Dev-server bisect" recipe): after reverting source for any plugin package, run bun run --filter='@pyreon/<package>' build + kill the dev server before re-running playwright. The recipe IS documented but it's easy to forget under the "plain git stash works for source files" intuition — plugin code is the exception.
Sequential manifest writes without atomicity in build pipelines
when a build step writes N adapter-consumed manifests sequentially (_redirects → _redirects.json → _pyreon-revalidate.json → ...), a SIGINT mid-flush leaves partial state — half the files point at the new build, half the old. Adapters polling the dist at deploy time see an inconsistent snapshot. Bare await writeFile(path, content) exposes the partial-write window (a reader that opens the file during the write sees a truncated body). Fix: write to a sibling <target>.tmp.<pid>.<seq> first, then rename — POSIX rename is atomic, readers see either the OLD file or the FULL new file, never a half-written body. Best-effort tmp cleanup on rename failure (try { unlink(tmp) } catch {}). Don't apply this to per-page HTML writes — they're individually-readable (no cross-file invariants) and the rename-per-page cost on large sites is significant. General rule: any build step that writes N files an external consumer reads as a coherent set MUST use atomic-rename per file. Reference: packages/zero/zero/src/ssg-plugin.ts:writeFileAtomic. Caught in M2.1.
Bare JSON.stringify(loaderData) for SSR data embedding
pre-M2.2 the four loader-data serialization sites (html.ts buildScripts*, ssg-plugin SSR entry, vite-plugin dev SSR) used JSON.stringify(data).replace(/<\//g, '<\\/'). Two failure modes hidden in plain sight: (1) a loader returning a Mongo/Prisma model with back-references intact throws Converting circular structure to JSON — opaque, doesn't name which route's loader is broken. (2) a loader returning { data, fn: () => {} } works by accident (JSON.stringify drops function VALUES), but the silence trains users to think loaders accept any shape. Fix: stringifyLoaderData(data) from @pyreon/router with a WeakSet cycle detector + key-path tracker. Strips functions / symbols silently (explicit drop covers array entries where JSON.stringify would otherwise produce null slots), throws [Pyreon] Loader returned circular reference at "<route-path>". Loaders must return JSON-serializable data... naming the offending route. General rule: any framework boundary that takes user data and serializes it to a string MUST own the serialization with a clear-error path. Bare JSON.stringify shifts the diagnostic burden to the user. Reference: packages/core/router/src/loader.ts:stringifyLoaderData. Caught in M2.2.
Dev-only console warnings on platform-adapter env-var failures
adapters like vercelAdapter.revalidate(path) read env vars (VERCEL_DEPLOYMENT_URL, VERCEL_REVALIDATE_TOKEN) at call time. Pre-M2.4 the missing-env warning was gated if (process.env.NODE_ENV !== 'production'). But adapters' revalidate() is invoked from PRODUCTION webhook handlers — a CMS triggers revalidate('/posts/1'), the env var was forgotten, regenerated: false is returned silently, no console output, no failure mode reported back, the user notices stale content hours later. General rule for production-only code paths: warnings about missing required config MUST fire regardless of NODE_ENV. Dedupe per-process via a module-level Set so a busy handler doesn't spam logs, but the FIRST call always surfaces the misconfiguration. Reference: packages/zero/zero/src/adapters/warn-missing-env.ts. Caught in M2.4.
Per-route convention as a CONVENTION rather than a scaffolded helper
vercelAdapter.revalidate(path) (PR I) introduced the /api/_pyreon-revalidate route convention but left it as documentation — users had to write the request-parsing + secret-validation + path-validation + revalidate-dispatch logic themselves. Two recurring foot-guns surfaced from real consumer attempts: (a) revalidation handlers that DIDN'T validate the path against the manifest, so a leaked VERCEL_REVALIDATE_TOKEN let an attacker revalidate arbitrary URLs (forcing the platform to re-render pages of their choice — minor cost-amplification + cache-pollution attack). (b) handlers that conflated env-var validation with secret validation, returning 200 for missing-env-var requests and confusing the CMS into thinking revalidation worked. General rule: when a convention has a CORRECT shape AND a SUBTLE security-relevant shape, ship a drop-in scaffold. Documentation + examples are not enough; the boilerplate IS the bug surface. Reference: packages/zero/zero/src/vercel-revalidate-handler.ts:vercelRevalidateHandler — validates POST + path + secret against env-var-or-misconfig-500 + manifest-or-404 explicitly. Caught + closed in M3.1.
Dynamic routes ([id].tsx, [...slug].tsx) silently skipped under mode: 'ssg' without getStaticPaths
fs-router's auto-detect step intentionally DOESN'T emit URLs for routes whose params can't be enumerated at build time. Without export const getStaticPaths, the route's dist/<concrete>/index.html is never created — user thinks their /posts/1 route is prerendered but production serves 404. The silence is correct framework behavior (no way to know concrete IDs without an enumerator) but the author confusion is severe. Defenses (3 layers ship in M3.A): (a) pyreon/missing-get-static-paths lint rule (warn) catches this at edit time, scoped to src/routes/ to avoid false positives on unrelated [bracket] filenames. (b) pyreon doctor --check-ssg audit catches it at CI time across the whole project tree. (c) Documentation in .claude/rules/anti-patterns.md (this entry). The rule is warn not error because dynamic routes in mode: 'ssr' / 'isr' legitimately don't need the enumerator — the user must consciously decide which mode the route uses. The --check-ssg audit now RESPECTS a per-route opt-out: a route that declares export const renderMode = 'spa' | 'ssr' | 'isr' (non-'ssg') is exempt — it has explicitly opted OUT of SSG prerendering, which is exactly the remedy the warning recommends. Pre-fix the audit only scanned for getStaticPaths and ignored renderMode, so it false-positived on the correctly-configured hybrid route it just told the user to write (the shipped instance: hn-clone's client-useQuery item/[id]/user/[id] in a mode: 'ssg' app). Inside mode: 'ssg' the only valid per-route override is 'spa' (a CSR shell served for any param); 'ssr'/'isr' are a separate build error (assertModesSupported). A DYNAMIC 'spa' route (item/[id]) can't be enumerated to a concrete dist/ file — the SSG build now auto-emits dist/404.html = the blank CSR shell so a direct /item/123 works on any static host (GitHub Pages / S3 / Netlify / Cloudflare / Firebase all serve 404.html for an unmatched path → the shell boots → the client router matches → the route renders; platform adapters ALSO emit _redirects /* → 200 for a 200 status). Emitted only when a dynamic spa route exists AND no _404.tsx already wrote dist/404.html (that page is itself a hydrating shell), gated by ssg.emit404. A STATIC 'spa' route gets its own per-path shell already; only DYNAMIC ones need the catch-all. (A strict no-fallback static server still 404s — but that's the universal SPA-on-static requirement, and no real host / adapter is that.) Reference: packages/core/compiler/src/ssg-audit.ts (the renderModeOverride audit exemption) + packages/zero/zero/src/ssg-plugin.ts (needsSpaFallbackShell + the dist/404.html CSR-shell emit) + tests/ssg-audit.test.ts + ssg-plugin.test.ts.
export const revalidate = TTL (non-literal) silently dropped from build-time ISR manifest
PR I's extractLiteralExport only captures NumericLiteral (60, 3600) and false keyword. Identifier references (const TTL = 60; export const revalidate = TTL), arithmetic (30 * 60), function calls, template literals — all silently dropped. The route stays absent from dist/_pyreon-revalidate.json and adapter revalidate() calls return regenerated: false with no signal. Defense (3 layers ship in M3.A): (a) pyreon/revalidate-not-pure-literal lint rule (error) catches at edit time. (b) pyreon doctor --check-ssg audit catches at CI time. (c) Documentation. The rule is error because there's no scenario where non-literal revalidate exports work — the extractor's contract is "numeric literal OR false, anything else is dropped." Fix: inline the value (export const revalidate = 60).
export const loader = <non-callable> crashes SSR with TypeError: loader is not a function
fs-router treats loader as a callable invoked with LoaderContext. Users learning the API occasionally export loader = { data: 1 } (mistaking it for static data) or loader = await fetch(...) (resolving the value at module-eval time instead of per-request). The runtime crashes deep inside prefetchLoaderData with a stack trace that doesn't name the route. Defense (M3.5): pyreon/invalid-loader-export lint rule (error) catches non-callable shapes at edit time. Accepts arrow functions, function expressions, identifier references (can't resolve cross-module without type-check, defer to TS), call expressions (makeLoader(...) factory pattern), member expressions, TS cast expressions. Rejects object literals, string/numeric literals, array literals, etc. — anything that's structurally non-callable.
Convention-driven lint rules that scope by filename alone can false-positive on adjacent conventions
M3.5's pyreon/missing-get-static-paths originally fired on ANY dynamic-named file under src/routes/ (e.g. [id].tsx, [...slug].tsx). It correctly caught missing-enumerator on page routes — but surfaced as a false positive on examples/cpa-pw-blog/src/routes/api/echo/[...path].ts the moment the showcase landed. API routes are runtime-only by definition; fs-router invokes them per-request and never prerenders them, so getStaticPaths doesn't apply. The fix combines two skips for defense-in-depth: (a) path-based — any file under src/routes/api/ (fs-router's runtime-handler convention) is skipped; (b) export-shape — any file without export default is skipped (page routes structurally require a default-exported component; method-handler-only files like export function GET() are API routes by structure wherever they sit). The same fix is mirrored in auditSsg (@pyreon/compiler:detectDynamicRouteMissingGetStaticPaths) so pyreon doctor --check-ssg doesn't false-positive either. General rule for convention-driven detectors: when a convention's filename overlaps with an adjacent convention's filename, scope by BOTH path location AND structural signal (export shape, marker comments, etc.). Filename alone is insufficient — adjacent conventions WILL collide. Caught in M3.B when the showcase exercised the rule against a real-world repo for the first time; the rule had 22 specs at landing but none covered the API-route shape because no test fixture had matched it. General lesson: lint rules NEED a real-world consumer (an example app, a published project) exercising every convention they target — synthetic specs catch the happy paths the author thought of, real-world repos surface the structural collisions the author didn't.
Per-path-open-coded dispatch lets a typed enum branch go unimplemented
imagePlugin's PlaceholderStrategy typed 'dominant-color' from inception, but each of the three code paths (CDN / dev / build) open-coded generateBlurPlaceholder directly instead of dispatching on the configured strategy. Result: placeholder: 'dominant-color' silently produced a blur, and placeholder: 'none' was honored ONLY in the CDN path — build mode ignored it. Same typed-but-unimplemented bug class the audit-types gate exists to catch, but invisible to it because the field WAS referenced (the type was used at the config boundary; only the runtime branch was missing). Fix: a single generatePlaceholder(input, strategy, size) dispatcher that EVERY path calls — 'none' early-returns '', 'color' → generateColorPlaceholder, else blur. normalizePlaceholder collapses the deprecated 'dominant-color' alias → 'color' at the config boundary so the dispatcher only sees the resolved set. General rule: when a config enum has N branches, route every call site through ONE dispatcher keyed on the resolved enum — never open-code one branch at each site. A per-site default silently swallows every other branch, and the audit can't see it because the type IS referenced. Bisect-locked by the 'none' produces no placeholder regardless of input spec, which fails against the pre-dispatcher build path. Reference: packages/zero/zero/src/image-plugin.ts:generatePlaceholder + normalizePlaceholder.