The Proxy Pipeline: How Cypress Intercepts Every Browser Request
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:
-
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. -
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. -
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.
-
Security rewriting: Content-Security-Policy headers,
document.domainassignments, 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 stagethis.skipMiddleware(name)— remove a specific named middleware from the remaining stackthis.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:httpdebug 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 documentsrewriteJsAsync/rewriteHtmlJsAsync— async rewriting using worker threads for performanceDeferredSourceMapCache— manages source maps that are generated during rewritingcreateInitialWorkers— 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.