Skip to content

Refactor: Extract shared request handling logic from the four server entry points #254

@southpolesteve

Description

@southpolesteve

Problem

Request handling logic is duplicated across four server entry points. AGENTS.md warns about this ("When fixing a bug in any of these files, check whether the same bug exists in the others"), but warnings don't prevent drift. The duplication has already caused behavioral divergence in several areas.

The four entry points:

  • ADS: server/app-dev-server.ts (App Router dev, generates virtual RSC entry)
  • DS: server/dev-server.ts (Pages Router dev SSR handler)
  • PS: server/prod-server.ts (Pages + App Router production)
  • IX: index.ts configureServer middleware (Pages Router dev) + generateServerEntry() (Pages Router prod entry)

There is also config/config-matchers.ts (CM), which was a good start at extraction, but only prod-server.ts uses it. The same logic is independently re-implemented as generated string code in ADS and IX.

Inventory of duplicated logic

16 pieces of shared logic, with copy counts:

# Logic Copies Identical? Notes
1 Protocol-relative URL guard (// rejection) 5 Yes Trivial but duplicated in ADS, PS (x2), IX, worker entry
2 Trailing slash normalization 3 Mostly ADS has extra .rsc check
3 BasePath stripping 3 Yes
4 Config redirects 3 3 separate implementations ADS inline, PS via CM, IX local
5 Config rewrites (3-phase) 3 3 separate implementations Same pattern as redirects
6 Config headers 3 3 separate implementations Application order differs (behavioral divergence)
7 External URL detection + proxy 3 Web fetch vs Node HTTP API
8 ReDoS-safe regex compilation 3 3 copies of ~90 lines ADS generated, IX generated, CM TypeScript
9 Cookie parsing 3-4 Yes ADS, IX generated, CM, DS (specialized)
10 Request context building 2 Yes ADS generated, CM TypeScript
11 has/missing condition checking 2 Yes ADS generated, CM TypeScript
12 Config pattern matching 2 Yes ADS generated, CM TypeScript
13 Middleware execution 4 Most diverged Different matchers, different result interpretation, different header unpacking
14 CSRF origin validation 1 N/A Only in ADS (not duplicated, but only exists as generated code)
15 Route matching (matchRoute + matchPattern) 3 Yes ADS generated, IX generated, DS imports from routing module
16 Image optimization passthrough 3 No Dev redirects, prod serves files

Highest-risk divergences

Middleware execution (item 13) is the most fragmented. Four implementations with:

  • Different matcher regex construction (ADS vs IX-generated)
  • Different result interpretation
  • Different x-middleware-request-* header unpacking (ADS uses applyMiddlewareRequestHeaders(), PS iterates manually)

Config headers application order (item 6) has actual behavioral divergence: ADS applies headers after the full request (skipping 3xx redirects), PS applies them early, IX applies them before route matching. This means the same next.config.js headers config produces different behavior depending on which server is running.

Config redirects/rewrites (items 4-5) have three independent implementations of the same pattern matching algorithm. config-matchers.ts is the canonical TypeScript version, but ADS and IX each generate their own as escaped string code.

Proposed approach

Phase 1: Make config-matchers.ts the single source of truth

config-matchers.ts already has clean TypeScript implementations of: ReDoS-safe regex, cookie parsing, config pattern matching, has/missing conditions, redirect matching, rewrite matching, header matching, external URL detection, external proxy, and request context building.

The generated code in ADS and IX should import from this module instead of inlining copies. Since the generated entries are virtual modules resolved by Vite, they can import from absolute paths. The generated code already does this for other shared modules (e.g., import { setNavigationContext } from "${absPath}").

This eliminates items 4-12 (config redirects, rewrites, headers, external proxy, ReDoS regex, cookie parsing, request context, has/missing conditions, config pattern matching) as duplicated code.

Phase 2: Extract shared request pipeline utilities

Create a new module (e.g., server/request-pipeline.ts) for the common request lifecycle steps:

// server/request-pipeline.ts
export function guardProtocolRelativeUrl(pathname: string): Response | null
export function stripBasePath(pathname: string, basePath: string): string
export function normalizeTrailingSlash(pathname: string, config: { trailingSlash: boolean, basePath: string }): Response | null

These are small functions, but extracting them ensures the trivial logic (items 1-3) stays consistent.

Phase 3: Unify middleware execution

This is the hardest piece. Create a shared middleware runner:

// server/middleware.ts (extend existing)
export interface MiddlewareResult {
  action: 'continue' | 'redirect' | 'rewrite' | 'respond'
  url?: string
  status?: number
  response?: Response
  requestHeaders?: Headers  // x-middleware-request-* unpacked
  responseHeaders?: Headers
}

export async function executeMiddleware(
  request: Request,
  middlewareFn: Function,
  matchers: MiddlewareMatcher[]
): Promise<MiddlewareResult>

All four entry points would call this and interpret the normalized result. The current divergence in matcher regex construction, result interpretation, and header unpacking would be eliminated.

Phase 4: Express the request pipeline ordering as a shared sequence

All servers follow the same sequence: protocol guard, basePath strip, trailing slash redirect, config redirects, beforeFiles rewrites, middleware, afterFiles rewrites, route match, fallback rewrites, config headers. Rather than each server reimplementing this procedurally, the ordering could be expressed as a composable pipeline. This is optional but would prevent future ordering divergences.

Generated code considerations

The ADS and IX-generated entries run as virtual modules in Vite's module graph. They can import from real modules using absolute paths. The pattern already exists:

// In the generated code template:
import { matchRedirect, matchRewrite, matchHeaders } from "${resolve('config/config-matchers.ts')}"
import { executeMiddleware } from "${resolve('server/middleware.ts')}"

This is how the generated entries already import shims like navigation.ts and headers.ts. The same pattern works for shared request handling logic.

Verification

  • pnpm test (all Vitest tests pass)
  • pnpm run test:e2e (all Playwright E2E tests pass)
  • pnpm run typecheck
  • pnpm run lint
  • Verify that config headers application order is consistent across all servers (fix the behavioral divergence in item 6)
  • Verify that middleware matcher regex produces the same results across all entry points

Relationship to other issues

This pairs with #253 (extract template strings into separate modules). That issue is about file organization (moving templates to their own files). This issue is about deduplication (making the templates import shared logic instead of inlining it). They can be done in either order, but doing #253 first makes this one easier because the templates will be in smaller, focused files.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions