Workers SDK Issue Reports

← Back to Dashboard

#3406 Index route is overwritten by wildcard route

Download Reproduction
Recommendation:KEEP OPEN
Difficulty:easy
Reasoning:

Bug confirmed on wrangler 4.60.0. Root index route (/) incorrectly handled by catch-all [[fallback]].js due to segment-count sorting in compareRoutes().

Suggested Action:

Keep open - valid routing priority bug needs fix in filepath-routing.ts

Analysis Report

Issue Review: cloudflare/workers-sdk#3406

Summary

Pages Functions routing incorrectly prioritizes catch-all wildcard routes ([[fallback]].js) over the root index route (index.js), causing / to be handled by the wrong function.

Findings

  • Created: 2023-06-05
  • Updated: 2025-10-30
  • Version: Wrangler 3.0.1 -> 4.60.0
  • Component: Pages Functions (filepath-routing)
  • Labels: bug, pages
  • Comments: 0

Key Evidence

  1. Issue is well-documented: The reporter correctly identified the root cause in the code - the compareRoutes function sorts routes by segment count, but doesn't account for the root index route having 0 segments.

  2. No fix found:

    • No PRs reference issue #3406
    • No changelog entries mention this issue
    • The relevant code in filepath-routing.ts appears unchanged
  3. Bug reproduced on wrangler 4.60.0:

    File structure:
    functions/index.js
    functions/about.js
    functions/[name].js
    functions/[[fallback]].js
    
    Results:
    GET /              -> [[fallback]].js  (BUG - should be index.js)
    GET /about         -> about.js         (correct)
    GET /something     -> [name].js        (correct)  
    GET /something/else -> [[fallback]].js (correct)
    
  4. Root cause confirmed: The sorting algorithm puts routes with more segments first. Since index.js becomes / (0 segments) and [[fallback]].js becomes /:fallback* (1 segment), the catch-all comes first and matches / before the index route.

  5. Note: The issue author stated / maps to [name].js, but testing shows it actually maps to [[fallback]].js. The [name].js pattern (:name) requires at least one character and won't match /, but the catch-all [[fallback]].js pattern (:fallback*) matches zero or more segments and incorrectly captures /.

Recommendation

Status: KEEP OPEN

Reasoning: The bug is confirmed to still exist in wrangler 4.60.0. The issue has a clear root cause analysis with code references, and can be reproduced with a minimal example. This is a legitimate routing priority bug that affects users with both index routes and catch-all routes.

Action: Keep open. This is a valid bug that needs engineering attention. A fix would need to modify the compareRoutes function in packages/wrangler/src/pages/functions/filepath-routing.ts to ensure the root index route takes precedence over catch-all wildcards.

Suggested Comment

This issue has been verified as still occurring on wrangler 4.60.0. Testing confirms that GET / incorrectly routes to [[fallback]].js instead of index.js when both files exist.

Note: The original report mentioned it maps to [name].js, but testing shows it actually maps to [[fallback]].js - the [name].js pattern requires at least one character, while [[fallback]].js (catch-all) matches zero or more path segments.

The root cause is in compareRoutes() - routes are sorted by segment count (more segments first), so /:fallback* (1 segment) comes before / (0 segments). Since catch-all patterns match empty paths, the wildcard intercepts requests to / before the index route.


Solution Recommendation

Root Cause Analysis

The compareRoutes() function in filepath-routing.ts:118-165 sorts routes by segment count first:

// sort routes with fewer segments after those with more segments
if (segmentsA.length !== segmentsB.length) {
  return segmentsB.length - segmentsA.length;
}

The problem:

  • index.js → routePath /0 segments
  • [[fallback]].js → routePath /:fallback*1 segment

Since routes with more segments come first, the catch-all (/:fallback*) is evaluated before the index route (/), and since * matches zero-or-more characters, it captures requests to /.

Proposed Solution

Add special handling in compareRoutes() to ensure the root index route (/) always takes precedence over catch-all wildcards at the root level.

export function compareRoutes(
  { routePath: routePathA, method: methodA }: RouteConfig,
  { routePath: routePathB, method: methodB }: RouteConfig
) {
  function parseRoutePath(routePath: UrlPath): string[] {
    return routePath.slice(1).split("/").filter(Boolean);
  }

  const segmentsA = parseRoutePath(routePathA);
  const segmentsB = parseRoutePath(routePathB);

  // NEW: Root index route (/) should come before root-level catch-all wildcards
  const isRootIndexA = segmentsA.length === 0;
  const isRootIndexB = segmentsB.length === 0;
  const isRootCatchAllA = segmentsA.length === 1 && segmentsA[0].includes("*");
  const isRootCatchAllB = segmentsB.length === 1 && segmentsB[0].includes("*");

  // Root index takes precedence over root-level catch-all
  if (isRootIndexA && isRootCatchAllB) return -1;
  if (isRootCatchAllA && isRootIndexB) return 1;

  // ... rest of existing logic unchanged
}

Alternative (More Comprehensive) Solution

A more robust fix would handle this at any nesting level, not just the root. The principle: an exact route should always beat a catch-all sibling.

export function compareRoutes(
  { routePath: routePathA, method: methodA }: RouteConfig,
  { routePath: routePathB, method: methodB }: RouteConfig
) {
  function parseRoutePath(routePath: UrlPath): string[] {
    return routePath.slice(1).split("/").filter(Boolean);
  }

  const segmentsA = parseRoutePath(routePathA);
  const segmentsB = parseRoutePath(routePathB);

  // NEW: Check if one route is a "parent" of a catch-all route
  // e.g., "/" vs "/:fallback*" or "/foo" vs "/foo/:rest*"
  const aIsPrefixOfB = routePathB.startsWith(routePathA.replace(/\/$/, "") + "/") || 
                       (routePathA === "/" && segmentsB.length > 0);
  const bIsPrefixOfA = routePathA.startsWith(routePathB.replace(/\/$/, "") + "/") ||
                       (routePathB === "/" && segmentsA.length > 0);

  // If A is a prefix of B and B ends with a catch-all, A should come first
  if (aIsPrefixOfB && segmentsB.some(s => s.includes("*"))) return -1;
  if (bIsPrefixOfA && segmentsA.some(s => s.includes("*"))) return 1;

  // ... rest of existing logic unchanged
}

Implementation Difficulty: EASY

Factor Assessment
Lines of code ~5-10 lines added
Risk Low - isolated change to sorting logic
Testing Easy to unit test with existing test infrastructure
Scope Single file (filepath-routing.ts)
Breaking changes None - this fixes incorrect behavior

Testing Recommendations

Add test cases to verify:

  1. GET /index.js (not [[fallback]].js)
  2. GET /foo[name].js (not [[fallback]].js)
  3. GET /foo/bar[[fallback]].js (correct catch-all behavior)
  4. Nested scenarios: /api/ with both api/index.js and api/[[...rest]].js

Files to Modify

  1. packages/wrangler/src/pages/functions/filepath-routing.ts - Add the fix
  2. packages/wrangler/src/pages/functions/filepath-routing.test.ts - Add test cases

Notes & Feedback (0)

No notes yet.

Add Note