feat: add a webjs.trailingSlash policy that canonicalizes with a 308 - #291
Conversation
A page reachable at both /about and /about/ is duplicate content.
webjs.trailingSlash ('never' strips, 'always' adds, 'ignore' default
no-op) 308-redirects to the canonical form before routing, preserving
query and hash. Root is always exempt, 'always' skips file-looking paths
(/foo.js), and /__webjs/* is exempt. Explicit webjs.redirects run first,
then slash canonicalization, so the two compose without a loop. The
router already matches both forms, so this is purely an SEO
canonicalization and defaults to ignore (non-breaking).
Closes #255
vivek7405
left a comment
There was a problem hiding this comment.
The slash logic is right (308, root and file-path and /__webjs/* exempt, query and hash preserved, method preserved), but two things. The serious one: applyTrailingSlash builds the Location from the raw pathname, so a request to //attacker.com/ under never emits Location: //attacker.com, a protocol-relative redirect to a foreign origin (and /\evil.com/ normalizes the same way). The redirects helper guards this with percent-encoded captures; this one does not. Refusing to canonicalize a // or backslash path (return null) closes it. Second, the doc claim that redirects and trailing-slash compose without a loop is false: a redirect whose destination contradicts the slash policy ping-pongs forever, so it needs the same author-responsibility note the redirects section carries.
applyTrailingSlash built the Location from the raw pathname, so a request to //attacker.com/ (or /\evil.com/, which URL-normalizes the same) under trailingSlash:never emitted Location: //attacker.com, a protocol-relative redirect to a foreign origin. It now refuses to canonicalize a path that is not a single-leading-slash same-origin path (returns null, the router 404s it). Also correct the docs: a redirect destination that contradicts the slash policy loops forever, and loop avoidance is the author's responsibility (no server guard), matching the redirects section.
There was a problem hiding this comment.
Re-read after the guard. A path that is not a single-leading-slash same-origin path (// or backslash network-path references) now returns null and falls through to a 404 instead of emitting a cross-origin Location, and the trailing strip can never re-introduce a leading double slash, so the open redirect is closed across every vector. Normal /about/ still canonicalizes, and the docs now correctly say a contradicting redirect destination loops and that is the developer's responsibility. Good to merge.
Summary
Closes #255
A page reachable at both
/aboutand/about/is duplicate content that fragments SEO and the client-router cache. This adds a configurable trailing-slash policy that canonicalizes the URL with a permanent 308 redirect, cohesive with #254'swebjs.redirects.package.json"webjs": { "trailingSlash": ... }:'never'strips the trailing slash (/about/->/about),'always'adds one (/about->/about/),'ignore'(the default) no-ops. The router already matches both forms against the same route, so this is purely an SEO canonicalization and defaults to non-breaking. Most apps want'never'(documented).Rules: 308 (SEO equity transfers, a redirected POST stays a POST); root
/is always exempt;'always'skips file-looking paths (/foo.js);/__webjs/*is exempt; query + hash preserved. Explicitwebjs.redirectsrules run first, then slash canonicalization, so the two compose without a loop. Applied at the start ofproduce()before routing.Test plan
packages/server/test/redirects/trailing-slash.test.js, new, 15):never(/about/-> 308/about,/aboutstays, root stays, query preserved);always(/about-> 308/about/,/about/stays,/foo.jsnot redirected, root stays);ignore/absent (no canonicalization); the redirects-win-first interaction with no loop;/__webjs/*exempt; hash preserved; config normalization. Counterfactual: removing the canonicalization makes/about/render 200 instead of 308.Definition of done
AGENTS.md(thewebjs.trailingSlashconfig + default + recommendation + exemptions + order vs redirects),packages/server/AGENTS.md(module map),docs/app/docs/configuration/page.ts.ignoreadds nothing; the docs/307 is its own pre-existing app redirect), zero broken modulepreloads.createRequestHandler.Follow-up: a repeated-slash path like
/about//collapses one slash per redirect (the common single-slash case settles in one hop).