ReviewOS

stacks/very-happy-dom

No page.route() / context.route(): interception is Puppeteer-style only

#1594
Closed glennmichael123 opened this 8 days ago ·
8 days ago

Summary

Request interception exists in the Puppeteer shape — page.on('request', …) plus await page.setRequestInterception(true) — but Playwright's route() is missing on both BrowserPage and BrowserContext.

The difference is not just naming. route() takes a URL pattern, so handlers are scoped to the requests they care about and unmatched requests are untouched. With a single request handler, every handler sees every request and each one must pattern-match by hand and remember to call continue(), or the request hangs.

Reproduction

import { Browser } from 'very-happy-dom'

const page = new Browser().newPage()

console.log(typeof page.route) // undefined
console.log(typeof page.unroute) // undefined
console.log(typeof page.context.route) // undefined
console.log(typeof page.setRequestInterception) // function

The existing path works, for reference:

const page = new Browser().newPage()

page.on('request', (request) => {
  request.respond({ status: 200, body: 'mocked' })
})

await page.setRequestInterception(true)

const response = await fetch('https://example.test/api/x')
console.log(response.status, await response.text()) // 200 mocked

Confirmed against main at 5fae54d4 with Bun 1.4.3.

Current implementation

BrowserPage.setRequestInterception() enables a RequestInterceptor that replaces global fetch and fans every request out to the page's request listeners. InterceptedRequest already carries the three terminal actions (continue, respond, abort), so the per-request half of the model is in place — what is missing is pattern-based registration.

Expected behavior

await page.route('**/api/**', async (route) => {
  await route.fulfill({ status: 200, body: JSON.stringify({ mocked: true }) })
})

await page.route(/\.png$/, route => route.abort())

await page.unroute('**/api/**')

Requirements:

  • route(url, handler) accepts a glob string, a RegExp, or a predicate function
  • enabling interception is implicit — registering a route is enough, with no separate setRequestInterception(true) call
  • requests matching no route pass through untouched
  • when several routes match, the most recently registered wins, and route.fallback() hands off to the next
  • unroute(url, handler?) removes one or all handlers for a pattern
  • the same API on BrowserContext, applying to every page in it

Route object, mapping onto what InterceptedRequest already does:

MethodExisting equivalent
route.fulfill({ status, headers, body, json, path, contentType })respond()
route.abort(errorCode?)abort()
route.continue({ url, method, headers, postData })continue()
route.fetch()— performs the request and returns the response for rewriting
route.request()— the Request being intercepted

Worth keeping setRequestInterception and page.on('request') working alongside it, since they are the happy-dom/Puppeteer-compatible surface and removing them would be a breaking change.

Also note the interceptor currently swaps global fetch, so two pages intercepting at once will contend for it. Per-page or per-context routing needs the interception to be scoped rather than global.

8 days ago

Implemented on main in 573adc6.

await page.route('**/api/**', route => route.fulfill({ json: { mocked: true } }))
await page.route(/\.png$/, route => route.abort())
await page.unroute('**/api/**')

Every requirement from the list:

  • Pattern forms — glob, RegExp, or predicate over the URL. Globs follow the usual split: * stops at a path separator, ** crosses them, and metacharacters stay literal, so /a.b does not match /axb.
  • Interception is implicit — registering a route is enough; no setRequestInterception(true).
  • Unmatched requests pass through untouched, so a route cannot stall unrelated traffic.
  • Most recently registered wins, and route.fallback() walks down to the next match — which is what lets a specific override sit on top of a general mock without unregistering anything.
  • unroute(url, handler?) removes one or all.
  • Same API on BrowserContext, applying to pages created before or after the call, with a page's own routes taking precedence.

Route carries request(), fulfill({ status, headers, body, json, contentType }), abort(errorCode?), continue(overrides?), fetch() and fallback(). fulfill({ json }) sets the content type unless you give one.

Both surfaces kept

This is layered on the existing RequestInterceptor rather than replacing it, so page.on('request') + setRequestInterception keeps working — that is the happy-dom-compatible path and removing it would be a breaking change. There is a test covering the two used together on one page.

One correction to the report

It says async page.on('request') handlers are "not awaited, so a handler that does any async work before respond() lets the request escape to the real network". That is not the case in RequestInterceptor.enable(), which does await handler(interceptedRequest) in its loop. I did not change that behaviour and did not need to.

Still true

The interceptor swaps global fetch, so two pages intercepting at once contend for it. Scoping interception per page or per context is a separate change — worth its own issue if you want it tracked.

18 tests in test/browser-routing.test.ts. Suite at 1495 passing, lint/typecheck/build clean.

8 days ago

Fixed in 573adc6 (page.route() and context.route() with glob matching, newest-wins ordering), with the context plumbing in e6e05e4. Released in v0.1.11, so this is now available to consumers rather than only on main. The scope notes I left earlier on this issue still stand — read those for what was deliberately left out.

Sign in to comment on this issue.