Skip to content

fix: enforce segment boundaries for basePath stripping and redirect prefixing#393

Merged
james-elicx merged 4 commits intocloudflare:mainfrom
JaredStowell:jstowell/fix-basepath-segment-boundary
Mar 10, 2026
Merged

fix: enforce segment boundaries for basePath stripping and redirect prefixing#393
james-elicx merged 4 commits intocloudflare:mainfrom
JaredStowell:jstowell/fix-basepath-segment-boundary

Conversation

@JaredStowell
Copy link
Copy Markdown
Contributor

@JaredStowell JaredStowell commented Mar 10, 2026

This fixes incorrect basePath handling when a path only shares a string prefix with the configured base path, instead of matching on a path-segment boundary.

Before this change, vinext treated any pathname beginning with the basePath string as being inside the base path. With basePath: "/app", that meant:

  • /app/about correctly stripped to /about
  • /app correctly stripped to /
  • but /application/about incorrectly stripped as well
  • and /app2 incorrectly stripped as well

That was not limited to request-path stripping. Redirect destination handling had the same bug: a destination like /application/about was incorrectly treated as already being under /app, so vinext would emit /application/about instead of /app/application/about.

In the server flow, the stripped value was fed back into URL/request handling, so /application/about could normalize to "/lication/about" and potentially collide with a real in-app route. In other words, a request outside the configured base path could be misrouted into an unrelated page if the suffix happened to exist.

The fix centralizes basePath handling into shared helpers:

  • hasBasePath() only matches when the pathname is exactly equal to the base path, or starts with basePath + "/"
  • stripBasePath() only strips in those cases

Implementation details:

  • added shared basePath helpers
  • updated shared request pipeline exports
  • updated Pages Router production request handling
  • updated Pages Router dev redirect handling
  • updated client-side App Router pathname stripping
  • updated client-side Pages Router pathname stripping
  • updated App Router redirect generation
  • updated generated Cloudflare Pages Router worker template redirect handling

Test coverage added:

  • unit regression coverage for exact-match, descendant-match, and shared-prefix non-matches
  • nested basePath coverage such as /docs/v2 vs /docs/v20
  • template/codegen assertions for the worker entry
  • App Router generator assertions for redirect prefixing
  • HTTP integration regressions covering:
    • shared-prefix collision protection, proving /application/about must not resolve to /app/lication/about
    • redirect destination prefixing, proving /app/redir correctly redirects to /app/application/about

Validation run:

  • pnpm test tests/features.test.ts -t "basePath HTTP routing"
  • pnpm test tests/request-pipeline.test.ts tests/deploy.test.ts tests/app-router.test.ts -t "basePath"
  • pnpm test tests/entry-templates.test.ts -u
  • pnpm run typecheck

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 10, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@393

commit: 63f28b5

@JaredStowell JaredStowell changed the title fix: enforce segment boundary when stripping basePath fix: enforce segment boundaries for basePath stripping and redirect prefixing Mar 10, 2026
@JaredStowell
Copy link
Copy Markdown
Contributor Author

/bigbonk review por favor

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review: basePath segment-boundary fix

This is a well-executed fix for a real correctness bug. The core issue — startsWith(basePath) matching /application when basePath is /app — is a classic boundary-matching mistake, and the fix (requiring exact match or basePath + "/" prefix) is the right approach.

What I like:

  • Centralized helpers. Extracting hasBasePath/stripBasePath into utils/base-path.ts eliminates the duplicated inline implementations in navigation.ts, router.ts, and request-pipeline.ts. The shared module is clean and well-documented.

  • Consistent application. The fix is applied across all code paths that perform basePath matching: dev server redirects (index.ts), prod server stripping + redirects (prod-server.ts), App Router RSC entry (app-rsc-entry.ts), Cloudflare worker template (deploy.ts), and both client-side shims (navigation.ts, router.ts). This follows the AGENTS.md guidance about checking dev/prod parity.

  • The redirect destination fix is a bonus. The old code also had a bug where redirect.destination.startsWith(basePath) would skip basePath prefixing for destinations like /application/about, plus it would incorrectly try to prefix external URLs. Adding the isExternalUrl check alongside hasBasePath fixes both cases.

  • Test coverage is thorough. Unit tests for hasBasePath and stripBasePath cover exact match, descendant, shared-prefix, and nested basePath cases. The HTTP integration tests exercise the actual routing pipeline with a collision route (/lication/about) and redirect destination prefixing. Snapshot updates and template assertions round it out.

  • The worker template duplication is justified. deploy.ts generates a self-contained worker entry as a template string, so it can't import from utils/base-path.ts. Duplicating the two small functions there is the right call.

I have two minor observations (noted inline) but neither blocks merging. Approved.

*/
export function hasBasePath(pathname: string, basePath: string): boolean {
if (!basePath) return false;
return pathname === basePath || pathname.startsWith(basePath + "/");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this could also be written as:

return pathname === basePath || pathname.startsWith(basePath + "/");

which is what you have — just noting it's clean and correct. The only edge case worth thinking about is a basePath of "/", but Next.js disallows that (basePath must not be /), so this is fine.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

???

}
return pathname;
}
export { hasBasePath, stripBasePath };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: the JSDoc block above (lines 39-49) still describes stripBasePath as a local function with @param tags, but it's now a re-export. This is cosmetic — the docs aren't wrong per se, but they could be misleading since the function is defined in utils/base-path.ts now. Consider simplifying this to just a re-export comment, or removing the duplicate docs entirely since the canonical docs live in base-path.ts.

Non-blocking.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 10, 2026

Review submitted and approved. Here's a summary:

Verdict: Approved

This PR fixes a real correctness bug where startsWith(basePath) would incorrectly match paths like /application when basePath is /app. The fix introduces segment-boundary matching (pathname === basePath || pathname.startsWith(basePath + "/")) centralized in a new utils/base-path.ts module.

Key strengths:

  • The fix is applied consistently across all 7 code paths (dev server, prod server, App Router RSC entry, Cloudflare worker template, and both client-side shims)
  • Also fixes a related bug in redirect destination handling where external URLs and prefix-only matches would be incorrectly processed
  • Thorough test coverage at both unit and HTTP integration levels
  • Clean deduplication of previously-scattered inline implementations

Two non-blocking inline comments were left:

  1. A note that the basePath = "/" edge case is safely handled by Next.js's own validation
  2. A suggestion to clean up the orphaned JSDoc in request-pipeline.ts that still describes stripBasePath as a local function when it's now a re-export

github run

@james-elicx james-elicx merged commit dea3c3e into cloudflare:main Mar 10, 2026
21 checks passed
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.

2 participants