Fix: route /mr-/ and /v<X.Y.Z>/ to their own builds via serve.json
Summary
- Per-MR preview deploys (
/mr-<iid>/) and per-version archives (/v<X.Y.Z>/) onupload-workbench.toolforge.orgwere silently serving the root build's HTML, with the wrong asset hashes. - Fix: ship
public/serve.jsonthat Vite copies intodist/, where Toolforge'svercel/servecontainer reads it. Tworedirects(which run before rewrites inserve-handler) catch/mr-<digits>and/v<X.Y.Z>and 301 them to their explicit/<subdir>/index.htmlbefore the--singleSPA fallback can hijack them. - Unblocks the three sibling tasks parked in Needs input waiting on a working preview URL.
Phabricator
- T425851 — Merge Request live previeuw bug (Unbreak Now!)
- Unblocks (in Needs input, waiting on this fix to verify their own previews):
Root cause
Toolforge's static-files serving for <tool>.toolforge.org is vercel/serve -s (the SPA-fallback flag) — confirmed by the response shape (Files within static/v0.4.0/ listing HTML matches serve-handler's built-in template byte-for-byte; the Vary / Content-Disposition headers match too).
The -s flag prepends { source: "**", destination: "/index.html" } to the serve.json rewrites. Combined with the lookup order in serve-handler (early-stat-if-extname → rewrites → findRelated):
| URL | What happens |
|---|---|
/mr-9/ |
extname "" → skip early stat → rewrite ** matches → resolve /index.html (the root one) → serve root build HTML, referencing /assets/... (root paths). Looks like the live app, but it isn't the MR's build. |
/v0.4.0/ |
extname .0 (last dot wins) → early stat fires → directory listing. Listing has a clickable index.html, but cleanUrls strips .html, then trailing-slash normalisation puts you back at the listing. Loop. |
/mr-9/index.html |
cleanUrls 301 → /mr-9/index → 301 → /mr-9 → root SPA. Same dead end. |
The --exclude='/v*/' --exclude='/mr-*/' on the root deploy's rsync is correct — it preserves the per-MR / per-version directories under --delete. The build files land where they should. Only the routing is broken.
Fix
public/serve.json:
{
"cleanUrls": false,
"redirects": [
{ "source": "/mr-:iid(\\d+)", "destination": "/mr-:iid/index.html", "type": 301 },
{ "source": "/v:version(\\d+\\.\\d+\\.\\d+)", "destination": "/v:version/index.html", "type": 301 }
],
"rewrites": [
{ "source": "**", "destination": "/index.html" }
]
}
serve-handler runs shouldRedirect before applyRewrites, so the redirect fires first. The redirect target is a real file (<subdir>/index.html exists on disk after rsync), and cleanUrls: false keeps serve from stripping the .html back into the loop. After the second hop the per-subdir HTML is served with the right asset paths.
The trailing ** → /index.html rewrite preserves the root SPA fallback for unknown paths (so /foo still loads the live app); it's harmlessly redundant with the container's --single flag.
Pattern constraints ((\d+) for the IID, (\d+\.\d+\.\d+) for SemVer) keep the redirects narrow — /version-info or /mr-feedback still fall through to the root SPA.
Verification
SSH to Toolforge wasn't available in the subagent's environment (the local SSH agents on the Claude Code host were stale / had no identities, and the user's encrypted key needs a passphrase that can't be entered non-interactively). I also can't fetch this MR's own preview URL until after the fix lands — that's the chicken-and-egg the parent task flagged.
What I did instead:
-
Read
serve-handler's source (src/index.jsand@zeit/schemas/deployment/config-static.js) to confirm:-
redirectsare processed beforerewrites. -
--singlealways prepends its own**→/index.htmlrewrite (no override available from a config file). -
cleanUrlstoggles whether.htmlredirects fire at all. - The schema rejects unknown top-level keys (so no comment field; rationale is in the commit body and CHANGELOG).
-
-
Reproduced the production layout locally — built
dist/withVITE_BASE_PATH=/,VITE_BASE_PATH=/v0.4.0/, andVITE_BASE_PATH=/mr-9/, dropped them into a fixture mirroringwww/static/. -
Ran
serve-handlerdirectly against the fixture, both with the simulated--singlerewrite prepended and without it. All cases pass:Request Status Body /200 root index.html, /assets/.../mr-9/and/mr-9301 → /mr-9/index.html→ 200mr-9 build HTML, /mr-9/assets/.../v0.4.0/and/v0.4.0301 → /v0.4.0/index.html→ 200v0.4.0 build HTML, /v0.4.0/assets/.../mr-9/assets/index-…js200 real bundle bytes /foo(unknown SPA path)200 root index.html (SPA fallback preserved) /version-info,/mr-feedback200 (no 301) root index.html (constraints prevent false matches) -
Validated
dist/serve.jsonagainst the real@zeit/schemas/deployment/config-static.jsschema with AJV. Passes.
Once this MR's pipeline runs, the maintainer can confirm by:
curl -sIL https://upload-workbench.toolforge.org/mr-<this-iid>/
# expect: 301 -> /mr-<iid>/index.html -> 200, content-length ~520, etag != root etag
curl -s https://upload-workbench.toolforge.org/mr-<this-iid>/ | grep src=
# expect: src="/mr-<iid>/assets/index-<hash>.js" (not the root hash)
If the --single flag is somehow not in use on the deployed container, cleanUrls: false in our serve.json could surface previously-redirected /index URLs as 404s — there are no such URLs in this app's source (grep'd src/).
Test plan
-
CI's
deploy:mrjob for this MR finishes green. -
curl -sIL https://upload-workbench.toolforge.org/mr-<IID>/follows a single 301 to/mr-<IID>/index.htmland returns 200 with a content-length that doesn't match the rootindex.html's 510 bytes. -
curl -s https://upload-workbench.toolforge.org/mr-<IID>/ | grep src=shows the asset path under/mr-<IID>/assets/.... -
Live root
/is unaffected — same etag, same SPA behaviour for unknown paths. -
After merge:
/v<new-version>/resolves the same way as/mr-<IID>/.