From 0abede9a5fef234bb2aa1ea71dc720677063970e Mon Sep 17 00:00:00 2001 From: jrandolf <101637635+jrandolf@users.noreply.github.com> Date: Thu, 18 Jan 2024 13:29:52 +0100 Subject: [PATCH] refactor: implement bidi/core (#11649) --- .../puppeteer-core/src/bidi/core/Browser.ts | 179 ++++++++ .../src/bidi/core/BrowsingContext.ts | 390 ++++++++++++++++++ .../src/bidi/core/Connection.ts | 139 +++++++ .../src/bidi/core/Navigation.ts | 105 +++++ .../puppeteer-core/src/bidi/core/Realm.ts | 283 +++++++++++++ .../puppeteer-core/src/bidi/core/Request.ts | 114 +++++ .../puppeteer-core/src/bidi/core/Session.ts | 164 ++++++++ .../src/bidi/core/UserContext.ts | 125 ++++++ .../src/bidi/core/UserPrompt.ts | 93 +++++ packages/puppeteer-core/src/bidi/core/core.ts | 15 + 10 files changed, 1607 insertions(+) create mode 100644 packages/puppeteer-core/src/bidi/core/Browser.ts create mode 100644 packages/puppeteer-core/src/bidi/core/BrowsingContext.ts create mode 100644 packages/puppeteer-core/src/bidi/core/Connection.ts create mode 100644 packages/puppeteer-core/src/bidi/core/Navigation.ts create mode 100644 packages/puppeteer-core/src/bidi/core/Realm.ts create mode 100644 packages/puppeteer-core/src/bidi/core/Request.ts create mode 100644 packages/puppeteer-core/src/bidi/core/Session.ts create mode 100644 packages/puppeteer-core/src/bidi/core/UserContext.ts create mode 100644 packages/puppeteer-core/src/bidi/core/UserPrompt.ts create mode 100644 packages/puppeteer-core/src/bidi/core/core.ts diff --git a/packages/puppeteer-core/src/bidi/core/Browser.ts b/packages/puppeteer-core/src/bidi/core/Browser.ts new file mode 100644 index 00000000000..259406940ad --- /dev/null +++ b/packages/puppeteer-core/src/bidi/core/Browser.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {throwIfDisposed} from '../../util/decorators.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {SharedWorkerRealm} from './Realm.js'; +import type {Session} from './Session.js'; +import {UserContext} from './UserContext.js'; + +/** + * @internal + */ +export type AddPreloadScriptOptions = Omit< + Bidi.Script.AddPreloadScriptParameters, + 'functionDeclaration' | 'contexts' +> & { + contexts?: [BrowsingContext, ...BrowsingContext[]]; +}; + +/** + * @internal + */ +export class Browser extends EventEmitter<{ + /** Emitted after the browser closes. */ + closed: { + /** The reason for closing the browser. */ + reason: string; + }; + /** Emitted after the browser disconnects. */ + disconnected: { + /** The reason for disconnecting the browser. */ + reason: string; + }; + /** Emitted when a shared worker is created. */ + sharedworker: { + /** The realm of the shared worker. */ + realm: SharedWorkerRealm; + }; +}> { + static async from(session: Session): Promise { + const browser = new Browser(session); + await browser.#initialize(); + return browser; + } + + // keep-sorted start + #reason: string | undefined; + readonly #userContexts = new Map(); + readonly session: Session; + // keep-sorted end + + private constructor(session: Session) { + super(); + + // keep-sorted start + this.session = session; + // keep-sorted end + + this.#userContexts.set('', UserContext.create(this, '')); + } + + async #initialize() { + // /////////////////////// + // Session listeners // + // /////////////////////// + const session = this.#session; + session.on('script.realmCreated', info => { + if (info.type === 'shared-worker') { + // TODO: Create a SharedWorkerRealm. + } + }); + + // /////////////////// + // Parent listeners // + // /////////////////// + this.session.once('ended', ({reason}) => { + this.#reason = reason; + this.emit('disconnected', {reason}); + this.removeAllListeners(); + }); + + // ////////////////////////////// + // Asynchronous initialization // + // ////////////////////////////// + // In case contexts are created or destroyed during `getTree`, we use this + // set to detect them. + const contextIds = new Set(); + const created = (info: {context: string}) => { + contextIds.add(info.context); + }; + const destroyed = (info: {context: string}) => { + contextIds.delete(info.context); + }; + session.on('browsingContext.contextCreated', created); + session.on('browsingContext.contextDestroyed', destroyed); + + const { + result: {contexts}, + } = await session.send('browsingContext.getTree', {}); + + session.off('browsingContext.contextDestroyed', destroyed); + session.off('browsingContext.contextCreated', created); + + // Simulating events so contexts are created naturally. + for (const info of contexts) { + if (contextIds.has(info.context)) { + session.emit('browsingContext.contextCreated', info); + } + if (info.children) { + contexts.push(...info.children); + } + } + } + + get #session() { + return this.session; + } + + get disposed(): boolean { + return this.#reason !== undefined; + } + + get defaultUserContext(): UserContext { + // SAFETY: A UserContext is always created for the default context. + return this.#userContexts.get('')!; + } + + get userContexts(): Iterable { + return this.#userContexts.values(); + } + + @throwIfDisposed((browser: Browser) => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async close(): Promise { + await this.#session.send('browser.close', {}); + this.#reason = `Browser has already closed.`; + this.emit('closed', {reason: this.#reason}); + this.removeAllListeners(); + } + + @throwIfDisposed((browser: Browser) => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async addPreloadScript( + functionDeclaration: string, + options: AddPreloadScriptOptions = {} + ): Promise { + const { + result: {script}, + } = await this.#session.send('script.addPreloadScript', { + functionDeclaration, + ...options, + contexts: options.contexts?.map(context => { + return context.id; + }) as [string, ...string[]], + }); + return script; + } + + @throwIfDisposed((browser: Browser) => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async removePreloadScript(script: string): Promise { + await this.#session.send('script.removePreloadScript', { + script, + }); + } +} diff --git a/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts b/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts new file mode 100644 index 00000000000..022b699df5f --- /dev/null +++ b/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts @@ -0,0 +1,390 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {throwIfDisposed} from '../../util/decorators.js'; + +import type {AddPreloadScriptOptions} from './Browser.js'; +import {Navigation} from './Navigation.js'; +import {WindowRealm} from './Realm.js'; +import {Request} from './Request.js'; +import type {UserContext} from './UserContext.js'; +import {UserPrompt} from './UserPrompt.js'; + +/** + * @internal + */ +export type CaptureScreenshotOptions = Omit< + Bidi.BrowsingContext.CaptureScreenshotParameters, + 'context' +>; + +/** + * @internal + */ +export type ReloadOptions = Omit< + Bidi.BrowsingContext.ReloadParameters, + 'context' +>; + +/** + * @internal + */ +export type PrintOptions = Omit< + Bidi.BrowsingContext.PrintParameters, + 'context' +>; + +/** + * @internal + */ +export type HandleUserPromptOptions = Omit< + Bidi.BrowsingContext.HandleUserPromptParameters, + 'context' +>; + +/** + * @internal + */ +export type SetViewportOptions = Omit< + Bidi.BrowsingContext.SetViewportParameters, + 'context' +>; + +/** + * @internal + */ +export class BrowsingContext extends EventEmitter<{ + /** Emitted when this context is destroyed. */ + destroyed: void; + /** Emitted when a child browsing context is created. */ + browsingcontext: { + /** The newly created child browsing context. */ + browsingContext: BrowsingContext; + }; + /** Emitted whenever a navigation occurs. */ + navigation: { + /** The navigation that occurred. */ + navigation: Navigation; + }; + /** Emitted whenever a request is made. */ + request: { + /** The request that was made. */ + request: Request; + }; + /** Emitted whenever a log entry is added. */ + log: { + /** Entry added to the log. */ + entry: Bidi.Log.Entry; + }; + /** Emitted whenever a prompt is opened. */ + userprompt: { + /** The prompt that was opened. */ + userPrompt: UserPrompt; + }; + /** Emitted whenever the frame emits `DOMContentLoaded` */ + DOMContentLoaded: void; + /** Emitted whenever the frame emits `load` */ + load: void; +}> { + static from( + userContext: UserContext, + parent: BrowsingContext | undefined, + id: string, + url: string + ): BrowsingContext { + const browsingContext = new BrowsingContext(userContext, parent, id, url); + browsingContext.#initialize(); + return browsingContext; + } + + // keep-sorted start + #navigation: Navigation | undefined; + #url: string; + readonly #children = new Map(); + readonly #realms = new Map(); + readonly #requests = new Map(); + readonly defaultRealm: WindowRealm; + readonly id: string; + readonly parent: BrowsingContext | undefined; + readonly userContext: UserContext; + disposed = false; + // keep-sorted end + + private constructor( + context: UserContext, + parent: BrowsingContext | undefined, + id: string, + url: string + ) { + super(); + + // keep-sorted start + this.#url = url; + this.id = id; + this.parent = parent; + this.userContext = context; + // keep-sorted end + + this.defaultRealm = WindowRealm.from(this); + } + + #initialize() { + // /////////////////////// + // Session listeners // + // /////////////////////// + const session = this.#session; + session.on('browsingContext.contextCreated', info => { + if (info.parent !== this.id) { + return; + } + + const browsingContext = BrowsingContext.from( + this.userContext, + this, + info.context, + info.url + ); + browsingContext.on('destroyed', () => { + this.#children.delete(browsingContext.id); + }); + + this.#children.set(info.context, browsingContext); + + this.emit('browsingcontext', {browsingContext}); + }); + session.on('browsingContext.contextDestroyed', info => { + if (info.context !== this.id) { + return; + } + this.disposed = true; + this.emit('destroyed', undefined); + this.removeAllListeners(); + }); + + session.on('browsingContext.domContentLoaded', info => { + if (info.context !== this.id) { + return; + } + this.#url = info.url; + this.emit('DOMContentLoaded', undefined); + }); + + session.on('browsingContext.load', info => { + if (info.context !== this.id) { + return; + } + this.#url = info.url; + this.emit('load', undefined); + }); + + session.on('browsingContext.navigationStarted', info => { + if (info.context !== this.id) { + return; + } + + this.#requests.clear(); + + // Note the navigation ID is null for this event. + this.#navigation = Navigation.from(this, info.url); + this.#navigation.on('fragment', ({url}) => { + this.#url = url; + }); + + this.emit('navigation', {navigation: this.#navigation}); + }); + session.on('network.beforeRequestSent', event => { + if (event.context !== this.id) { + return; + } + if (this.#requests.has(event.request.request)) { + return; + } + + const request = new Request(this, event); + this.#requests.set(request.id, request); + this.emit('request', {request}); + }); + + session.on('log.entryAdded', entry => { + if (entry.source.context !== this.id) { + return; + } + + this.emit('log', {entry}); + }); + + session.on('browsingContext.userPromptOpened', info => { + if (info.context !== this.id) { + return; + } + + const userPrompt = UserPrompt.from(this, info); + this.emit('userprompt', {userPrompt}); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.userContext.browser.session; + } + get children(): Iterable { + return this.#children.values(); + } + get realms(): Iterable { + return this.#realms.values(); + } + get top(): BrowsingContext { + let context = this as BrowsingContext; + for (let {parent} = context; parent; {parent} = context) { + context = parent; + } + return context; + } + get url(): string { + return this.#url; + } + // keep-sorted end + + @throwIfDisposed() + async activate(): Promise { + await this.#session.send('browsingContext.activate', { + context: this.id, + }); + } + + @throwIfDisposed() + async captureScreenshot( + options: CaptureScreenshotOptions = {} + ): Promise { + const { + result: {data}, + } = await this.#session.send('browsingContext.captureScreenshot', { + context: this.id, + ...options, + }); + return data; + } + + @throwIfDisposed() + async close(promptUnload?: boolean): Promise { + await Promise.all( + [...this.#children.values()].map(async child => { + await child.close(promptUnload); + }) + ); + await this.#session.send('browsingContext.close', { + context: this.id, + promptUnload, + }); + } + + @throwIfDisposed() + async traverseHistory(delta: number): Promise { + await this.#session.send('browsingContext.traverseHistory', { + context: this.id, + delta, + }); + } + + @throwIfDisposed() + async navigate( + url: string, + wait?: Bidi.BrowsingContext.ReadinessState + ): Promise { + await this.#session.send('browsingContext.navigate', { + context: this.id, + url, + wait, + }); + return await new Promise(resolve => { + this.once('navigation', ({navigation}) => { + resolve(navigation); + }); + }); + } + + @throwIfDisposed() + async reload(options: ReloadOptions = {}): Promise { + await this.#session.send('browsingContext.reload', { + context: this.id, + ...options, + }); + return await new Promise(resolve => { + this.once('navigation', ({navigation}) => { + resolve(navigation); + }); + }); + } + + @throwIfDisposed() + async print(options: PrintOptions = {}): Promise { + const { + result: {data}, + } = await this.#session.send('browsingContext.print', { + context: this.id, + ...options, + }); + return data; + } + + @throwIfDisposed() + async handleUserPrompt(options: HandleUserPromptOptions = {}): Promise { + await this.#session.send('browsingContext.handleUserPrompt', { + context: this.id, + ...options, + }); + } + + @throwIfDisposed() + async setViewport(options: SetViewportOptions = {}): Promise { + await this.#session.send('browsingContext.setViewport', { + context: this.id, + ...options, + }); + } + + @throwIfDisposed() + async performActions(actions: Bidi.Input.SourceActions[]): Promise { + await this.#session.send('input.performActions', { + context: this.id, + actions, + }); + } + + @throwIfDisposed() + async releaseActions(): Promise { + await this.#session.send('input.releaseActions', { + context: this.id, + }); + } + + @throwIfDisposed() + createWindowRealm(sandbox: string): WindowRealm { + return WindowRealm.from(this, sandbox); + } + + @throwIfDisposed() + async addPreloadScript( + functionDeclaration: string, + options: AddPreloadScriptOptions = {} + ): Promise { + return await this.userContext.browser.addPreloadScript( + functionDeclaration, + { + ...options, + contexts: [this, ...(options.contexts ?? [])], + } + ); + } + + @throwIfDisposed() + async removePreloadScript(script: string): Promise { + await this.userContext.browser.removePreloadScript(script); + } +} diff --git a/packages/puppeteer-core/src/bidi/core/Connection.ts b/packages/puppeteer-core/src/bidi/core/Connection.ts new file mode 100644 index 00000000000..b9de14372b0 --- /dev/null +++ b/packages/puppeteer-core/src/bidi/core/Connection.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {EventEmitter} from '../../common/EventEmitter.js'; + +/** + * @internal + */ +export interface Commands { + 'script.evaluate': { + params: Bidi.Script.EvaluateParameters; + returnType: Bidi.Script.EvaluateResult; + }; + 'script.callFunction': { + params: Bidi.Script.CallFunctionParameters; + returnType: Bidi.Script.EvaluateResult; + }; + 'script.disown': { + params: Bidi.Script.DisownParameters; + returnType: Bidi.EmptyResult; + }; + 'script.addPreloadScript': { + params: Bidi.Script.AddPreloadScriptParameters; + returnType: Bidi.Script.AddPreloadScriptResult; + }; + 'script.removePreloadScript': { + params: Bidi.Script.RemovePreloadScriptParameters; + returnType: Bidi.EmptyResult; + }; + + 'browser.close': { + params: Bidi.EmptyParams; + returnType: Bidi.EmptyResult; + }; + + 'browsingContext.activate': { + params: Bidi.BrowsingContext.ActivateParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.create': { + params: Bidi.BrowsingContext.CreateParameters; + returnType: Bidi.BrowsingContext.CreateResult; + }; + 'browsingContext.close': { + params: Bidi.BrowsingContext.CloseParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.getTree': { + params: Bidi.BrowsingContext.GetTreeParameters; + returnType: Bidi.BrowsingContext.GetTreeResult; + }; + 'browsingContext.navigate': { + params: Bidi.BrowsingContext.NavigateParameters; + returnType: Bidi.BrowsingContext.NavigateResult; + }; + 'browsingContext.reload': { + params: Bidi.BrowsingContext.ReloadParameters; + returnType: Bidi.BrowsingContext.NavigateResult; + }; + 'browsingContext.print': { + params: Bidi.BrowsingContext.PrintParameters; + returnType: Bidi.BrowsingContext.PrintResult; + }; + 'browsingContext.captureScreenshot': { + params: Bidi.BrowsingContext.CaptureScreenshotParameters; + returnType: Bidi.BrowsingContext.CaptureScreenshotResult; + }; + 'browsingContext.handleUserPrompt': { + params: Bidi.BrowsingContext.HandleUserPromptParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.setViewport': { + params: Bidi.BrowsingContext.SetViewportParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.traverseHistory': { + params: Bidi.BrowsingContext.TraverseHistoryParameters; + returnType: Bidi.EmptyResult; + }; + + 'input.performActions': { + params: Bidi.Input.PerformActionsParameters; + returnType: Bidi.EmptyResult; + }; + 'input.releaseActions': { + params: Bidi.Input.ReleaseActionsParameters; + returnType: Bidi.EmptyResult; + }; + + 'session.end': { + params: Bidi.EmptyParams; + returnType: Bidi.EmptyResult; + }; + 'session.new': { + params: Bidi.Session.NewParameters; + returnType: Bidi.Session.NewResult; + }; + 'session.status': { + params: object; + returnType: Bidi.Session.StatusResult; + }; + 'session.subscribe': { + params: Bidi.Session.SubscriptionRequest; + returnType: Bidi.EmptyResult; + }; + 'session.unsubscribe': { + params: Bidi.Session.SubscriptionRequest; + returnType: Bidi.EmptyResult; + }; +} + +/** + * @internal + */ +export type BidiEvents = { + [K in Bidi.ChromiumBidi.Event['method']]: Extract< + Bidi.ChromiumBidi.Event, + {method: K} + >['params']; +}; + +/** + * @internal + */ +export interface Connection + extends EventEmitter { + send( + method: T, + params: Commands[T]['params'] + ): Promise<{result: Commands[T]['returnType']}>; + + // This will pipe events into the provided emitter. + pipeTo(emitter: EventEmitter): void; +} diff --git a/packages/puppeteer-core/src/bidi/core/Navigation.ts b/packages/puppeteer-core/src/bidi/core/Navigation.ts new file mode 100644 index 00000000000..0ddb181d6a2 --- /dev/null +++ b/packages/puppeteer-core/src/bidi/core/Navigation.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {Deferred} from '../../util/Deferred.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {Request} from './Request.js'; + +/** + * @internal + */ +export interface NavigationInfo { + url: string; + timestamp: Date; +} + +/** + * @internal + */ +export class Navigation extends EventEmitter<{ + fragment: NavigationInfo; + failed: NavigationInfo; + aborted: NavigationInfo; +}> { + static from(context: BrowsingContext, url: string): Navigation { + const navigation = new Navigation(context, url); + navigation.#initialize(); + return navigation; + } + + // keep-sorted start + #context: BrowsingContext; + #id = new Deferred(); + #request: Request | undefined; + #url: string; + // keep-sorted end + + private constructor(context: BrowsingContext, url: string) { + super(); + + // keep-sorted start + this.#context = context; + this.#url = url; + // keep-sorted end + } + + #initialize() { + // /////////////////////// + // Session listeners // + // /////////////////////// + const session = this.#session; + for (const [bidiEvent, event] of [ + ['browsingContext.fragmentNavigated', 'fragment'], + ['browsingContext.navigationFailed', 'failed'], + ['browsingContext.navigationAborted', 'aborted'], + ] as const) { + session.on(bidiEvent, (info: Bidi.BrowsingContext.NavigationInfo) => { + if (info.context !== this.#context.id) { + return; + } + if (!info.navigation) { + return; + } + if (!this.#id.resolved()) { + this.#id.resolve(info.navigation); + } + if (this.#id.value() !== info.navigation) { + return; + } + this.#url = info.url; + this.emit(event, { + url: this.#url, + timestamp: new Date(info.timestamp), + }); + }); + } + + // /////////////////// + // Parent listeners // + // /////////////////// + this.#context.on('request', ({request}) => { + if (request.navigation === this.#id.value()) { + this.#request = request; + } + }); + } + + get #session() { + return this.#context.userContext.browser.session; + } + + get url(): string { + return this.#url; + } + + request(): Request | undefined { + return this.#request; + } +} diff --git a/packages/puppeteer-core/src/bidi/core/Realm.ts b/packages/puppeteer-core/src/bidi/core/Realm.ts new file mode 100644 index 00000000000..7aff1eb8ebf --- /dev/null +++ b/packages/puppeteer-core/src/bidi/core/Realm.ts @@ -0,0 +1,283 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {Session} from './Session.js'; + +/** + * @internal + */ +export type CallFunctionOptions = Omit< + Bidi.Script.CallFunctionParameters, + 'functionDeclaration' | 'awaitPromise' | 'target' +>; + +/** + * @internal + */ +export type EvaluateOptions = Omit< + Bidi.Script.EvaluateParameters, + 'expression' | 'awaitPromise' | 'target' +>; + +/** + * @internal + */ +export abstract class Realm extends EventEmitter<{ + /** Emitted when the realm is destroyed. */ + destroyed: void; + /** Emitted when a dedicated worker is created in the realm. */ + worker: DedicatedWorkerRealm; + /** Emitted when a shared worker is created in the realm. */ + sharedworker: SharedWorkerRealm; +}> { + readonly id: string; + readonly origin: string; + protected constructor(id: string, origin: string) { + super(); + this.id = id; + this.origin = origin; + } + + protected initialize(): void { + this.session.on('script.realmDestroyed', info => { + if (info.realm === this.id) { + this.emit('destroyed', undefined); + } + }); + } + + protected abstract get session(): Session; + + protected get target(): Bidi.Script.Target { + return {realm: this.id}; + } + + async disown(handles: string[]): Promise { + await this.session.send('script.disown', { + target: this.target, + handles, + }); + } + + async callFunction( + functionDeclaration: string, + awaitPromise: boolean, + options: CallFunctionOptions = {} + ): Promise { + const {result} = await this.session.send('script.callFunction', { + functionDeclaration, + awaitPromise, + target: this.target, + ...options, + }); + return result; + } + + async evaluate( + expression: string, + awaitPromise: boolean, + options: EvaluateOptions = {} + ): Promise { + const {result} = await this.session.send('script.evaluate', { + expression, + awaitPromise, + target: this.target, + ...options, + }); + return result; + } +} + +export class WindowRealm extends Realm { + static from(context: BrowsingContext, sandbox?: string): WindowRealm { + const realm = new WindowRealm(context, sandbox); + realm.initialize(); + return realm; + } + + readonly browsingContext: BrowsingContext; + readonly sandbox?: string; + + readonly #workers: { + dedicated: Map; + shared: Map; + } = { + dedicated: new Map(), + shared: new Map(), + }; + + constructor(context: BrowsingContext, sandbox?: string) { + super('', ''); + this.browsingContext = context; + this.sandbox = sandbox; + } + + override initialize(): void { + super.initialize(); + + // /////////////////////// + // Session listeners // + // /////////////////////// + this.session.on('script.realmCreated', info => { + if (info.type === 'window') { + // SAFETY: This is the only time we allow mutations. + (this as any).id = info.realm; + return; + } + if (info.type === 'dedicated-worker') { + if (!info.owners.includes(this.id)) { + return; + } + + const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); + realm.on('destroyed', () => { + this.#workers.dedicated.delete(realm.id); + }); + + this.#workers.dedicated.set(realm.id, realm); + + this.emit('worker', realm); + } + }); + + // /////////////////// + // Parent listeners // + // /////////////////// + this.browsingContext.userContext.browser.on('sharedworker', ({realm}) => { + if (realm.owners.has(this)) { + realm.on('destroyed', () => { + this.#workers.shared.delete(realm.id); + }); + + this.#workers.shared.set(realm.id, realm); + + this.emit('sharedworker', realm); + } + }); + } + + override get session(): Session { + return this.browsingContext.userContext.browser.session; + } + + override get target(): Bidi.Script.Target { + return {context: this.browsingContext.id, sandbox: this.sandbox}; + } +} + +export type DedicatedWorkerOwnerRealm = + | DedicatedWorkerRealm + | SharedWorkerRealm + | WindowRealm; + +export class DedicatedWorkerRealm extends Realm { + static from( + owner: DedicatedWorkerOwnerRealm, + id: string, + origin: string + ): DedicatedWorkerRealm { + const realm = new DedicatedWorkerRealm(owner, id, origin); + realm.initialize(); + return realm; + } + + readonly owners: Set; + + readonly #workers = new Map(); + + constructor(owner: DedicatedWorkerOwnerRealm, id: string, origin: string) { + super(id, origin); + this.owners = new Set([owner]); + } + + override initialize(): void { + super.initialize(); + + // /////////////////////// + // Session listeners // + // /////////////////////// + this.session.on('script.realmCreated', info => { + if (info.type === 'dedicated-worker') { + if (!info.owners.includes(this.id)) { + return; + } + + const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); + realm.on('destroyed', () => { + this.#workers.delete(realm.id); + }); + + this.#workers.set(realm.id, realm); + + this.emit('worker', realm); + } + }); + } + + override get session(): Session { + // SAFETY: At least one owner will exist. + return this.owners.values().next().value.session; + } +} + +export class SharedWorkerRealm extends Realm { + static from( + owners: [WindowRealm, ...WindowRealm[]], + id: string, + origin: string + ): SharedWorkerRealm { + const realm = new SharedWorkerRealm(owners, id, origin); + realm.initialize(); + return realm; + } + + readonly owners: Set; + + readonly #workers = new Map(); + + constructor( + owners: [WindowRealm, ...WindowRealm[]], + id: string, + origin: string + ) { + super(id, origin); + this.owners = new Set(owners); + } + + override initialize(): void { + super.initialize(); + + // /////////////////////// + // Session listeners // + // /////////////////////// + this.session.on('script.realmCreated', info => { + if (info.type === 'dedicated-worker') { + if (!info.owners.includes(this.id)) { + return; + } + + const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); + realm.on('destroyed', () => { + this.#workers.delete(realm.id); + }); + + this.#workers.set(realm.id, realm); + + this.emit('worker', realm); + } + }); + } + + override get session(): Session { + // SAFETY: At least one owner will exist. + return this.owners.values().next().value.session; + } +} diff --git a/packages/puppeteer-core/src/bidi/core/Request.ts b/packages/puppeteer-core/src/bidi/core/Request.ts new file mode 100644 index 00000000000..c21f45757dd --- /dev/null +++ b/packages/puppeteer-core/src/bidi/core/Request.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export class Request extends EventEmitter<{ + // Emitted whenever a redirect is received. + redirect: Request; + // Emitted when when the request succeeds. + success: Bidi.Network.ResponseData; + // Emitted when when the request errors. + error: string; +}> { + readonly #context: BrowsingContext; + readonly #event: Bidi.Network.BeforeRequestSentParameters; + + #response?: Bidi.Network.ResponseData; + #redirect?: Request; + #error?: string; + + constructor( + context: BrowsingContext, + event: Bidi.Network.BeforeRequestSentParameters + ) { + super(); + this.#context = context; + this.#event = event; + + const session = this.#session; + session.on('network.beforeRequestSent', event => { + if (event.context !== this.id) { + return; + } + if (event.request.request !== this.id) { + return; + } + if (this.#redirect) { + return; + } + this.#redirect = new Request(this.#context, event); + this.emit('redirect', this.#redirect); + }); + session.on('network.fetchError', event => { + if (event.context !== this.#context.id) { + return; + } + if (event.request.request !== this.id) { + return; + } + this.#error = event.errorText; + this.emit('error', this.#error); + }); + session.on('network.responseCompleted', event => { + if (event.context !== this.#context.id) { + return; + } + if (event.request.request !== this.id) { + return; + } + this.#response = event.response; + this.emit('success', this.#response); + }); + } + + get #session() { + return this.#context.userContext.browser.session; + } + + get id(): string { + return this.#event.request.request; + } + + get url(): string { + return this.#event.request.url; + } + + get initiator(): Bidi.Network.Initiator { + return this.#event.initiator; + } + + get method(): string { + return this.#event.request.method; + } + + get headers(): Bidi.Network.Header[] { + return this.#event.request.headers; + } + + get navigation(): string | undefined { + return this.#event.navigation ?? undefined; + } + + get redirect(): Request | undefined { + return this.redirect; + } + + get response(): Bidi.Network.ResponseData | undefined { + return this.#response; + } + + get error(): string | undefined { + return this.#error; + } +} diff --git a/packages/puppeteer-core/src/bidi/core/Session.ts b/packages/puppeteer-core/src/bidi/core/Session.ts new file mode 100644 index 00000000000..02feb6bc5e9 --- /dev/null +++ b/packages/puppeteer-core/src/bidi/core/Session.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {debugError} from '../../common/util.js'; +import {throwIfDisposed} from '../../util/decorators.js'; +import type {BidiEvents} from '../Connection.js'; + +import {Browser} from './Browser.js'; +import type {Connection} from './Connection.js'; +import type {Commands} from './Connection.js'; + +const MAX_RETRIES = 5; + +/** + * @internal + */ +export class Session + extends EventEmitter + implements Connection +{ + static async from( + connection: Connection, + capabilities: Bidi.Session.CapabilitiesRequest + ): Promise { + // Wait until the session is ready. + let status = {message: '', ready: false}; + for (let i = 0; i < MAX_RETRIES; ++i) { + status = (await connection.send('session.status', {})).result; + if (status.ready) { + break; + } + // Backoff a little bit each time. + await new Promise(resolve => { + return setTimeout(resolve, (1 << i) * 100); + }); + } + if (!status.ready) { + throw new Error(status.message); + } + + let result; + try { + result = ( + await connection.send('session.new', { + capabilities, + }) + ).result; + } catch (err) { + // Chrome does not support session.new. + debugError(err); + result = { + sessionId: '', + capabilities: { + acceptInsecureCerts: false, + browserName: 'chrome', + browserVersion: '', + platformName: '', + setWindowRect: false, + webSocketUrl: '', + }, + }; + } + + const session = new Session(connection, result); + await session.#initialize(); + return session; + } + + readonly #connection: Connection; + + readonly #info: Bidi.Session.NewResult; + readonly browser!: Browser; + + #reason: string | undefined; + + private constructor(connection: Connection, info: Bidi.Session.NewResult) { + super(); + this.#connection = connection; + this.#info = info; + } + + async #initialize(): Promise { + // /////////////////////// + // Connection listeners // + // /////////////////////// + this.#connection.pipeTo(this); + + // ////////////////////////////// + // Asynchronous initialization // + // ////////////////////////////// + // SAFETY: We use `any` to allow assignment of the readonly property. + (this as any).browser = await Browser.from(this); + + // ////////////////// + // Child listeners // + // ////////////////// + this.browser.once('closed', ({reason}) => { + this.#reason = reason; + this.emit('ended', {reason}); + this.removeAllListeners(); + }); + } + + get disposed(): boolean { + return this.#reason !== undefined; + } + + get id(): string { + return this.#info.sessionId; + } + + get capabilities(): Bidi.Session.NewResult['capabilities'] { + return this.#info.capabilities; + } + + pipeTo(emitter: EventEmitter): void { + this.#connection.pipeTo(emitter); + } + + /** + * Currently, there is a 1:1 relationship between the session and the + * session. In the future, we might support multiple sessions and in that + * case we always needs to make sure that the session for the right session + * object is used, so we implement this method here, although it's not defined + * in the spec. + */ + @throwIfDisposed((session: Session) => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return session.#reason!; + }) + async send( + method: T, + params: Commands[T]['params'] + ): Promise<{result: Commands[T]['returnType']}> { + return await this.#connection.send(method, params); + } + + @throwIfDisposed((session: Session) => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return session.#reason!; + }) + async subscribe(events: string[]): Promise { + await this.send('session.subscribe', { + events, + }); + } + + @throwIfDisposed((session: Session) => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return session.#reason!; + }) + async end(): Promise { + await this.send('session.end', {}); + this.#reason = `Session (${this.id}) has already ended.`; + this.emit('ended', {reason: this.#reason}); + this.removeAllListeners(); + } +} diff --git a/packages/puppeteer-core/src/bidi/core/UserContext.ts b/packages/puppeteer-core/src/bidi/core/UserContext.ts new file mode 100644 index 00000000000..57da3b00cf6 --- /dev/null +++ b/packages/puppeteer-core/src/bidi/core/UserContext.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {assert} from '../../util/assert.js'; + +import type {Browser} from './Browser.js'; +import {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export type CreateBrowsingContextOptions = Omit< + Bidi.BrowsingContext.CreateParameters, + 'type' | 'referenceContext' +> & { + referenceContext?: BrowsingContext; +}; + +/** + * @internal + */ +export class UserContext extends EventEmitter<{ + /** + * Emitted when a new browsing context is created. + */ + browsingcontext: { + /** The new browsing context. */ + browsingContext: BrowsingContext; + }; +}> { + static create(browser: Browser, id: string): UserContext { + const context = new UserContext(browser, id); + context.#initialize(); + return context; + } + + // keep-sorted start + // Note these are only top-level contexts. + readonly #browsingContexts = new Map(); + // @ts-expect-error -- TODO: This will be used once the WebDriver BiDi + // protocol supports it. + readonly #id: string; + readonly browser: Browser; + // keep-sorted end + + private constructor(browser: Browser, id: string) { + super(); + + // keep-sorted start + this.#id = id; + this.browser = browser; + // keep-sorted end + } + + #initialize() { + // /////////////////////// + // Session listeners // + // /////////////////////// + const session = this.#session; + session.on('browsingContext.contextCreated', info => { + if (info.parent) { + return; + } + + const browsingContext = BrowsingContext.from( + this, + undefined, + info.context, + info.url + ); + browsingContext.on('destroyed', () => { + this.#browsingContexts.delete(browsingContext.id); + }); + + this.#browsingContexts.set(browsingContext.id, browsingContext); + + this.emit('browsingcontext', {browsingContext}); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.browser.session; + } + get browsingContexts(): Iterable { + return this.#browsingContexts.values(); + } + // keep-sorted end + + async createBrowsingContext( + type: Bidi.BrowsingContext.CreateType, + options: CreateBrowsingContextOptions = {} + ): Promise { + const { + result: {context: contextId}, + } = await this.#session.send('browsingContext.create', { + type, + ...options, + referenceContext: options.referenceContext?.id, + }); + + const browsingContext = this.#browsingContexts.get(contextId); + assert( + browsingContext, + 'The WebDriver BiDi implementation is failing to create a browsing context correctly.' + ); + + // We use an array to avoid the promise from being awaited. + return browsingContext; + } + + async close(): Promise { + const promises = []; + for (const browsingContext of this.#browsingContexts.values()) { + promises.push(browsingContext.close()); + } + await Promise.all(promises); + } +} diff --git a/packages/puppeteer-core/src/bidi/core/UserPrompt.ts b/packages/puppeteer-core/src/bidi/core/UserPrompt.ts new file mode 100644 index 00000000000..fb2c1df1caf --- /dev/null +++ b/packages/puppeteer-core/src/bidi/core/UserPrompt.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export type HandleOptions = Omit< + Bidi.BrowsingContext.HandleUserPromptParameters, + 'context' +>; + +/** + * @internal + */ +export type UserPromptResult = Omit< + Bidi.BrowsingContext.UserPromptClosedParameters, + 'context' +>; + +/** + * @internal + */ +export class UserPrompt extends EventEmitter<{ + handled: UserPromptResult; +}> { + static from( + browsingContext: BrowsingContext, + info: Bidi.BrowsingContext.UserPromptOpenedParameters + ): UserPrompt { + const userPrompt = new UserPrompt(browsingContext, info); + userPrompt.#initialize(); + return userPrompt; + } + + // keep-sorted start + #result?: UserPromptResult; + readonly info: Bidi.BrowsingContext.UserPromptOpenedParameters; + readonly browsingContext: BrowsingContext; + // keep-sorted end + + private constructor( + context: BrowsingContext, + info: Bidi.BrowsingContext.UserPromptOpenedParameters + ) { + super(); + + // keep-sorted start + this.info = info; + this.browsingContext = context; + // keep-sorted end + } + + #initialize() { + // /////////////////////// + // Session listeners // + // /////////////////////// + this.#session.on('browsingContext.userPromptClosed', parameters => { + if (parameters.context !== this.browsingContext.id) { + return; + } + this.#result = parameters; + this.emit('handled', parameters); + this.removeAllListeners(); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.browsingContext.userContext.browser.session; + } + get result(): UserPromptResult | undefined { + return this.#result; + } + // keep-sorted end + + async handle(options: HandleOptions = {}): Promise { + await this.#session.send('browsingContext.handleUserPrompt', { + ...options, + context: this.info.context, + }); + // SAFETY: `handled` is triggered before the above promise resolved. + return this.#result!; + } +} diff --git a/packages/puppeteer-core/src/bidi/core/core.ts b/packages/puppeteer-core/src/bidi/core/core.ts new file mode 100644 index 00000000000..203281614b4 --- /dev/null +++ b/packages/puppeteer-core/src/bidi/core/core.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './Browser.js'; +export * from './BrowsingContext.js'; +export * from './Connection.js'; +export * from './Navigation.js'; +export * from './Realm.js'; +export * from './Request.js'; +export * from './Session.js'; +export * from './UserContext.js'; +export * from './UserPrompt.js';