Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions bin/lib/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,24 +305,41 @@ class FrameUtils {
}

class RouteUtils {
static async setupRoute(owner, ownerId, url, generateId, routes, extractRequestData, sendFramedResponse, routeIdFactory = null) {
await owner.route(url, async (route) => {
const routeId = typeof routeIdFactory === 'function' ? routeIdFactory() : generateId('route');
// Playwright removes a single registration only when unroute() gets the very
// callback route() was given, so each one is kept under the PHP handlerId.
static async setupRoute(owner, ownerId, url, generateId, routes, extractRequestData, sendFramedResponse, routeHandlers = null, handlerId = null) {
const callback = async (route) => {
const routeId = generateId('route');
routes.set(routeId, { route, contextId: ownerId });
const req = route.request();
const requestData = extractRequestData(req);
logger.info('ROUTE SETUP', { url: requestData.url || req.url(), method: requestData.method || req.method(), ownerId });
sendFramedResponse({ objectId: ownerId, event: 'route', params: { routeId, request: requestData } });
});
const params = handlerId ? { routeId, handlerId, request: requestData } : { routeId, request: requestData };
sendFramedResponse({ objectId: ownerId, event: 'route', params });
};
await owner.route(url, callback);
if (handlerId) routeHandlers.set(handlerId, { ownerId, url, callback });
return { success: true };
}

static async unroute(owner, ownerId, routeHandlers, handlerIds) {
for (const handlerId of handlerIds) {
const handler = routeHandlers.get(handlerId);
if (!handler || handler.ownerId !== ownerId) continue;
routeHandlers.delete(handlerId);
await owner.unroute(handler.url, handler.callback);
}
}

// unrouteAll drops the handlers inside Playwright, but the pending Route
// objects we handed out stay in the map and can never be answered again.
static forgetRoutes(routes, ownerId) {
static forgetRoutes(routes, routeHandlers, ownerId) {
for (const [routeId, info] of routes.entries()) {
if (info?.contextId === ownerId) routes.delete(routeId);
}
for (const [handlerId, handler] of routeHandlers.entries()) {
if (handler.ownerId === ownerId) routeHandlers.delete(handlerId);
}
}
}

Expand Down
20 changes: 9 additions & 11 deletions bin/lib/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ class ContextHandler extends BaseHandler {
setNetworkThrottling: () => this.setThrottling(command),
setDefaultTimeout: () => context.setDefaultTimeout(command.timeout),
setDefaultNavigationTimeout: () => context.setDefaultNavigationTimeout(command.timeout),
route: () => RouteUtils.setupRoute(context, command.contextId, command.url, this.generateId, this.routes, this.extractRequestData, this.sendFramedResponse),
unroute: () => context.unroute(command.url),
route: () => RouteUtils.setupRoute(context, command.contextId, command.url, this.generateId, this.routes, this.extractRequestData, this.sendFramedResponse, this.routeHandlers, command.handlerId),
unroute: () => RouteUtils.unroute(context, command.contextId, this.routeHandlers, command.handlerIds || []),
unrouteAll: () => this.unrouteAll(context, command.contextId, command.options),
cookies: async () => ({ cookies: await context.cookies(command.urls) }),
storageState: async () => ({ storageState: await context.storageState(command.options) }),
Expand Down Expand Up @@ -243,7 +243,7 @@ class ContextHandler extends BaseHandler {

async unrouteAll(context, contextId, options) {
await context.unrouteAll(options || {});
RouteUtils.forgetRoutes(this.routes, contextId);
RouteUtils.forgetRoutes(this.routes, this.routeHandlers, contextId);
}

setThrottling(command) {
Expand Down Expand Up @@ -295,14 +295,11 @@ class ContextHandler extends BaseHandler {
if (mappedContextId === contextId) {
this.pageContexts.delete(pageId);
this.pages.delete(pageId);
RouteUtils.forgetRoutes(this.routes, this.routeHandlers, pageId);
}
}

for (const [routeId, info] of this.routes.entries()) {
if (info?.contextId === contextId) {
this.routes.delete(routeId);
}
}
RouteUtils.forgetRoutes(this.routes, this.routeHandlers, contextId);
}

async waitForPopup(context, command) {
Expand Down Expand Up @@ -402,8 +399,8 @@ class PageHandler extends BaseHandler {
addScriptTag: () => page.addScriptTag(command.options),
addStyleTag: () => page.addStyleTag(command.options).then(() => ({ success: true })),
handleDialog: () => this.handleDialog(command),
route: () => RouteUtils.setupRoute(page, command.pageId, command.url, this.generateId, this.routes, this.extractRequestData, this.sendFramedResponse, () => `route_${++this.routeCounter.value}`),
unroute: () => page.unroute(command.url),
route: () => RouteUtils.setupRoute(page, command.pageId, command.url, this.generateId, this.routes, this.extractRequestData, this.sendFramedResponse, this.routeHandlers, command.handlerId),
unroute: () => RouteUtils.unroute(page, command.pageId, this.routeHandlers, command.handlerIds || []),
unrouteAll: () => this.unrouteAll(page, command.pageId, command.options),
goBack: () => this.followNavigationRedirects(command.pageId, page, () => page.goBack(command.options)),
goForward: () => this.followNavigationRedirects(command.pageId, page, () => page.goForward(command.options)),
Expand All @@ -424,6 +421,7 @@ class PageHandler extends BaseHandler {
async closePage(pageId) {
const page = this.pages.get(pageId);
if (page) { await page.close(); this.pages.delete(pageId); }
RouteUtils.forgetRoutes(this.routes, this.routeHandlers, pageId);
}

async getVideo(page) {
Expand Down Expand Up @@ -534,7 +532,7 @@ class PageHandler extends BaseHandler {

async unrouteAll(page, pageId, options) {
await page.unrouteAll(options || {});
RouteUtils.forgetRoutes(this.routes, pageId);
RouteUtils.forgetRoutes(this.routes, this.routeHandlers, pageId);
}

async querySelector(page, command) {
Expand Down
12 changes: 8 additions & 4 deletions bin/playwright-server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const {chromium, firefox, webkit, request} = require('playwright');
const { logger, ErrorHandler, LspFraming, sendFramedResponse, CommandRegistry, BaseHandler } = require('./lib/core');
const { logger, ErrorHandler, LspFraming, sendFramedResponse, CommandRegistry, BaseHandler, RouteUtils } = require('./lib/core');
const { APIRequestHandler, ContextHandler, PageHandler, LocatorHandler, InteractionHandler, FrameHandler, JSHandleHandler, SelectorsHandler, VideoHandler } = require('./lib/handlers');
const { globalCoordinator } = require('./lib/coordination');

Expand All @@ -17,6 +17,7 @@ class PlaywrightServer extends BaseHandler {
this.pageContexts = new Map();
this.responses = new Map();
this.routes = new Map();
this.routeHandlers = new Map();
this.dialogs = new Map();
this.elementHandles = new Map();
this.contextThrottling = new Map();
Expand All @@ -32,12 +33,11 @@ class PlaywrightServer extends BaseHandler {
contexts: this.contexts, contextThrottling: this.contextThrottling, pages: this.pages,
apiContexts: this.apiContexts, apiRequest: request,
pageContexts: this.pageContexts, dialogs: this.dialogs, elementHandles: this.elementHandles,
responses: this.responses, routes: this.routes, videos: this.videos, generateId: this.generateId.bind(this),
responses: this.responses, routes: this.routes, routeHandlers: this.routeHandlers, videos: this.videos, generateId: this.generateId.bind(this),
navigationRedirects: this.navigationRedirects,
extractRequestData: this.extractRequestData.bind(this), serializeResponse: this.serializeResponse.bind(this),
serializeConsoleMessage: this.serializeConsoleMessage.bind(this),
sendFramedResponse,
routeCounter: { value: this.counters.route },
setupPageEventListeners: this.setupPageEventListeners.bind(this)
};
this.apiRequestHandler = new APIRequestHandler(deps);
Expand Down Expand Up @@ -128,7 +128,8 @@ class PlaywrightServer extends BaseHandler {
fulfill: () => this.fulfillRoute(route, command.options),
abort: () => route.abort(command.errorCode),
redirectNavigationRequest: () => this.redirectNavigationRequest(route, info, command),
continue: () => this.continueRoute(route, info, command)
continue: () => this.continueRoute(route, info, command),
fallback: () => route.fallback(command.options || undefined)
});
logger.info(`ROUTE ${method.toUpperCase()}`, { routeId: command.routeId });
await ErrorHandler.safeExecute(() => this.executeWithRegistry(registry, method), { method, routeId: command.routeId });
Expand Down Expand Up @@ -173,6 +174,7 @@ class PlaywrightServer extends BaseHandler {
sendFramedResponse({ objectId: pageId, event: eventName, params });
}));
});
page.once('close', () => RouteUtils.forgetRoutes(this.routes, this.routeHandlers, pageId));
}

// Pages opened by the browser itself (window.open(), target="_blank", ...) never go through
Expand All @@ -191,6 +193,8 @@ class PlaywrightServer extends BaseHandler {
sendFramedResponse({ objectId: contextId, event: 'page', params: { pageId } });
});
});
// A context also closes with its browser, which never goes through closeContext().
context.once('close', () => RouteUtils.forgetRoutes(this.routes, this.routeHandlers, contextId));
}

formatEventParams(eventName, eventData) {
Expand Down
64 changes: 48 additions & 16 deletions src/Browser/BrowserContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ final class BrowserContext implements BrowserContextInterface, EventDispatcherIn
private array $pages = [];

/**
* @var array<array{url: string, handler: callable}>
* @var array<string, array{url: string, handler: callable}>
*/
private array $routeHandlers = [];

Expand Down Expand Up @@ -117,19 +117,13 @@ public function dispatchEvent(string $eventName, array $params): void
if (!is_array($params['request'])) {
throw new ProtocolErrorException('Invalid request data in route event', 0);
}
$handlerId = is_string($params['handlerId'] ?? null) ? $params['handlerId'] : null;
$route = new Route(
$this->transport,
$params['routeId'],
$this->validateTransportArray($params['request'], 'request')
);
foreach ($this->routeHandlers as $handler) {
if (fnmatch($handler['url'], $route->request()->url())) {
$handler['handler']($route);

return;
}
}
$route->continue();
$this->dispatchRoute($route, $handlerId);
}

if (in_array($eventName, ['page', 'popup', 'pageCreated'], true)) {
Expand Down Expand Up @@ -241,6 +235,7 @@ public function close(): void
'action' => 'context.close',
'contextId' => $this->contextId,
]);
$this->routeHandlers = [];
}

public function isClosed(): bool
Expand Down Expand Up @@ -518,21 +513,42 @@ public function setOffline(bool $offline): void

public function route(string $url, callable $handler): void
{
$this->transport->send([
'action' => 'context.route',
'contextId' => $this->contextId,
'url' => $url,
]);
$this->routeHandlers[] = ['url' => $url, 'handler' => $handler];
// Registered before the command is sent: a request already in flight
// can be routed while the server is still answering it.
$handlerId = uniqid('handler_', true);
$this->routeHandlers[$handlerId] = ['url' => $url, 'handler' => $handler];
try {
$response = $this->transport->send([
'action' => 'context.route',
'contextId' => $this->contextId,
'url' => $url,
'handlerId' => $handlerId,
]);
if (isset($response['error'])) {
$errorMsg = is_string($response['error']) ? $response['error'] : 'Unknown transport error';
throw new TransportException('Transport error in route: '.$errorMsg);
}
} catch (\Throwable $e) {
unset($this->routeHandlers[$handlerId]);

throw $e;
}
}

public function unroute(string $url, ?callable $handler = null): void
{
$this->routeHandlers = array_filter($this->routeHandlers, fn ($h) => $h['url'] !== $url);
$handlerIds = [];
foreach ($this->routeHandlers as $handlerId => $registration) {
if ($registration['url'] === $url && (null === $handler || $registration['handler'] === $handler)) {
$handlerIds[] = $handlerId;
unset($this->routeHandlers[$handlerId]);
}
}
$this->transport->send([
'action' => 'context.unroute',
'contextId' => $this->contextId,
'url' => $url,
'handlerIds' => $handlerIds,
]);
}

Expand Down Expand Up @@ -665,6 +681,22 @@ private static function validateExtraHttpHeaders(array $headers): void
}
}

/**
* A route event carries the registration whose native route matched, so
* Playwright's own order (newest first, then fallback) decides who answers.
*/
private function dispatchRoute(Route $route, ?string $handlerId): void
{
$registration = null === $handlerId ? null : ($this->routeHandlers[$handlerId] ?? null);
if (null === $registration) {
$route->fallback();

return;
}

$registration['handler']($route);
}

/**
* Helper method to validate and cast transport data to proper array type.
*
Expand Down
48 changes: 45 additions & 3 deletions src/Page/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ final class Page implements PageInterface, EventDispatcherInterface
*/
private array $handledDialogs = [];

/**
* @var array<string, array{url: string, handler: callable}>
*/
private array $routeHandlers = [];

public function __construct(
private readonly TransportInterface $transport,
private readonly BrowserContextInterface $context,
Expand Down Expand Up @@ -179,9 +184,11 @@ public function dispatchEvent(string $eventName, array $params): void
break;
case 'close':
$this->isClosed = true;
$this->routeHandlers = [];
$this->eventHandler->publicEmit('close', []);
break;
case 'route':
$handlerId = is_string($params['handlerId'] ?? null) ? $params['handlerId'] : null;
if (is_string($params['routeId']) && is_array($params['request'])) {
$route = $this->createRoute(
$this->pageId,
Expand All @@ -191,6 +198,7 @@ public function dispatchEvent(string $eventName, array $params): void
} else {
break;
}
$this->dispatchRoute($route, $handlerId);
$this->eventHandler->publicEmit('route', [$route]);
break;
default:
Expand Down Expand Up @@ -639,6 +647,7 @@ public function close(): void
$this->sendCommand('close');

$this->isClosed = true;
$this->routeHandlers = [];
}

public function isClosed(): bool
Expand Down Expand Up @@ -1189,20 +1198,37 @@ public function frame(array|FrameQueryOptions $options): ?FrameInterface

public function route(string $url, callable $handler): void
{
$this->eventHandler->onRoute($handler);
$this->sendCommand('route', ['url' => $url]);
// Registered before the command is sent: a request already in flight
// can be routed while the server is still answering it.
$handlerId = uniqid('handler_', true);
$this->routeHandlers[$handlerId] = ['url' => $url, 'handler' => $handler];
try {
$this->sendCommand('route', ['url' => $url, 'handlerId' => $handlerId]);
} catch (\Throwable $e) {
unset($this->routeHandlers[$handlerId]);

throw $e;
}
}

public function unroute(string $url, ?callable $handler = null): void
{
$this->sendCommand('unroute', ['url' => $url]);
$handlerIds = [];
foreach ($this->routeHandlers as $handlerId => $registration) {
if ($registration['url'] === $url && (null === $handler || $registration['handler'] === $handler)) {
$handlerIds[] = $handlerId;
unset($this->routeHandlers[$handlerId]);
}
}
$this->sendCommand('unroute', ['url' => $url, 'handlerIds' => $handlerIds]);
}

/**
* @param array{behavior?: 'default'|'wait'|'ignoreErrors'} $options
*/
public function unrouteAll(array $options = []): void
{
$this->routeHandlers = [];
$this->sendCommand('unrouteAll', ['options' => $options]);
}

Expand Down Expand Up @@ -1316,6 +1342,22 @@ private function createRequest(mixed $data): Request
return new Request($this->validateRequestData($data), $this->transport, null, $this->pageId);
}

/**
* A route event carries the registration whose native route matched, so
* Playwright's own order (newest first, then fallback) decides who answers.
*/
private function dispatchRoute(Route $route, ?string $handlerId): void
{
$registration = null === $handlerId ? null : ($this->routeHandlers[$handlerId] ?? null);
if (null === $registration) {
$route->fallback();

return;
}

$registration['handler']($route);
}

/**
* Create a Route object from transport data.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/Page/PageEventHandlerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,10 @@ public function onResponse(callable $handler): void;

public function onRequestFailed(callable $handler): void;

/**
* Observes routed requests only: the handler registered with route()
* answers each one before these listeners run, so they must not
* fulfill, abort, continue or fall back the route themselves.
*/
public function onRoute(callable $handler): void;
}
Loading
Loading