Skip to content

feat: add a webjs.trailingSlash policy that canonicalizes with a 308 - #291

Merged
vivek7405 merged 2 commits into
mainfrom
feat/trailing-slash-policy
Jun 3, 2026
Merged

feat: add a webjs.trailingSlash policy that canonicalizes with a 308#291
vivek7405 merged 2 commits into
mainfrom
feat/trailing-slash-policy

Conversation

@vivek7405

Copy link
Copy Markdown
Collaborator

Summary

Closes #255

A page reachable at both /about and /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's webjs.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. Explicit webjs.redirects rules run first, then slash canonicalization, so the two compose without a loop. Applied at the start of produce() before routing.

Test plan

  • Unit/integration (packages/server/test/redirects/trailing-slash.test.js, new, 15): never (/about/ -> 308 /about, /about stays, root stays, query preserved); always (/about -> 308 /about/, /about/ stays, /foo.js not 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.
  • Full unit suite 1855/1855.

Definition of done

  • Docs: root AGENTS.md (the webjs.trailingSlash config + default + recommendation + exemptions + order vs redirects), packages/server/AGENTS.md (module map), docs/app/docs/configuration/page.ts.
  • Dogfood: blog e2e 69/69; website / docs / ui-website boot 200 in prod mode (default ignore adds nothing; the docs / 307 is its own pre-existing app redirect), zero broken modulepreloads.
  • Browser/e2e: N/A because it is a server-side request-pipeline redirect, covered through createRequestHandler.
  • Version bump: N/A in this PR (release PR after merge).

Follow-up: a repeated-slash path like /about// collapses one slash per redirect (the common single-slash case settles in one hop).

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 vivek7405 self-assigned this Jun 3, 2026

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vivek7405
vivek7405 merged commit c9d7d9a into main Jun 3, 2026
5 checks passed
@vivek7405
vivek7405 deleted the feat/trailing-slash-policy branch June 3, 2026 05:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a trailing-slash policy that canonicalizes with a redirect

1 participant