Skip to content

Commit

Permalink
feat: use CDP's auto-attach mechanism
Browse files Browse the repository at this point in the history
  • Loading branch information
OrKoN committed Jun 28, 2022
1 parent 79e1198 commit a6a59a5
Show file tree
Hide file tree
Showing 17 changed files with 1,153 additions and 173 deletions.
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -30,7 +30,7 @@
"test": "npm run lint --silent && npm run test:unit:coverage",
"test:unit": "npm run build:test && mocha",
"test:unit:firefox": "cross-env PUPPETEER_PRODUCT=firefox npm run test:unit",
"test:unit:coverage": "c8 --check-coverage --lines 94 npm run test:unit",
"test:unit:coverage": "c8 --check-coverage --lines 93 npm run test:unit",
"test:unit:chrome-headless": "cross-env HEADLESS=chrome npm run test:unit",
"test:protocol-revision": "ts-node -s scripts/ensure-correct-devtools-protocol-package",
"test:pinned-deps": "ts-node -s scripts/ensure-pinned-deps",
Expand Down
171 changes: 109 additions & 62 deletions src/common/Browser.ts
Expand Up @@ -17,13 +17,19 @@
import {ChildProcess} from 'child_process';
import {Protocol} from 'devtools-protocol';
import {assert} from './assert.js';
import {Connection, ConnectionEmittedEvents} from './Connection.js';
import {CDPSession, Connection, ConnectionEmittedEvents} from './Connection.js';
import {EventEmitter} from './EventEmitter.js';
import {waitWithTimeout} from './util.js';
import {Page} from './Page.js';
import {Viewport} from './PuppeteerViewport.js';
import {Target} from './Target.js';
import {TaskQueue} from './TaskQueue.js';
import {
TargetManager,
ChromeTargetManager,
TargetManagerEmittedEvents,
} from './TargetManager.js';
import {FirefoxTargetManager} from './FirefoxTargetManager.js';

/**
* BrowserContext options.
Expand Down Expand Up @@ -218,6 +224,7 @@ export class Browser extends EventEmitter {
* @internal
*/
static async _create(
product: 'firefox' | 'chrome' | undefined,
connection: Connection,
contextIds: string[],
ignoreHTTPSErrors: boolean,
Expand All @@ -228,6 +235,7 @@ export class Browser extends EventEmitter {
isPageTargetCallback?: IsPageTargetCallback
): Promise<Browser> {
const browser = new Browser(
product,
connection,
contextIds,
ignoreHTTPSErrors,
Expand All @@ -237,7 +245,7 @@ export class Browser extends EventEmitter {
targetFilterCallback,
isPageTargetCallback
);
await connection.send('Target.setDiscoverTargets', {discover: true});
await browser._attach();
return browser;
}
#ignoreHTTPSErrors: boolean;
Expand All @@ -250,20 +258,20 @@ export class Browser extends EventEmitter {
#defaultContext: BrowserContext;
#contexts: Map<string, BrowserContext>;
#screenshotTaskQueue: TaskQueue;
#targets: Map<string, Target>;
#ignoredTargets = new Set<string>();
#targetManager: TargetManager;

/**
* @internal
*/
get _targets(): Map<string, Target> {
return this.#targets;
return this.#targetManager.attachedTargets();
}

/**
* @internal
*/
constructor(
product: 'chrome' | 'firefox' | undefined,
connection: Connection,
contextIds: string[],
ignoreHTTPSErrors: boolean,
Expand All @@ -274,6 +282,7 @@ export class Browser extends EventEmitter {
isPageTargetCallback?: IsPageTargetCallback
) {
super();
product = product || 'chrome';
this.#ignoreHTTPSErrors = ignoreHTTPSErrors;
this.#defaultViewport = defaultViewport;
this.#process = process;
Expand All @@ -286,6 +295,18 @@ export class Browser extends EventEmitter {
return true;
});
this.#setIsPageTargetCallback(isPageTargetCallback);
this.#targetManager =
product === 'chrome'
? new ChromeTargetManager(
connection,
this.#createTarget,
this.#targetFilterCallback
)
: new FirefoxTargetManager(
connection,
this.#createTarget,
this.#targetFilterCallback
);

this.#defaultContext = new BrowserContext(this.#connection, this);
this.#contexts = new Map();
Expand All @@ -295,19 +316,54 @@ export class Browser extends EventEmitter {
new BrowserContext(this.#connection, this, contextId)
);
}
}

this.#targets = new Map();
this.#connection.on(ConnectionEmittedEvents.Disconnected, () => {
return this.emit(BrowserEmittedEvents.Disconnected);
});
this.#connection.on('Target.targetCreated', this.#targetCreated.bind(this));
#emitDisconnected = () => {
this.emit(BrowserEmittedEvents.Disconnected);
};

/**
* @internal
*/
async _attach(): Promise<void> {
this.#connection.on(
'Target.targetDestroyed',
this.#targetDestroyed.bind(this)
ConnectionEmittedEvents.Disconnected,
this.#emitDisconnected
);
this.#connection.on(
'Target.targetInfoChanged',
this.#targetInfoChanged.bind(this)
this.#targetManager.on(
TargetManagerEmittedEvents.AttachedToTarget,
this.#onAttachedToTarget
);
this.#targetManager.on(
TargetManagerEmittedEvents.DetachedFromTarget,
this.#onDetachedFromTarget
);
this.#targetManager.on(
TargetManagerEmittedEvents.TargetChanged,
this.#onTargetChanged
);
await this.#targetManager.initialize();
}

/**
* @internal
*/
_detach(): void {
this.#connection.off(
ConnectionEmittedEvents.Disconnected,
this.#emitDisconnected
);
this.#targetManager.off(
TargetManagerEmittedEvents.AttachedToTarget,
this.#onAttachedToTarget
);
this.#targetManager.off(
TargetManagerEmittedEvents.DetachedFromTarget,
this.#onDetachedFromTarget
);
this.#targetManager.off(
TargetManagerEmittedEvents.TargetChanged,
this.#onTargetChanged
);
}

Expand All @@ -319,6 +375,13 @@ export class Browser extends EventEmitter {
return this.#process ?? null;
}

/**
* @internal
*/
_targetManager(): TargetManager {
return this.#targetManager;
}

#setIsPageTargetCallback(isPageTargetCallback?: IsPageTargetCallback): void {
this.#isPageTargetCallback =
isPageTargetCallback ||
Expand Down Expand Up @@ -404,10 +467,10 @@ export class Browser extends EventEmitter {
this.#contexts.delete(contextId);
}

async #targetCreated(
event: Protocol.Target.TargetCreatedEvent
): Promise<void> {
const targetInfo = event.targetInfo;
#createTarget = (
targetInfo: Protocol.Target.TargetInfo,
session?: CDPSession
) => {
const {browserContextId} = targetInfo;
const context =
browserContextId && this.#contexts.has(browserContextId)
Expand All @@ -418,15 +481,11 @@ export class Browser extends EventEmitter {
throw new Error('Missing browser context');
}

const shouldAttachToTarget = this.#targetFilterCallback(targetInfo);
if (!shouldAttachToTarget) {
this.#ignoredTargets.add(targetInfo.targetId);
return;
}

const target = new Target(
return new Target(
targetInfo,
session,
context,
this.#targetManager,
() => {
return this.#connection.createSession(targetInfo);
},
Expand All @@ -435,59 +494,45 @@ export class Browser extends EventEmitter {
this.#screenshotTaskQueue,
this.#isPageTargetCallback
);
assert(
!this.#targets.has(event.targetInfo.targetId),
'Target should not exist before targetCreated'
);
this.#targets.set(event.targetInfo.targetId, target);
};

#onAttachedToTarget = async (target: Target) => {
if (await target._initializedPromise) {
this.emit(BrowserEmittedEvents.TargetCreated, target);
context.emit(BrowserContextEmittedEvents.TargetCreated, target);
target
.browserContext()
.emit(BrowserContextEmittedEvents.TargetCreated, target);
}
}
};

async #targetDestroyed(event: {targetId: string}): Promise<void> {
if (this.#ignoredTargets.has(event.targetId)) {
return;
}
const target = this.#targets.get(event.targetId);
if (!target) {
throw new Error(
`Missing target in _targetDestroyed (id = ${event.targetId})`
);
}
#onDetachedFromTarget = async (target: Target): Promise<void> => {
target._initializedCallback(false);
this.#targets.delete(event.targetId);
target._closedCallback();
if (await target._initializedPromise) {
this.emit(BrowserEmittedEvents.TargetDestroyed, target);
target
.browserContext()
.emit(BrowserContextEmittedEvents.TargetDestroyed, target);
}
}

#targetInfoChanged(event: Protocol.Target.TargetInfoChangedEvent): void {
if (this.#ignoredTargets.has(event.targetInfo.targetId)) {
return;
}
const target = this.#targets.get(event.targetInfo.targetId);
if (!target) {
throw new Error(
`Missing target in targetInfoChanged (id = ${event.targetInfo.targetId})`
);
}
};

#onTargetChanged = ({
target,
targetInfo,
}: {
target: Target;
targetInfo: Protocol.Target.TargetInfo;
}): void => {
const previousURL = target.url();
const wasInitialized = target._isInitialized;
target._targetInfoChanged(event.targetInfo);
target._targetInfoChanged(targetInfo);
if (wasInitialized && previousURL !== target.url()) {
this.emit(BrowserEmittedEvents.TargetChanged, target);
target
.browserContext()
.emit(BrowserContextEmittedEvents.TargetChanged, target);
}
}
};

/**
* The browser websocket endpoint which can be used as an argument to
Expand Down Expand Up @@ -526,7 +571,7 @@ export class Browser extends EventEmitter {
url: 'about:blank',
browserContextId: contextId || undefined,
});
const target = this.#targets.get(targetId);
const target = this.#targetManager.attachedTargets().get(targetId);
if (!target) {
throw new Error(`Missing target for page (id = ${targetId})`);
}
Expand All @@ -548,9 +593,11 @@ export class Browser extends EventEmitter {
* an array with all the targets in all browser contexts.
*/
targets(): Target[] {
return Array.from(this.#targets.values()).filter(target => {
return target._isInitialized;
});
return Array.from(this.#targetManager.attachedTargets().values()).filter(
target => {
return target._isInitialized;
}
);
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/common/BrowserConnector.ts
Expand Up @@ -26,7 +26,7 @@ import {Connection} from './Connection.js';
import {ConnectionTransport} from './ConnectionTransport.js';
import {getFetch} from './fetch.js';
import {Viewport} from './PuppeteerViewport.js';

import {Product} from './Product.js';
/**
* Generic browser options that can be passed when launching any browser or when
* connecting to an existing browser instance.
Expand Down Expand Up @@ -75,6 +75,7 @@ export async function _connectToBrowser(
browserWSEndpoint?: string;
browserURL?: string;
transport?: ConnectionTransport;
product?: Product;
}
): Promise<Browser> {
const {
Expand All @@ -86,6 +87,7 @@ export async function _connectToBrowser(
slowMo = 0,
targetFilter,
_isPageTarget: isPageTarget,
product,
} = options;

assert(
Expand Down Expand Up @@ -114,6 +116,7 @@ export async function _connectToBrowser(
'Target.getBrowserContexts'
);
return Browser._create(
product || 'chrome',
connection,
browserContextIds,
ignoreHTTPSErrors,
Expand Down
6 changes: 6 additions & 0 deletions src/common/Connection.ts
Expand Up @@ -59,6 +59,7 @@ export class Connection extends EventEmitter {
#sessions: Map<string, CDPSession> = new Map();
#closed = false;
#callbacks: Map<number, ConnectionCallback> = new Map();
#manuallyAttached = new Set<string>();

constructor(url: string, transport: ConnectionTransport, delay = 0) {
super();
Expand Down Expand Up @@ -210,13 +211,18 @@ export class Connection extends EventEmitter {
this.#transport.close();
}

isAutoAttached(targetId: string): boolean {
return !this.#manuallyAttached.has(targetId);
}

/**
* @param targetInfo - The target info
* @returns The CDP session that is created
*/
async createSession(
targetInfo: Protocol.Target.TargetInfo
): Promise<CDPSession> {
this.#manuallyAttached.add(targetInfo.targetId);
const {sessionId} = await this.send('Target.attachToTarget', {
targetId: targetInfo.targetId,
flatten: true,
Expand Down

0 comments on commit a6a59a5

Please sign in to comment.