feat(route): match pattern on the server side (#20410)

This avoids client-side roundtrip for requests that are not handled by
any route.

Fixes #19607.
This commit is contained in:
Dmitry Gozman 2023-01-27 10:43:19 -08:00 committed by GitHub
parent ead4989947
commit d458e84f5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 100 additions and 56 deletions

View File

@ -22,7 +22,7 @@ import { Worker } from './worker';
import type { Headers, RemoteAddr, SecurityDetails, WaitForEventOptions } from './types'; import type { Headers, RemoteAddr, SecurityDetails, WaitForEventOptions } from './types';
import fs from 'fs'; import fs from 'fs';
import { mime } from '../utilsBundle'; import { mime } from '../utilsBundle';
import { assert, isString, headersObjectToArray } from '../utils'; import { assert, isString, headersObjectToArray, isRegExp } from '../utils';
import { ManualPromise } from '../utils/manualPromise'; import { ManualPromise } from '../utils/manualPromise';
import { Events } from './events'; import { Events } from './events';
import type { Page } from './page'; import type { Page } from './page';
@ -641,8 +641,7 @@ export class NetworkRouter {
async route(url: URLMatch, handler: RouteHandlerCallback, options: { times?: number } = {}): Promise<void> { async route(url: URLMatch, handler: RouteHandlerCallback, options: { times?: number } = {}): Promise<void> {
this._routes.unshift(new RouteHandler(this._baseURL, url, handler, options.times)); this._routes.unshift(new RouteHandler(this._baseURL, url, handler, options.times));
if (this._routes.length === 1) await this._updateInterception();
await this._owner._channel.setNetworkInterceptionEnabled({ enabled: true });
} }
async routeFromHAR(har: string, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback' } = {}): Promise<void> { async routeFromHAR(har: string, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback' } = {}): Promise<void> {
@ -653,8 +652,7 @@ export class NetworkRouter {
async unroute(url: URLMatch, handler?: RouteHandlerCallback): Promise<void> { async unroute(url: URLMatch, handler?: RouteHandlerCallback): Promise<void> {
this._routes = this._routes.filter(route => route.url !== url || (handler && route.handler !== handler)); this._routes = this._routes.filter(route => route.url !== url || (handler && route.handler !== handler));
if (!this._routes.length) await this._updateInterception();
await this._disableInterception();
} }
async handleRoute(route: Route) { async handleRoute(route: Route) {
@ -666,15 +664,25 @@ export class NetworkRouter {
this._routes.splice(this._routes.indexOf(routeHandler), 1); this._routes.splice(this._routes.indexOf(routeHandler), 1);
const handled = await routeHandler.handle(route); const handled = await routeHandler.handle(route);
if (!this._routes.length) if (!this._routes.length)
this._owner._wrapApiCall(() => this._disableInterception(), true).catch(() => {}); this._owner._wrapApiCall(() => this._updateInterception(), true).catch(() => {});
if (handled) if (handled)
return true; return true;
} }
return false; return false;
} }
private async _disableInterception() { private async _updateInterception() {
await this._owner._channel.setNetworkInterceptionEnabled({ enabled: false }); const patterns: channels.BrowserContextSetNetworkInterceptionPatternsParams['patterns'] = [];
let all = false;
for (const handler of this._routes) {
if (isString(handler.url))
patterns.push({ glob: handler.url });
else if (isRegExp(handler.url))
patterns.push({ regexSource: handler.url.source, regexFlags: handler.url.flags });
else
all = true;
}
await this._owner._channel.setNetworkInterceptionPatterns(all ? { patterns: [{ glob: '**/*' }] } : { patterns });
} }
} }

View File

@ -821,10 +821,14 @@ scheme.BrowserContextSetHTTPCredentialsParams = tObject({
})), })),
}); });
scheme.BrowserContextSetHTTPCredentialsResult = tOptional(tObject({})); scheme.BrowserContextSetHTTPCredentialsResult = tOptional(tObject({}));
scheme.BrowserContextSetNetworkInterceptionEnabledParams = tObject({ scheme.BrowserContextSetNetworkInterceptionPatternsParams = tObject({
enabled: tBoolean, patterns: tArray(tObject({
glob: tOptional(tString),
regexSource: tOptional(tString),
regexFlags: tOptional(tString),
})),
}); });
scheme.BrowserContextSetNetworkInterceptionEnabledResult = tOptional(tObject({})); scheme.BrowserContextSetNetworkInterceptionPatternsResult = tOptional(tObject({}));
scheme.BrowserContextSetOfflineParams = tObject({ scheme.BrowserContextSetOfflineParams = tObject({
offline: tBoolean, offline: tBoolean,
}); });
@ -1035,10 +1039,14 @@ scheme.PageSetExtraHTTPHeadersParams = tObject({
headers: tArray(tType('NameValue')), headers: tArray(tType('NameValue')),
}); });
scheme.PageSetExtraHTTPHeadersResult = tOptional(tObject({})); scheme.PageSetExtraHTTPHeadersResult = tOptional(tObject({}));
scheme.PageSetNetworkInterceptionEnabledParams = tObject({ scheme.PageSetNetworkInterceptionPatternsParams = tObject({
enabled: tBoolean, patterns: tArray(tObject({
glob: tOptional(tString),
regexSource: tOptional(tString),
regexFlags: tOptional(tString),
})),
}); });
scheme.PageSetNetworkInterceptionEnabledResult = tOptional(tObject({})); scheme.PageSetNetworkInterceptionPatternsResult = tOptional(tObject({}));
scheme.PageSetViewportSizeParams = tObject({ scheme.PageSetViewportSizeParams = tObject({
viewportSize: tObject({ viewportSize: tObject({
width: tNumber, width: tNumber,

View File

@ -461,6 +461,7 @@ export abstract class BrowserContext extends SdkObject {
const page = await this.newPage(internalMetadata); const page = await this.newPage(internalMetadata);
await page._setServerRequestInterceptor(handler => { await page._setServerRequestInterceptor(handler => {
handler.fulfill({ body: '<html></html>' }).catch(() => {}); handler.fulfill({ body: '<html></html>' }).catch(() => {});
return true;
}); });
for (const origin of this._origins) { for (const origin of this._origins) {
const originStorage: channels.OriginStorage = { origin, localStorage: [] }; const originStorage: channels.OriginStorage = { origin, localStorage: [] };
@ -489,6 +490,7 @@ export abstract class BrowserContext extends SdkObject {
page = page || await this.newPage(internalMetadata); page = page || await this.newPage(internalMetadata);
await page._setServerRequestInterceptor(handler => { await page._setServerRequestInterceptor(handler => {
handler.fulfill({ body: '<html></html>' }).catch(() => {}); handler.fulfill({ body: '<html></html>' }).catch(() => {});
return true;
}); });
for (const origin of new Set([...oldOrigins, ...newOrigins.keys()])) { for (const origin of new Set([...oldOrigins, ...newOrigins.keys()])) {
@ -523,6 +525,7 @@ export abstract class BrowserContext extends SdkObject {
const page = await this.newPage(internalMetadata); const page = await this.newPage(internalMetadata);
await page._setServerRequestInterceptor(handler => { await page._setServerRequestInterceptor(handler => {
handler.fulfill({ body: '<html></html>' }).catch(() => {}); handler.fulfill({ body: '<html></html>' }).catch(() => {});
return true;
}); });
for (const originState of state.origins) { for (const originState of state.origins) {
const frame = page.mainFrame(); const frame = page.mainFrame();

View File

@ -112,10 +112,8 @@ export class CRServiceWorker extends Worker {
this._browserContext.emit(BrowserContext.Events.Request, request); this._browserContext.emit(BrowserContext.Events.Request, request);
if (route) { if (route) {
const r = new network.Route(request, route); const r = new network.Route(request, route);
if (this._browserContext._requestInterceptor) { if (this._browserContext._requestInterceptor?.(r, request))
this._browserContext._requestInterceptor(r, request);
return; return;
}
r.continue(); r.continue();
} }
} }

View File

@ -31,7 +31,7 @@ import type { Request, Response } from '../network';
import { TracingDispatcher } from './tracingDispatcher'; import { TracingDispatcher } from './tracingDispatcher';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { createGuid } from '../../utils'; import { createGuid, urlMatches } from '../../utils';
import { WritableStreamDispatcher } from './writableStreamDispatcher'; import { WritableStreamDispatcher } from './writableStreamDispatcher';
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel { export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
@ -216,13 +216,18 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
await this._context.addInitScript(params.source); await this._context.addInitScript(params.source);
} }
async setNetworkInterceptionEnabled(params: channels.BrowserContextSetNetworkInterceptionEnabledParams): Promise<void> { async setNetworkInterceptionPatterns(params: channels.BrowserContextSetNetworkInterceptionPatternsParams): Promise<void> {
if (!params.enabled) { if (!params.patterns.length) {
await this._context.setRequestInterceptor(undefined); await this._context.setRequestInterceptor(undefined);
return; return;
} }
const urlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!);
await this._context.setRequestInterceptor((route, request) => { await this._context.setRequestInterceptor((route, request) => {
const matchesSome = urlMatchers.some(urlMatch => urlMatches(this._context._options.baseURL, request.url(), urlMatch));
if (!matchesSome)
return false;
this._dispatchEvent('route', { route: RouteDispatcher.from(RequestDispatcher.from(this, request), route) }); this._dispatchEvent('route', { route: RouteDispatcher.from(RequestDispatcher.from(this, request), route) });
return true;
}); });
} }

View File

@ -35,7 +35,7 @@ import type { CallMetadata } from '../instrumentation';
import type { Artifact } from '../artifact'; import type { Artifact } from '../artifact';
import { ArtifactDispatcher } from './artifactDispatcher'; import { ArtifactDispatcher } from './artifactDispatcher';
import type { Download } from '../download'; import type { Download } from '../download';
import { createGuid } from '../../utils'; import { createGuid, urlMatches } from '../../utils';
import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type { BrowserContextDispatcher } from './browserContextDispatcher';
export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, BrowserContextDispatcher> implements channels.PageChannel { export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, BrowserContextDispatcher> implements channels.PageChannel {
@ -154,13 +154,18 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
await this._page.addInitScript(params.source); await this._page.addInitScript(params.source);
} }
async setNetworkInterceptionEnabled(params: channels.PageSetNetworkInterceptionEnabledParams, metadata: CallMetadata): Promise<void> { async setNetworkInterceptionPatterns(params: channels.PageSetNetworkInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
if (!params.enabled) { if (!params.patterns.length) {
await this._page.setClientRequestInterceptor(undefined); await this._page.setClientRequestInterceptor(undefined);
return; return;
} }
const urlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!);
await this._page.setClientRequestInterceptor((route, request) => { await this._page.setClientRequestInterceptor((route, request) => {
const matchesSome = urlMatchers.some(urlMatch => urlMatches(this._page._browserContext._options.baseURL, request.url(), urlMatch));
if (!matchesSome)
return false;
this._dispatchEvent('route', { route: RouteDispatcher.from(RequestDispatcher.from(this.parentScope(), request), route) }); this._dispatchEvent('route', { route: RouteDispatcher.from(RequestDispatcher.from(this.parentScope(), request), route) });
return true;
}); });
} }

View File

@ -304,18 +304,12 @@ export class FrameManager {
this._page.emitOnContext(BrowserContext.Events.Request, request); this._page.emitOnContext(BrowserContext.Events.Request, request);
if (route) { if (route) {
const r = new network.Route(request, route); const r = new network.Route(request, route);
if (this._page._serverRequestInterceptor) { if (this._page._serverRequestInterceptor?.(r, request))
this._page._serverRequestInterceptor(r, request);
return; return;
} if (this._page._clientRequestInterceptor?.(r, request))
if (this._page._clientRequestInterceptor) {
this._page._clientRequestInterceptor(r, request);
return; return;
} if (this._page._browserContext._requestInterceptor?.(r, request))
if (this._page._browserContext._requestInterceptor) {
this._page._browserContext._requestInterceptor(r, request);
return; return;
}
r.continue(); r.continue();
} }
} }

View File

@ -333,7 +333,7 @@ export class Route extends SdkObject {
} }
} }
export type RouteHandler = (route: Route, request: Request) => void; export type RouteHandler = (route: Route, request: Request) => boolean;
type GetResponseBodyCallback = () => Promise<Buffer>; type GetResponseBodyCallback = () => Promise<Buffer>;

View File

@ -82,12 +82,14 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
await installAppIcon(this._page); await installAppIcon(this._page);
await syncLocalStorageWithSettings(this._page, 'recorder'); await syncLocalStorageWithSettings(this._page, 'recorder');
await this._page._setServerRequestInterceptor(async route => { await this._page._setServerRequestInterceptor(route => {
if (route.request().url().startsWith('https://playwright/')) { if (!route.request().url().startsWith('https://playwright/'))
return false;
const uri = route.request().url().substring('https://playwright/'.length); const uri = route.request().url().substring('https://playwright/'.length);
const file = require.resolve('../../webpack/recorder/' + uri); const file = require.resolve('../../webpack/recorder/' + uri);
const buffer = await fs.promises.readFile(file); fs.promises.readFile(file).then(buffer => {
await route.fulfill({ route.fulfill({
status: 200, status: 200,
headers: [ headers: [
{ name: 'Content-Type', value: mime.getType(path.extname(file)) || 'application/octet-stream' } { name: 'Content-Type', value: mime.getType(path.extname(file)) || 'application/octet-stream' }
@ -95,9 +97,8 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
body: buffer.toString('base64'), body: buffer.toString('base64'),
isBase64: true isBase64: true
}); });
return; });
} return true;
await route.continue();
}); });
await this._page.exposeBinding('dispatch', false, (_, data: any) => this.emit('event', data)); await this._page.exposeBinding('dispatch', false, (_, data: any) => this.emit('event', data));

View File

@ -1371,7 +1371,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT
setExtraHTTPHeaders(params: BrowserContextSetExtraHTTPHeadersParams, metadata?: Metadata): Promise<BrowserContextSetExtraHTTPHeadersResult>; setExtraHTTPHeaders(params: BrowserContextSetExtraHTTPHeadersParams, metadata?: Metadata): Promise<BrowserContextSetExtraHTTPHeadersResult>;
setGeolocation(params: BrowserContextSetGeolocationParams, metadata?: Metadata): Promise<BrowserContextSetGeolocationResult>; setGeolocation(params: BrowserContextSetGeolocationParams, metadata?: Metadata): Promise<BrowserContextSetGeolocationResult>;
setHTTPCredentials(params: BrowserContextSetHTTPCredentialsParams, metadata?: Metadata): Promise<BrowserContextSetHTTPCredentialsResult>; setHTTPCredentials(params: BrowserContextSetHTTPCredentialsParams, metadata?: Metadata): Promise<BrowserContextSetHTTPCredentialsResult>;
setNetworkInterceptionEnabled(params: BrowserContextSetNetworkInterceptionEnabledParams, metadata?: Metadata): Promise<BrowserContextSetNetworkInterceptionEnabledResult>; setNetworkInterceptionPatterns(params: BrowserContextSetNetworkInterceptionPatternsParams, metadata?: Metadata): Promise<BrowserContextSetNetworkInterceptionPatternsResult>;
setOffline(params: BrowserContextSetOfflineParams, metadata?: Metadata): Promise<BrowserContextSetOfflineResult>; setOffline(params: BrowserContextSetOfflineParams, metadata?: Metadata): Promise<BrowserContextSetOfflineResult>;
storageState(params?: BrowserContextStorageStateParams, metadata?: Metadata): Promise<BrowserContextStorageStateResult>; storageState(params?: BrowserContextStorageStateParams, metadata?: Metadata): Promise<BrowserContextStorageStateResult>;
pause(params?: BrowserContextPauseParams, metadata?: Metadata): Promise<BrowserContextPauseResult>; pause(params?: BrowserContextPauseParams, metadata?: Metadata): Promise<BrowserContextPauseResult>;
@ -1523,13 +1523,17 @@ export type BrowserContextSetHTTPCredentialsOptions = {
}, },
}; };
export type BrowserContextSetHTTPCredentialsResult = void; export type BrowserContextSetHTTPCredentialsResult = void;
export type BrowserContextSetNetworkInterceptionEnabledParams = { export type BrowserContextSetNetworkInterceptionPatternsParams = {
enabled: boolean, patterns: {
glob?: string,
regexSource?: string,
regexFlags?: string,
}[],
}; };
export type BrowserContextSetNetworkInterceptionEnabledOptions = { export type BrowserContextSetNetworkInterceptionPatternsOptions = {
}; };
export type BrowserContextSetNetworkInterceptionEnabledResult = void; export type BrowserContextSetNetworkInterceptionPatternsResult = void;
export type BrowserContextSetOfflineParams = { export type BrowserContextSetOfflineParams = {
offline: boolean, offline: boolean,
}; };
@ -1673,7 +1677,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel {
expectScreenshot(params: PageExpectScreenshotParams, metadata?: Metadata): Promise<PageExpectScreenshotResult>; expectScreenshot(params: PageExpectScreenshotParams, metadata?: Metadata): Promise<PageExpectScreenshotResult>;
screenshot(params: PageScreenshotParams, metadata?: Metadata): Promise<PageScreenshotResult>; screenshot(params: PageScreenshotParams, metadata?: Metadata): Promise<PageScreenshotResult>;
setExtraHTTPHeaders(params: PageSetExtraHTTPHeadersParams, metadata?: Metadata): Promise<PageSetExtraHTTPHeadersResult>; setExtraHTTPHeaders(params: PageSetExtraHTTPHeadersParams, metadata?: Metadata): Promise<PageSetExtraHTTPHeadersResult>;
setNetworkInterceptionEnabled(params: PageSetNetworkInterceptionEnabledParams, metadata?: Metadata): Promise<PageSetNetworkInterceptionEnabledResult>; setNetworkInterceptionPatterns(params: PageSetNetworkInterceptionPatternsParams, metadata?: Metadata): Promise<PageSetNetworkInterceptionPatternsResult>;
setViewportSize(params: PageSetViewportSizeParams, metadata?: Metadata): Promise<PageSetViewportSizeResult>; setViewportSize(params: PageSetViewportSizeParams, metadata?: Metadata): Promise<PageSetViewportSizeResult>;
keyboardDown(params: PageKeyboardDownParams, metadata?: Metadata): Promise<PageKeyboardDownResult>; keyboardDown(params: PageKeyboardDownParams, metadata?: Metadata): Promise<PageKeyboardDownResult>;
keyboardUp(params: PageKeyboardUpParams, metadata?: Metadata): Promise<PageKeyboardUpResult>; keyboardUp(params: PageKeyboardUpParams, metadata?: Metadata): Promise<PageKeyboardUpResult>;
@ -1918,13 +1922,17 @@ export type PageSetExtraHTTPHeadersOptions = {
}; };
export type PageSetExtraHTTPHeadersResult = void; export type PageSetExtraHTTPHeadersResult = void;
export type PageSetNetworkInterceptionEnabledParams = { export type PageSetNetworkInterceptionPatternsParams = {
enabled: boolean, patterns: {
glob?: string,
regexSource?: string,
regexFlags?: string,
}[],
}; };
export type PageSetNetworkInterceptionEnabledOptions = { export type PageSetNetworkInterceptionPatternsOptions = {
}; };
export type PageSetNetworkInterceptionEnabledResult = void; export type PageSetNetworkInterceptionPatternsResult = void;
export type PageSetViewportSizeParams = { export type PageSetViewportSizeParams = {
viewportSize: { viewportSize: {
width: number, width: number,

View File

@ -1032,9 +1032,16 @@ BrowserContext:
username: string username: string
password: string password: string
setNetworkInterceptionEnabled: setNetworkInterceptionPatterns:
parameters: parameters:
enabled: boolean patterns:
type: array
items:
type: object
properties:
glob: string?
regexSource: string?
regexFlags: string?
setOffline: setOffline:
parameters: parameters:
@ -1311,9 +1318,16 @@ Page:
type: array type: array
items: NameValue items: NameValue
setNetworkInterceptionEnabled: setNetworkInterceptionPatterns:
parameters: parameters:
enabled: boolean patterns:
type: array
items:
type: object
properties:
glob: string?
regexSource: string?
regexFlags: string?
setViewportSize: setViewportSize:
parameters: parameters: