Read OSS

The Proxy Pipeline: How Cypress Intercepts Every Browser Request

Advanced

Prerequisites

  • Article 1: Architecture and Navigation Guide
  • Article 2: Boot Sequence and Mode Dispatch
  • HTTP proxy concepts (MITM, TLS interception)
  • Express middleware pattern familiarity

The Proxy Pipeline: How Cypress Intercepts Every Browser Request

If there's one component that defines Cypress's architecture, it's the HTTP proxy. Every single request the browser makes during a test — HTML pages, JavaScript files, API calls, images, WebSocket upgrades — passes through Cypress's proxy. This is how Cypress injects its driver scripts into the application under test, intercepts network requests for cy.intercept(), manages cookies across origins, and rewrites problematic browser APIs. Understanding the proxy pipeline is essential for understanding how Cypress actually works.

Why Cypress Needs a Proxy

Other testing tools automate browsers from the outside, sending commands over WebDriver or CDP. Cypress takes a radically different approach: it runs inside the browser, alongside your application. But to get there, it needs to inject itself into every HTML page your application loads.

The proxy solves four fundamental problems:

  1. Script injection: Every HTML response is intercepted and modified to include the Cypress driver, runner, and reporter scripts. This is how cy.* commands become available in the test context.

  2. Request interception: cy.intercept() needs to match, stub, and spy on network requests. The proxy is where requests are compared against route definitions and optionally replaced with stubs.

  3. Cookie management: Browsers enforce same-origin policies on cookies. Cypress's proxy manages a server-side cookie jar that ensures cookies are correctly attached across origins.

  4. Security rewriting: Content-Security-Policy headers, document.domain assignments, and integrity attributes can all prevent Cypress from functioning. The proxy strips or rewrites them.

The server sets up this proxy infrastructure in packages/server/lib/server-base.ts, creating an Express app with an http-proxy instance and wiring in the NetworkProxy from @packages/proxy.

The Http Class and 3-Stage Pipeline

The proxy's core is the Http class in packages/proxy/lib/http/index.ts. It defines three processing stages as an enum:

export enum HttpStages {
  IncomingRequest,
  IncomingResponse,
  Error
}

Each stage has its own middleware stack, registered as named objects:

export const defaultMiddleware = {
  [HttpStages.IncomingRequest]: RequestMiddleware,
  [HttpStages.IncomingResponse]: ResponseMiddleware,
  [HttpStages.Error]: ErrorMiddleware,
}
flowchart LR
    REQ[Browser Request] --> STAGE1["Stage 1<br/>IncomingRequest<br/>16 middlewares"]
    STAGE1 --> UPSTREAM[Upstream Server]
    UPSTREAM --> STAGE2["Stage 2<br/>IncomingResponse<br/>22 middlewares"]
    STAGE2 --> RES[Browser Response]
    STAGE1 -.->|error| STAGE3["Stage 3<br/>Error"]
    STAGE2 -.->|error| STAGE3

When handleHttpRequest is called, it creates a context object containing the request, response, config, buffers, net-stubbing state, and cookie jar. This context flows through every middleware in the pipeline.

The Stage Runner: _runStage() Deep Dive

The _runStage() function is the engine that drives middleware execution. Its design is unusual and worth studying.

Unlike Express middleware (where next() calls the next handler in a fixed array), Cypress's proxy uses destructive consumption: each middleware is removed from the stack as it executes. The first key from the middleware object is popped, executed, and then the rest continue:

const middlewareName = _.keys(middlewares)[0]
const middleware = middlewares[middlewareName]
ctx.middleware[type] = _.omit(middlewares, middlewareName)

This enables powerful flow control. Each middleware receives four control functions on this:

  • this.next() — proceed to the next middleware (can only be called once)
  • this.end() — skip all remaining middlewares in this stage
  • this.skipMiddleware(name) — remove a specific named middleware from the remaining stack
  • this.onlyRunMiddleware(names) — remove all middlewares except the named ones
sequenceDiagram
    participant Runner as _runStage
    participant MW1 as LogRequest
    participant MW2 as ExtractMetadata
    participant MW3 as CorrelatePre
    participant MWN as ...remaining

    Runner->>MW1: execute (shift off stack)
    MW1->>Runner: this.next()
    Runner->>MW2: execute (shift off stack)
    MW2->>Runner: this.next()
    Runner->>MW3: execute (shift off stack)
    Note over MW3: May call this.onlyRunMiddleware(['SendRequestOutgoing'])<br/>to skip everything else
    MW3->>Runner: this.next()
    Runner->>MWN: continue...

The copyChangedCtx() function propagates state mutations between middlewares. When a middleware modifies this.req or this.simulatedCookies, those changes are copied back to the shared context before the next middleware runs. This is necessary because each middleware gets its own spread copy of the context.

Tip: When a request seems to be handled incorrectly, enable the cypress-verbose:proxy:http debug namespace. Each request gets a random color in the console, making it easy to trace a single request through all middleware stages.

Request Middleware Chain: 16 Named Steps

The full request middleware chain is defined in packages/proxy/lib/http/request-middleware.ts:

export default {
  LogRequest,
  ExtractCypressMetadataHeaders,
  MaybeSimulateSecHeaders,
  CorrelateBrowserPreRequest,
  CalculateCredentialLevelIfApplicable,
  FormatCookiesIfApplicable,
  MaybeAttachCrossOriginCookies,
  MaybeEndRequestWithBufferedResponse,
  SetMatchingRoutes,
  SendToDriver,
  InterceptRequest,
  RedirectToClientRouteIfUnloaded,
  EndRequestsToBlockedHosts,
  StripUnsupportedAcceptEncoding,
  MaybeSetBasicAuthHeaders,
  SendRequestOutgoing,
}

Here's what the key middlewares do:

Middleware Purpose
ExtractCypressMetadataHeaders Reads x-cypress-is-aut-frame and similar headers that the driver injects to identify special requests
CorrelateBrowserPreRequest Matches this proxy request to a CDP pre-request event for correct request ID tracking
MaybeAttachCrossOriginCookies Adds cookies from the server-side jar for cross-origin requests
MaybeEndRequestWithBufferedResponse Short-circuits with a previously buffered response (used during page transitions)
SetMatchingRoutes Finds cy.intercept() route definitions that match this request
InterceptRequest Delegates to net-stubbing for stub/spy behavior if a route matched
EndRequestsToBlockedHosts Blocks requests to hosts listed in blockHosts config
SendRequestOutgoing Sends the actual request to the upstream server

The first middleware, LogRequest, simply logs the method, URL, and headers. The last, SendRequestOutgoing, actually sends the request upstream using the request module. Everything in between transforms, matches, or short-circuits.

Response Middleware: Injection, CSP, and Compression

The response side has even more middlewares — 22 in total, defined in packages/proxy/lib/http/response-middleware.ts:

flowchart TD
    LOG["LogResponse"]
    FILTER["FilterNonProxiedResponse"]
    INTERCEPT["InterceptResponse"]
    PATCH["PatchExpressSetHeader"]
    OMIT["OmitProblematicHeaders"]
    INJECT_LEVEL["SetInjectionLevel"]
    COOKIES["MaybeCopyCookiesFromIncomingRes"]
    REDIRECT["MaybeSendRedirectToClient"]
    INJECT_HTML["MaybeInjectHtml"]
    SECURITY["MaybeRemoveSecurity"]
    COMPRESS["CompressBody"]
    SEND["SendResponseBodyToClient"]

    LOG --> FILTER --> INTERCEPT --> PATCH
    PATCH --> OMIT --> INJECT_LEVEL --> COOKIES
    COOKIES --> REDIRECT --> INJECT_HTML
    INJECT_HTML --> SECURITY --> COMPRESS --> SEND

The critical middleware is MaybeInjectHtml. For HTML responses (determined by content-type and the SetInjectionLevel middleware), it inserts <script> tags that load the Cypress driver, runner, and reporter. This is the mechanism that puts Cypress inside the browser.

The response pipeline also handles decompression and recompression. The AttachPlainTextStreamFn middleware provides a makeResStreamPlainText() function that decompresses gzip or brotli encoding so downstream middlewares can inspect and modify the response body. After modifications, CompressBody recompresses using the original encoding order, tracked via the contentEncodingOrder array.

CSP (Content-Security-Policy) handling happens in OmitProblematicHeaders and MaybeRemoveSecurity. The proxy uses utilities from packages/proxy/lib/http/util/csp-header.ts to parse CSP headers, remove directives that would block Cypress script injection, and regenerate the headers.

Net-Stubbing: How cy.intercept() Plugs In

The SetMatchingRoutes and InterceptRequest middlewares in the request pipeline, and InterceptResponse in the response pipeline, are all imported from @packages/net-stubbing. This package implements the cy.intercept() API.

Route matching happens in packages/net-stubbing/lib/server/route-matching.ts. The _doesRouteMatch() function checks the request against every field in the RouteMatcherOptions: URL (using string equality, minimatch globs, or regex), method (case-insensitive), headers, query parameters, port, and HTTPS flag.

sequenceDiagram
    participant Proxy as Request Middleware
    participant SMR as SetMatchingRoutes
    participant Match as _doesRouteMatch
    participant IR as InterceptRequest
    participant Driver as Browser Driver

    Proxy->>SMR: this.next()
    SMR->>Match: Check all registered routes
    Match-->>SMR: Matching routes found
    SMR->>Proxy: Attach routes to req
    Proxy->>IR: this.next()
    IR->>Driver: Send matched request info via Socket.IO
    Driver-->>IR: Stub response or pass-through

The net-stubbing server entry points are cleanly exported from packages/net-stubbing/lib/server/index.ts:

export { SetMatchingRoutes, InterceptRequest } from './middleware/request'
export { InterceptResponse } from './middleware/response'
export { NetStubbingState, ResourceType } from './types'
export { getRoutesForRequest } from './route-matching'

This is a clean separation of concerns: the proxy provides the pipeline framework and net-stubbing provides the matching and interception logic.

The Rewriter: Transforming HTML and JS

The @packages/rewriter handles transformations that go beyond simple header manipulation. It exports:

  • HtmlJsRewriter — rewrites inline JavaScript within HTML documents
  • rewriteJsAsync / rewriteHtmlJsAsync — async rewriting using worker threads for performance
  • DeferredSourceMapCache — manages source maps that are generated during rewriting
  • createInitialWorkers — pre-spawns worker threads at startup

The rewriter transforms document.domain assignments (which can break Cypress's iframe-based architecture), strips integrity attributes from <script> and <link> tags (since the proxy modifies content, hashes would no longer match), and handles various edge cases around module scripts and service workers.

Worker threads are used because JavaScript parsing and transformation is CPU-intensive. By offloading to separate threads, the proxy doesn't block on rewriting and can continue handling other requests concurrently.

What's Next

We've seen how the proxy intercepts and transforms every network request. But all of this infrastructure exists to serve one purpose: executing test commands in the browser. In Part 4, we'll explore the browser-side driver engine — the command queue, the query/action distinction, and the retry mechanism that makes Cypress's cy.get().should() pattern work.