spa.js treats host 404-fallback (200 + index.html) as successful navigation — broken links silently render the homepage at a phantom URL; build emits no 404.html #73

Closed
opened 2026-07-03 00:28:59 +00:00 by hugooconnor · 0 comments
hugooconnor commented 2026-07-03 00:28:59 +00:00 (Migrated from codeberg.org)

Summary

When a static zetl build is deployed to a host with SPA-style 404 fallback (e.g. Cloudflare Pages with no 404.html in the output), spa.js navigate() treats the fallback response as a successful navigation: any broken/stale link fetches the homepage HTML with HTTP 200, swap() renders it, and pushState leaves the phantom URL in the address bar. The user silently gets the wrong page at a wrong URL instead of any visible failure.

Two zetl-side gaps combine to cause this:

  1. spa.js navigate() only checks r.ok — it never verifies that the document it fetched is actually the page it asked for.
  2. zetl build emits no 404.html — Cloudflare Pages (and similar hosts) interpret its absence as "SPA mode" and serve /index.html with 200 for every unknown path, so !r.ok can never trip.

Follow-up to #70: this is what made #70 so confusing to diagnose in production. On a fallback host the compounded URLs never visibly 404 — navigation just "goes to the homepage" at URLs like /papers/concepts/<slug>/index.html, with no error in console or UI. Any future bug (or plain dead link — zetl stats already counts dead_links) gets the same silent masking.

Reproduction (live, zetl 0.9.0 build on Cloudflare Pages)

Observed on https://agent-comms.anuna.io (built pre-#70-fix, so stale sidebar links provide the broken hrefs; but any dead link reproduces the masking on a current build):

  1. Load https://agent-comms.anuna.io/concepts/adaptive-protocols/.
  2. Click a paper in the persistent sidebar → SPA-navigates to /papers/security/security-applications-of-formal-language-theory/index.html (correct).
  3. Click the "Adaptive Protocols" concept in the (stale, #70) sidebar → resolves to /papers/concepts/adaptive-protocols/index.html — a path that does not exist.
  4. fetch() gets the Cloudflare Pages fallback: 200 with the homepage document (<title>index — Agent Communications</title>). swap() renders the homepage, pushState keeps the phantom URL.
$ curl -s -o /dev/null -w "%{http_code}" https://agent-comms.anuna.io/papers/concepts/adaptive-protocols/index.html
200        # ← homepage HTML, not a 404

Playwright trace of step 3–4:

{"url": "https://agent-comms.anuna.io/papers/concepts/adaptive-protocols/index.html",
 "title": "index — Agent Communications"}

Expected

  • A navigation to a URL that doesn't correspond to a real page should fail loudly (hard navigation so the host's real 404/redirect behaviour is visible), not render homepage content under a phantom URL.
  • A static build deployed to a fallback host should produce a real 404, i.e. zetl build should emit a 404.html so hosts like Cloudflare Pages don't switch to SPA fallback mode.

Suggested fix

  1. In navigate()/swap(), verify the fetched document matches the requested slug before swapping — e.g. compare the parsed doc's data-slug (or a <link rel="canonical">/<meta name="zetl:slug"> emitted per page) against the target slug derived from url; on mismatch, location.href = url instead of swapping.
  2. Emit dist/404.html in zetl build (rendered with the shell, absolute asset URLs since it can be served at any depth).

Either alone helps; both together close the hole (1 protects SPA nav on hosts that still 200-fallback, 2 restores honest status codes for full loads and crawlers).

Environment

  • zetl 0.9.0 build artifacts observed live; navigate() in themes/default/static/spa.js at HEAD (v0.9.2, 830638a) still has the same if (!r.ok) throw r.status check only, and zetl build at HEAD still emits no 404.html.
  • Host: Cloudflare Pages (no 404.html in upload → automatic index.html fallback with 200).
## Summary When a static `zetl build` is deployed to a host with SPA-style 404 fallback (e.g. Cloudflare Pages with no `404.html` in the output), `spa.js` `navigate()` treats the fallback response as a successful navigation: any broken/stale link fetches the **homepage HTML with HTTP 200**, `swap()` renders it, and `pushState` leaves the phantom URL in the address bar. The user silently gets the wrong page at a wrong URL instead of any visible failure. Two zetl-side gaps combine to cause this: 1. **`spa.js` `navigate()` only checks `r.ok`** — it never verifies that the document it fetched is actually the page it asked for. 2. **`zetl build` emits no `404.html`** — Cloudflare Pages (and similar hosts) interpret its absence as "SPA mode" and serve `/index.html` with **200** for every unknown path, so `!r.ok` can never trip. Follow-up to #70: this is what made #70 so confusing to diagnose in production. On a fallback host the compounded URLs never visibly 404 — navigation just "goes to the homepage" at URLs like `/papers/concepts/<slug>/index.html`, with no error in console or UI. Any future bug (or plain dead link — `zetl stats` already counts `dead_links`) gets the same silent masking. ## Reproduction (live, zetl 0.9.0 build on Cloudflare Pages) Observed on https://agent-comms.anuna.io (built pre-#70-fix, so stale sidebar links provide the broken hrefs; but any dead link reproduces the masking on a current build): 1. Load `https://agent-comms.anuna.io/concepts/adaptive-protocols/`. 2. Click a paper in the persistent sidebar → SPA-navigates to `/papers/security/security-applications-of-formal-language-theory/index.html` (correct). 3. Click the "Adaptive Protocols" concept in the (stale, #70) sidebar → resolves to `/papers/concepts/adaptive-protocols/index.html` — a path that does not exist. 4. `fetch()` gets the Cloudflare Pages fallback: **200** with the homepage document (`<title>index — Agent Communications</title>`). `swap()` renders the homepage, `pushState` keeps the phantom URL. ``` $ curl -s -o /dev/null -w "%{http_code}" https://agent-comms.anuna.io/papers/concepts/adaptive-protocols/index.html 200 # ← homepage HTML, not a 404 ``` Playwright trace of step 3–4: ```json {"url": "https://agent-comms.anuna.io/papers/concepts/adaptive-protocols/index.html", "title": "index — Agent Communications"} ``` ## Expected * A navigation to a URL that doesn't correspond to a real page should fail loudly (hard navigation so the host's real 404/redirect behaviour is visible), not render homepage content under a phantom URL. * A static build deployed to a fallback host should produce a real 404, i.e. `zetl build` should emit a `404.html` so hosts like Cloudflare Pages don't switch to SPA fallback mode. ## Suggested fix 1. In `navigate()`/`swap()`, verify the fetched document matches the requested slug before swapping — e.g. compare the parsed doc's `data-slug` (or a `<link rel="canonical">`/`<meta name="zetl:slug">` emitted per page) against the target slug derived from `url`; on mismatch, `location.href = url` instead of swapping. 2. Emit `dist/404.html` in `zetl build` (rendered with the shell, absolute asset URLs since it can be served at any depth). Either alone helps; both together close the hole (1 protects SPA nav on hosts that still 200-fallback, 2 restores honest status codes for full loads and crawlers). ## Environment * zetl 0.9.0 build artifacts observed live; `navigate()` in `themes/default/static/spa.js` at HEAD (v0.9.2, `830638a`) still has the same `if (!r.ok) throw r.status` check only, and `zetl build` at HEAD still emits no `404.html`. * Host: Cloudflare Pages (no `404.html` in upload → automatic index.html fallback with 200).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
anuna-research/zetl#73
No description provided.