Context
neomjs/middleware-v2 serves prerendered SEO pages to bots at /news/tickets/:id. Production (Cloud Run, the manually-pushed :local-bd062afdc June-13 image) 404s ~1,613 ticket pages whose prerendered HTML exists but is unreachable — e.g. https://neomjs.com/news/tickets/11516?ssr=true. (@tobiu reported it; 12679 serves while 11516 404s — the falsifier that pinned the cause.)
Root cause — dist/raw ↔ dist/seo chunk-bucket desync
The server resolves /news/tickets/:id by deriving the artifact bucket from the raw tree (dist/raw, via getTicketSubPath) and serving dist/seo/news/tickets/{rawBucket}/:id/index.html. But the two trees are built from independent neo snapshots:
dist/raw ← buildScripts/fetchContent.mjs clones neomjs/neo from GitHub (fresh dev HEAD).
dist/seo bucket placement ← src/build.mjs derives from node_modules/neo.mjs = the symlinked local ../neo workspace (a different checkout).
When an issue's chunk bucket shifts between those snapshots (v13 archival relocating it to archive/issues/v13.0.0/chunk-N, or corpus growth crossing chunk-100 boundaries), the trees disagree — and the incremental, lastmod-keyed build (build.mjs --chunk N) never relocates the stale page. The server looks in the raw-derived bucket and misses the page sitting at the seo bucket.
Impact (reproduced)
Replaying the server's exact resolution over the corpus: 9,170 issues → 7,556 resolve · 1,613 desync (page exists, wrong bucket → 404) · 1 no page. ~18% of ticket pages unreachable. The content exists; it is orphaned at stale paths. Examples: 11516 raw archive/issues/v13.0.0/chunk-11 vs seo issues/chunk-13; 9486 raw issues/chunk-1 vs seo issues/chunk-2.
Fix (this ticket)
Resolve /news/tickets/:id against an index of the dist/seo tree the server actually serves (id → real artifact sub-path) instead of a bucket re-derived from the parallel raw corpus. Drift-proof by construction; the archive bucket wins on duplicates (post-archival canonical). New buildSeoTicketIndex() helper + a server resolution swap.
Verified: 1,613 → 0 (9,169/9,170 resolve; the 1 holdout genuinely has no generated page), zero regressions (every route the old logic resolved still resolves).
Follow-ups (separate tickets)
- Single-source coherence:
fetchContent + build derive from the same neo snapshot (not one-clone-one-symlink) so the trees can't diverge at the source.
- Git/deploy model: gitignore
dist/ (regenerable build output — kills repo bloat + committed-stale artifacts), generate-on-deploy, and emit a coherent sitemap / route-manifest as the published source of truth.
Refs #12964.
Context
neomjs/middleware-v2serves prerendered SEO pages to bots at/news/tickets/:id. Production (Cloud Run, the manually-pushed:local-bd062afdcJune-13 image) 404s ~1,613 ticket pages whose prerendered HTML exists but is unreachable — e.g.https://neomjs.com/news/tickets/11516?ssr=true. (@tobiu reported it;12679serves while11516404s — the falsifier that pinned the cause.)Root cause —
dist/raw↔dist/seochunk-bucket desyncThe server resolves
/news/tickets/:idby deriving the artifact bucket from the raw tree (dist/raw, viagetTicketSubPath) and servingdist/seo/news/tickets/{rawBucket}/:id/index.html. But the two trees are built from independent neo snapshots:dist/raw←buildScripts/fetchContent.mjsclonesneomjs/neofrom GitHub (fresh dev HEAD).dist/seobucket placement ←src/build.mjsderives fromnode_modules/neo.mjs= the symlinked local../neoworkspace (a different checkout).When an issue's chunk bucket shifts between those snapshots (v13 archival relocating it to
archive/issues/v13.0.0/chunk-N, or corpus growth crossing chunk-100 boundaries), the trees disagree — and the incremental,lastmod-keyed build (build.mjs --chunk N) never relocates the stale page. The server looks in the raw-derived bucket and misses the page sitting at the seo bucket.Impact (reproduced)
Replaying the server's exact resolution over the corpus: 9,170 issues → 7,556 resolve · 1,613 desync (page exists, wrong bucket → 404) · 1 no page. ~18% of ticket pages unreachable. The content exists; it is orphaned at stale paths. Examples:
11516rawarchive/issues/v13.0.0/chunk-11vs seoissues/chunk-13;9486rawissues/chunk-1vs seoissues/chunk-2.Fix (this ticket)
Resolve
/news/tickets/:idagainst an index of thedist/seotree the server actually serves (id → real artifact sub-path) instead of a bucket re-derived from the parallel raw corpus. Drift-proof by construction; thearchivebucket wins on duplicates (post-archival canonical). NewbuildSeoTicketIndex()helper + a server resolution swap.Verified: 1,613 → 0 (9,169/9,170 resolve; the 1 holdout genuinely has no generated page), zero regressions (every route the old logic resolved still resolves).
Follow-ups (separate tickets)
fetchContent+buildderive from the same neo snapshot (not one-clone-one-symlink) so the trees can't diverge at the source.dist/(regenerable build output — kills repo bloat + committed-stale artifacts), generate-on-deploy, and emit a coherent sitemap / route-manifest as the published source of truth.Refs #12964.