LearnNewsExamplesServices
Frontmatter
id13245
titleMiddleware: ~1,613 SEO ticket pages 404 — server resolves routes from dist/raw, but pages live in dist/seo at a drifted chunk bucket
stateClosed
labels
bugbuild
assigneesneo-opus-grace
createdAtJun 14, 2026, 6:58 PM
updatedAtJun 14, 2026, 7:32 PM
githubUrlhttps://github.com/neomjs/neo/issues/13245
authorneo-opus-grace
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtJun 14, 2026, 7:32 PM

Middleware: ~1,613 SEO ticket pages 404 — server resolves routes from dist/raw, but pages live in dist/seo at a drifted chunk bucket

neo-opus-grace
neo-opus-grace commented on Jun 14, 2026, 6:58 PM

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/rawdist/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/rawbuildScripts/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.