Skip to main content
Glama
PageCollector.ts10.2 kB
/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import {FakeIssuesManager} from './DevtoolsUtils.js'; import {logger} from './logger.js'; import type { Target, CDPSession, ConsoleMessage, Protocol, } from './third_party/index.js'; import {DevTools} from './third_party/index.js'; import { type Browser, type Frame, type Handler, type HTTPRequest, type Page, type PageEvents as PuppeteerPageEvents, } from './third_party/index.js'; interface PageEvents extends PuppeteerPageEvents { issue: DevTools.AggregatedIssue; } export type ListenerMap<EventMap extends PageEvents = PageEvents> = { [K in keyof EventMap]?: (event: EventMap[K]) => void; }; function createIdGenerator() { let i = 1; return () => { if (i === Number.MAX_SAFE_INTEGER) { i = 0; } return i++; }; } export const stableIdSymbol = Symbol('stableIdSymbol'); type WithSymbolId<T> = T & { [stableIdSymbol]?: number; }; export class PageCollector<T> { #browser: Browser; #listenersInitializer: ( collector: (item: T) => void, ) => ListenerMap<PageEvents>; #listeners = new WeakMap<Page, ListenerMap>(); #maxNavigationSaved = 3; /** * This maps a Page to a list of navigations with a sub-list * of all collected resources. * The newer navigations come first. */ protected storage = new WeakMap<Page, Array<Array<WithSymbolId<T>>>>(); constructor( browser: Browser, listeners: (collector: (item: T) => void) => ListenerMap<PageEvents>, ) { this.#browser = browser; this.#listenersInitializer = listeners; } async init(pages: Page[]) { for (const page of pages) { this.addPage(page); } this.#browser.on('targetcreated', this.#onTargetCreated); this.#browser.on('targetdestroyed', this.#onTargetDestroyed); } dispose() { this.#browser.off('targetcreated', this.#onTargetCreated); this.#browser.off('targetdestroyed', this.#onTargetDestroyed); } #onTargetCreated = async (target: Target) => { try { const page = await target.page(); if (!page) { return; } this.addPage(page); } catch (err) { logger('Error getting a page for a target onTargetCreated', err); } }; #onTargetDestroyed = async (target: Target) => { try { const page = await target.page(); if (!page) { return; } this.cleanupPageDestroyed(page); } catch (err) { logger('Error getting a page for a target onTargetDestroyed', err); } }; public addPage(page: Page) { this.#initializePage(page); } #initializePage(page: Page) { if (this.storage.has(page)) { return; } const idGenerator = createIdGenerator(); const storedLists: Array<Array<WithSymbolId<T>>> = [[]]; this.storage.set(page, storedLists); const listeners = this.#listenersInitializer(value => { const withId = value as WithSymbolId<T>; withId[stableIdSymbol] = idGenerator(); const navigations = this.storage.get(page) ?? [[]]; navigations[0].push(withId); }); listeners['framenavigated'] = (frame: Frame) => { // Only split the storage on main frame navigation if (frame !== page.mainFrame()) { return; } this.splitAfterNavigation(page); }; for (const [name, listener] of Object.entries(listeners)) { page.on(name, listener as Handler<unknown>); } this.#listeners.set(page, listeners); } protected splitAfterNavigation(page: Page) { const navigations = this.storage.get(page); if (!navigations) { return; } // Add the latest navigation first navigations.unshift([]); navigations.splice(this.#maxNavigationSaved); } protected cleanupPageDestroyed(page: Page) { const listeners = this.#listeners.get(page); if (listeners) { for (const [name, listener] of Object.entries(listeners)) { page.off(name, listener as Handler<unknown>); } } this.storage.delete(page); } getData(page: Page, includePreservedData?: boolean): T[] { const navigations = this.storage.get(page); if (!navigations) { return []; } if (!includePreservedData) { return navigations[0]; } const data: T[] = []; for (let index = this.#maxNavigationSaved; index >= 0; index--) { if (navigations[index]) { data.push(...navigations[index]); } } return data; } getIdForResource(resource: WithSymbolId<T>): number { return resource[stableIdSymbol] ?? -1; } getById(page: Page, stableId: number): T { const navigations = this.storage.get(page); if (!navigations) { throw new Error('No requests found for selected page'); } const item = this.find(page, item => item[stableIdSymbol] === stableId); if (item) { return item; } throw new Error('Request not found for selected page'); } find( page: Page, filter: (item: WithSymbolId<T>) => boolean, ): WithSymbolId<T> | undefined { const navigations = this.storage.get(page); if (!navigations) { return; } for (const navigation of navigations) { const item = navigation.find(filter); if (item) { return item; } } return; } } export class ConsoleCollector extends PageCollector< ConsoleMessage | Error | DevTools.AggregatedIssue > { #subscribedPages = new WeakMap<Page, PageIssueSubscriber>(); override addPage(page: Page): void { super.addPage(page); if (!this.#subscribedPages.has(page)) { const subscriber = new PageIssueSubscriber(page); this.#subscribedPages.set(page, subscriber); void subscriber.subscribe(); } } protected override cleanupPageDestroyed(page: Page): void { super.cleanupPageDestroyed(page); this.#subscribedPages.get(page)?.unsubscribe(); this.#subscribedPages.delete(page); } } class PageIssueSubscriber { #issueManager = new FakeIssuesManager(); #issueAggregator = new DevTools.IssueAggregator(this.#issueManager); #seenKeys = new Set<string>(); #seenIssues = new Set<DevTools.AggregatedIssue>(); #page: Page; #session: CDPSession; constructor(page: Page) { this.#page = page; // @ts-expect-error use existing CDP client (internal Puppeteer API). this.#session = this.#page._client() as CDPSession; } #resetIssueAggregator() { this.#issueManager = new FakeIssuesManager(); if (this.#issueAggregator) { this.#issueAggregator.removeEventListener( DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED, this.#onAggregatedissue, ); } this.#issueAggregator = new DevTools.IssueAggregator(this.#issueManager); this.#issueAggregator.addEventListener( DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED, this.#onAggregatedissue, ); } async subscribe() { this.#resetIssueAggregator(); this.#page.on('framenavigated', this.#onFrameNavigated); this.#session.on('Audits.issueAdded', this.#onIssueAdded); try { await this.#session.send('Audits.enable'); } catch (error) { logger('Error subscribing to issues', error); } } unsubscribe() { this.#seenKeys.clear(); this.#seenIssues.clear(); this.#page.off('framenavigated', this.#onFrameNavigated); this.#session.off('Audits.issueAdded', this.#onIssueAdded); if (this.#issueAggregator) { this.#issueAggregator.removeEventListener( DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED, this.#onAggregatedissue, ); } void this.#session.send('Audits.disable').catch(() => { // might fail. }); } #onAggregatedissue = ( event: DevTools.Common.EventTarget.EventTargetEvent<DevTools.AggregatedIssue>, ) => { if (this.#seenIssues.has(event.data)) { return; } this.#seenIssues.add(event.data); this.#page.emit('issue', event.data); }; // On navigation, we reset issue aggregation. #onFrameNavigated = (frame: Frame) => { // Only split the storage on main frame navigation if (frame !== frame.page().mainFrame()) { return; } this.#seenKeys.clear(); this.#seenIssues.clear(); this.#resetIssueAggregator(); }; #onIssueAdded = (data: Protocol.Audits.IssueAddedEvent) => { try { const inspectorIssue = data.issue; const issue = DevTools.createIssuesFromProtocolIssue( null, // @ts-expect-error Protocol types diverge. inspectorIssue, )[0]; if (!issue) { logger('No issue mapping for for the issue: ', inspectorIssue.code); return; } const primaryKey = issue.primaryKey(); if (this.#seenKeys.has(primaryKey)) { return; } this.#seenKeys.add(primaryKey); this.#issueManager.dispatchEventToListeners( DevTools.IssuesManagerEvents.ISSUE_ADDED, { issue, // @ts-expect-error We don't care that issues model is null issuesModel: null, }, ); } catch (error) { logger('Error creating a new issue', error); } }; } export class NetworkCollector extends PageCollector<HTTPRequest> { constructor( browser: Browser, listeners: ( collector: (item: HTTPRequest) => void, ) => ListenerMap<PageEvents> = collect => { return { request: req => { collect(req); }, } as ListenerMap; }, ) { super(browser, listeners); } override splitAfterNavigation(page: Page) { const navigations = this.storage.get(page) ?? []; if (!navigations) { return; } const requests = navigations[0]; const lastRequestIdx = requests.findLastIndex(request => { return request.frame() === page.mainFrame() ? request.isNavigationRequest() : false; }); // Keep all requests since the last navigation request including that // navigation request itself. // Keep the reference if (lastRequestIdx !== -1) { const fromCurrentNavigation = requests.splice(lastRequestIdx); navigations.unshift(fromCurrentNavigation); } else { navigations.unshift([]); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ChromeDevTools/chrome-devtools-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server