tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

Frame.ts (18750B)


      1 /**
      2 * @license
      3 * Copyright 2023 Google Inc.
      4 * SPDX-License-Identifier: Apache-2.0
      5 */
      6 
      7 import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
      8 
      9 import type {Observable} from '../../third_party/rxjs/rxjs.js';
     10 import {
     11  combineLatest,
     12  defer,
     13  delayWhen,
     14  filter,
     15  first,
     16  firstValueFrom,
     17  map,
     18  of,
     19  race,
     20  raceWith,
     21  switchMap,
     22 } from '../../third_party/rxjs/rxjs.js';
     23 import type {CDPSession} from '../api/CDPSession.js';
     24 import {
     25  Frame,
     26  throwIfDetached,
     27  type GoToOptions,
     28  type WaitForOptions,
     29 } from '../api/Frame.js';
     30 import {PageEvent} from '../api/Page.js';
     31 import {Accessibility} from '../cdp/Accessibility.js';
     32 import type {ConsoleMessageType} from '../common/ConsoleMessage.js';
     33 import {
     34  ConsoleMessage,
     35  type ConsoleMessageLocation,
     36 } from '../common/ConsoleMessage.js';
     37 import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js';
     38 import type {TimeoutSettings} from '../common/TimeoutSettings.js';
     39 import type {Awaitable} from '../common/types.js';
     40 import {
     41  debugError,
     42  fromAbortSignal,
     43  fromEmitterEvent,
     44  timeout,
     45 } from '../common/util.js';
     46 import {isErrorLike} from '../util/ErrorLike.js';
     47 
     48 import {BidiCdpSession} from './CDPSession.js';
     49 import type {BrowsingContext} from './core/BrowsingContext.js';
     50 import type {Navigation} from './core/Navigation.js';
     51 import type {Request} from './core/Request.js';
     52 import {BidiDeserializer} from './Deserializer.js';
     53 import {BidiDialog} from './Dialog.js';
     54 import type {BidiElementHandle} from './ElementHandle.js';
     55 import {ExposableFunction} from './ExposedFunction.js';
     56 import {BidiHTTPRequest, requests} from './HTTPRequest.js';
     57 import type {BidiHTTPResponse} from './HTTPResponse.js';
     58 import {BidiJSHandle} from './JSHandle.js';
     59 import type {BidiPage} from './Page.js';
     60 import type {BidiRealm} from './Realm.js';
     61 import {BidiFrameRealm} from './Realm.js';
     62 import {rewriteNavigationError} from './util.js';
     63 import {BidiWebWorker} from './WebWorker.js';
     64 
     65 // TODO: Remove this and map CDP the correct method.
     66 // Requires breaking change.
     67 function convertConsoleMessageLevel(method: string): ConsoleMessageType {
     68  switch (method) {
     69    case 'group':
     70      return 'startGroup';
     71    case 'groupCollapsed':
     72      return 'startGroupCollapsed';
     73    case 'groupEnd':
     74      return 'endGroup';
     75    default:
     76      return method as ConsoleMessageType;
     77  }
     78 }
     79 
     80 export class BidiFrame extends Frame {
     81  static from(
     82    parent: BidiPage | BidiFrame,
     83    browsingContext: BrowsingContext,
     84  ): BidiFrame {
     85    const frame = new BidiFrame(parent, browsingContext);
     86    frame.#initialize();
     87    return frame;
     88  }
     89 
     90  readonly #parent: BidiPage | BidiFrame;
     91  readonly browsingContext: BrowsingContext;
     92  readonly #frames = new WeakMap<BrowsingContext, BidiFrame>();
     93  readonly realms: {default: BidiFrameRealm; internal: BidiFrameRealm};
     94 
     95  override readonly _id: string;
     96  override readonly client: BidiCdpSession;
     97  override readonly accessibility: Accessibility;
     98 
     99  private constructor(
    100    parent: BidiPage | BidiFrame,
    101    browsingContext: BrowsingContext,
    102  ) {
    103    super();
    104    this.#parent = parent;
    105    this.browsingContext = browsingContext;
    106 
    107    this._id = browsingContext.id;
    108    this.client = new BidiCdpSession(this);
    109    this.realms = {
    110      default: BidiFrameRealm.from(this.browsingContext.defaultRealm, this),
    111      internal: BidiFrameRealm.from(
    112        this.browsingContext.createWindowRealm(
    113          `__puppeteer_internal_${Math.ceil(Math.random() * 10000)}`,
    114        ),
    115        this,
    116      ),
    117    };
    118    this.accessibility = new Accessibility(this.realms.default, this._id);
    119  }
    120 
    121  #initialize(): void {
    122    for (const browsingContext of this.browsingContext.children) {
    123      this.#createFrameTarget(browsingContext);
    124    }
    125 
    126    this.browsingContext.on('browsingcontext', ({browsingContext}) => {
    127      this.#createFrameTarget(browsingContext);
    128    });
    129    this.browsingContext.on('closed', () => {
    130      for (const session of BidiCdpSession.sessions.values()) {
    131        if (session.frame === this) {
    132          session.onClose();
    133        }
    134      }
    135      this.page().trustedEmitter.emit(PageEvent.FrameDetached, this);
    136    });
    137 
    138    this.browsingContext.on('request', ({request}) => {
    139      const httpRequest = BidiHTTPRequest.from(request, this);
    140      request.once('success', () => {
    141        this.page().trustedEmitter.emit(PageEvent.RequestFinished, httpRequest);
    142      });
    143 
    144      request.once('error', () => {
    145        this.page().trustedEmitter.emit(PageEvent.RequestFailed, httpRequest);
    146      });
    147      void httpRequest.finalizeInterceptions();
    148    });
    149 
    150    this.browsingContext.on('navigation', ({navigation}) => {
    151      navigation.once('fragment', () => {
    152        this.page().trustedEmitter.emit(PageEvent.FrameNavigated, this);
    153      });
    154    });
    155    this.browsingContext.on('load', () => {
    156      this.page().trustedEmitter.emit(PageEvent.Load, undefined);
    157    });
    158    this.browsingContext.on('DOMContentLoaded', () => {
    159      this._hasStartedLoading = true;
    160      this.page().trustedEmitter.emit(PageEvent.DOMContentLoaded, undefined);
    161      this.page().trustedEmitter.emit(PageEvent.FrameNavigated, this);
    162    });
    163 
    164    this.browsingContext.on('userprompt', ({userPrompt}) => {
    165      this.page().trustedEmitter.emit(
    166        PageEvent.Dialog,
    167        BidiDialog.from(userPrompt),
    168      );
    169    });
    170 
    171    this.browsingContext.on('log', ({entry}) => {
    172      if (this._id !== entry.source.context) {
    173        return;
    174      }
    175      if (isConsoleLogEntry(entry)) {
    176        const args = entry.args.map(arg => {
    177          return this.mainRealm().createHandle(arg);
    178        });
    179 
    180        const text = args
    181          .reduce((value, arg) => {
    182            const parsedValue =
    183              arg instanceof BidiJSHandle && arg.isPrimitiveValue
    184                ? BidiDeserializer.deserialize(arg.remoteValue())
    185                : arg.toString();
    186            return `${value} ${parsedValue}`;
    187          }, '')
    188          .slice(1);
    189 
    190        this.page().trustedEmitter.emit(
    191          PageEvent.Console,
    192          new ConsoleMessage(
    193            convertConsoleMessageLevel(entry.method),
    194            text,
    195            args,
    196            getStackTraceLocations(entry.stackTrace),
    197            this,
    198          ),
    199        );
    200      } else if (isJavaScriptLogEntry(entry)) {
    201        const error = new Error(entry.text ?? '');
    202 
    203        const messageHeight = error.message.split('\n').length;
    204        const messageLines = error.stack!.split('\n').splice(0, messageHeight);
    205 
    206        const stackLines = [];
    207        if (entry.stackTrace) {
    208          for (const frame of entry.stackTrace.callFrames) {
    209            // Note we need to add `1` because the values are 0-indexed.
    210            stackLines.push(
    211              `    at ${frame.functionName || '<anonymous>'} (${frame.url}:${
    212                frame.lineNumber + 1
    213              }:${frame.columnNumber + 1})`,
    214            );
    215            if (stackLines.length >= Error.stackTraceLimit) {
    216              break;
    217            }
    218          }
    219        }
    220 
    221        error.stack = [...messageLines, ...stackLines].join('\n');
    222        this.page().trustedEmitter.emit(PageEvent.PageError, error);
    223      } else {
    224        debugError(
    225          `Unhandled LogEntry with type "${entry.type}", text "${entry.text}" and level "${entry.level}"`,
    226        );
    227      }
    228    });
    229 
    230    this.browsingContext.on('worker', ({realm}) => {
    231      const worker = BidiWebWorker.from(this, realm);
    232      realm.on('destroyed', () => {
    233        this.page().trustedEmitter.emit(PageEvent.WorkerDestroyed, worker);
    234      });
    235      this.page().trustedEmitter.emit(PageEvent.WorkerCreated, worker);
    236    });
    237  }
    238 
    239  #createFrameTarget(browsingContext: BrowsingContext) {
    240    const frame = BidiFrame.from(this, browsingContext);
    241    this.#frames.set(browsingContext, frame);
    242    this.page().trustedEmitter.emit(PageEvent.FrameAttached, frame);
    243 
    244    browsingContext.on('closed', () => {
    245      this.#frames.delete(browsingContext);
    246    });
    247 
    248    return frame;
    249  }
    250 
    251  get timeoutSettings(): TimeoutSettings {
    252    return this.page()._timeoutSettings;
    253  }
    254 
    255  override mainRealm(): BidiFrameRealm {
    256    return this.realms.default;
    257  }
    258 
    259  override isolatedRealm(): BidiFrameRealm {
    260    return this.realms.internal;
    261  }
    262 
    263  realm(id: string): BidiRealm | undefined {
    264    for (const realm of Object.values(this.realms)) {
    265      if (realm.realm.id === id) {
    266        return realm;
    267      }
    268    }
    269    return;
    270  }
    271 
    272  override page(): BidiPage {
    273    let parent = this.#parent;
    274    while (parent instanceof BidiFrame) {
    275      parent = parent.#parent;
    276    }
    277    return parent;
    278  }
    279 
    280  override url(): string {
    281    return this.browsingContext.url;
    282  }
    283 
    284  override parentFrame(): BidiFrame | null {
    285    if (this.#parent instanceof BidiFrame) {
    286      return this.#parent;
    287    }
    288    return null;
    289  }
    290 
    291  override childFrames(): BidiFrame[] {
    292    return [...this.browsingContext.children].map(child => {
    293      return this.#frames.get(child)!;
    294    });
    295  }
    296 
    297  #detached$() {
    298    return defer(() => {
    299      if (this.detached) {
    300        return of(this as Frame);
    301      }
    302      return fromEmitterEvent(
    303        this.page().trustedEmitter,
    304        PageEvent.FrameDetached,
    305      ).pipe(
    306        filter(detachedFrame => {
    307          return detachedFrame === this;
    308        }),
    309      );
    310    });
    311  }
    312 
    313  @throwIfDetached
    314  override async goto(
    315    url: string,
    316    options: GoToOptions = {},
    317  ): Promise<BidiHTTPResponse | null> {
    318    const [response] = await Promise.all([
    319      this.waitForNavigation(options),
    320      // Some implementations currently only report errors when the
    321      // readiness=interactive.
    322      //
    323      // Related: https://bugzilla.mozilla.org/show_bug.cgi?id=1846601
    324      this.browsingContext
    325        .navigate(url, Bidi.BrowsingContext.ReadinessState.Interactive)
    326        .catch(error => {
    327          if (
    328            isErrorLike(error) &&
    329            error.message.includes('net::ERR_HTTP_RESPONSE_CODE_FAILURE')
    330          ) {
    331            return;
    332          }
    333 
    334          if (error.message.includes('navigation canceled')) {
    335            return;
    336          }
    337 
    338          if (
    339            error.message.includes(
    340              'Navigation was aborted by another navigation',
    341            )
    342          ) {
    343            return;
    344          }
    345 
    346          throw error;
    347        }),
    348    ]).catch(
    349      rewriteNavigationError(
    350        url,
    351        options.timeout ?? this.timeoutSettings.navigationTimeout(),
    352      ),
    353    );
    354    return response;
    355  }
    356 
    357  @throwIfDetached
    358  override async setContent(
    359    html: string,
    360    options: WaitForOptions = {},
    361  ): Promise<void> {
    362    await Promise.all([
    363      this.setFrameContent(html),
    364      firstValueFrom(
    365        combineLatest([
    366          this.#waitForLoad$(options),
    367          this.#waitForNetworkIdle$(options),
    368        ]),
    369      ),
    370    ]);
    371  }
    372 
    373  @throwIfDetached
    374  override async waitForNavigation(
    375    options: WaitForOptions = {},
    376  ): Promise<BidiHTTPResponse | null> {
    377    const {timeout: ms = this.timeoutSettings.navigationTimeout(), signal} =
    378      options;
    379 
    380    const frames = this.childFrames().map(frame => {
    381      return frame.#detached$();
    382    });
    383    return await firstValueFrom(
    384      combineLatest([
    385        race(
    386          fromEmitterEvent(this.browsingContext, 'navigation'),
    387          fromEmitterEvent(this.browsingContext, 'historyUpdated').pipe(
    388            map(() => {
    389              return {navigation: null};
    390            }),
    391          ),
    392        )
    393          .pipe(first())
    394          .pipe(
    395            switchMap(({navigation}) => {
    396              if (navigation === null) {
    397                return of(null);
    398              }
    399              return this.#waitForLoad$(options).pipe(
    400                delayWhen(() => {
    401                  if (frames.length === 0) {
    402                    return of(undefined);
    403                  }
    404                  return combineLatest(frames);
    405                }),
    406                raceWith(
    407                  fromEmitterEvent(navigation, 'fragment'),
    408                  fromEmitterEvent(navigation, 'failed'),
    409                  fromEmitterEvent(navigation, 'aborted'),
    410                ),
    411                switchMap(() => {
    412                  if (navigation.request) {
    413                    function requestFinished$(
    414                      request: Request,
    415                    ): Observable<Navigation | null> {
    416                      if (navigation === null) {
    417                        return of(null);
    418                      }
    419                      // Reduces flakiness if the response events arrive after
    420                      // the load event.
    421                      // Usually, the response or error is already there at this point.
    422                      if (request.response || request.error) {
    423                        return of(navigation);
    424                      }
    425                      if (request.redirect) {
    426                        return requestFinished$(request.redirect);
    427                      }
    428                      return fromEmitterEvent(request, 'success')
    429                        .pipe(
    430                          raceWith(fromEmitterEvent(request, 'error')),
    431                          raceWith(fromEmitterEvent(request, 'redirect')),
    432                        )
    433                        .pipe(
    434                          switchMap(() => {
    435                            return requestFinished$(request);
    436                          }),
    437                        );
    438                    }
    439                    return requestFinished$(navigation.request);
    440                  }
    441                  return of(navigation);
    442                }),
    443              );
    444            }),
    445          ),
    446        this.#waitForNetworkIdle$(options),
    447      ]).pipe(
    448        map(([navigation]) => {
    449          if (!navigation) {
    450            return null;
    451          }
    452          const request = navigation.request;
    453          if (!request) {
    454            return null;
    455          }
    456          const lastRequest = request.lastRedirect ?? request;
    457          const httpRequest = requests.get(lastRequest)!;
    458          return httpRequest.response();
    459        }),
    460        raceWith(
    461          timeout(ms),
    462          fromAbortSignal(signal),
    463          this.#detached$().pipe(
    464            map(() => {
    465              throw new TargetCloseError('Frame detached.');
    466            }),
    467          ),
    468        ),
    469      ),
    470    );
    471  }
    472 
    473  override waitForDevicePrompt(): never {
    474    throw new UnsupportedOperation();
    475  }
    476 
    477  override get detached(): boolean {
    478    return this.browsingContext.closed;
    479  }
    480 
    481  #exposedFunctions = new Map<string, ExposableFunction<never[], unknown>>();
    482  async exposeFunction<Args extends unknown[], Ret>(
    483    name: string,
    484    apply: (...args: Args) => Awaitable<Ret>,
    485  ): Promise<void> {
    486    if (this.#exposedFunctions.has(name)) {
    487      throw new Error(
    488        `Failed to add page binding with name ${name}: globalThis['${name}'] already exists!`,
    489      );
    490    }
    491    const exposable = await ExposableFunction.from(this, name, apply);
    492    this.#exposedFunctions.set(name, exposable);
    493  }
    494 
    495  async removeExposedFunction(name: string): Promise<void> {
    496    const exposedFunction = this.#exposedFunctions.get(name);
    497    if (!exposedFunction) {
    498      throw new Error(
    499        `Failed to remove page binding with name ${name}: window['${name}'] does not exists!`,
    500      );
    501    }
    502 
    503    this.#exposedFunctions.delete(name);
    504    await exposedFunction[Symbol.asyncDispose]();
    505  }
    506 
    507  async createCDPSession(): Promise<CDPSession> {
    508    if (!this.page().browser().cdpSupported) {
    509      throw new UnsupportedOperation();
    510    }
    511 
    512    const cdpConnection = this.page().browser().cdpConnection!;
    513    return await cdpConnection._createSession({targetId: this._id});
    514  }
    515 
    516  @throwIfDetached
    517  #waitForLoad$(options: WaitForOptions = {}): Observable<void> {
    518    let {waitUntil = 'load'} = options;
    519    const {timeout: ms = this.timeoutSettings.navigationTimeout()} = options;
    520 
    521    if (!Array.isArray(waitUntil)) {
    522      waitUntil = [waitUntil];
    523    }
    524 
    525    const events = new Set<'load' | 'DOMContentLoaded'>();
    526    for (const lifecycleEvent of waitUntil) {
    527      switch (lifecycleEvent) {
    528        case 'load': {
    529          events.add('load');
    530          break;
    531        }
    532        case 'domcontentloaded': {
    533          events.add('DOMContentLoaded');
    534          break;
    535        }
    536      }
    537    }
    538    if (events.size === 0) {
    539      return of(undefined);
    540    }
    541 
    542    return combineLatest(
    543      [...events].map(event => {
    544        return fromEmitterEvent(this.browsingContext, event);
    545      }),
    546    ).pipe(
    547      map(() => {}),
    548      first(),
    549      raceWith(
    550        timeout(ms),
    551        this.#detached$().pipe(
    552          map(() => {
    553            throw new Error('Frame detached.');
    554          }),
    555        ),
    556      ),
    557    );
    558  }
    559 
    560  @throwIfDetached
    561  #waitForNetworkIdle$(options: WaitForOptions = {}): Observable<void> {
    562    let {waitUntil = 'load'} = options;
    563    if (!Array.isArray(waitUntil)) {
    564      waitUntil = [waitUntil];
    565    }
    566 
    567    let concurrency = Infinity;
    568    for (const event of waitUntil) {
    569      switch (event) {
    570        case 'networkidle0': {
    571          concurrency = Math.min(0, concurrency);
    572          break;
    573        }
    574        case 'networkidle2': {
    575          concurrency = Math.min(2, concurrency);
    576          break;
    577        }
    578      }
    579    }
    580    if (concurrency === Infinity) {
    581      return of(undefined);
    582    }
    583 
    584    return this.page().waitForNetworkIdle$({
    585      idleTime: 500,
    586      timeout: options.timeout ?? this.timeoutSettings.timeout(),
    587      concurrency,
    588    });
    589  }
    590 
    591  @throwIfDetached
    592  async setFiles(element: BidiElementHandle, files: string[]): Promise<void> {
    593    await this.browsingContext.setFiles(
    594      // SAFETY: ElementHandles are always remote references.
    595      element.remoteValue() as Bidi.Script.SharedReference,
    596      files,
    597    );
    598  }
    599 
    600  @throwIfDetached
    601  async locateNodes(
    602    element: BidiElementHandle,
    603    locator: Bidi.BrowsingContext.Locator,
    604  ): Promise<Bidi.Script.NodeRemoteValue[]> {
    605    return await this.browsingContext.locateNodes(
    606      locator,
    607      // SAFETY: ElementHandles are always remote references.
    608      [element.remoteValue() as Bidi.Script.SharedReference],
    609    );
    610  }
    611 }
    612 
    613 function isConsoleLogEntry(
    614  event: Bidi.Log.Entry,
    615 ): event is Bidi.Log.ConsoleLogEntry {
    616  return event.type === 'console';
    617 }
    618 
    619 function isJavaScriptLogEntry(
    620  event: Bidi.Log.Entry,
    621 ): event is Bidi.Log.JavascriptLogEntry {
    622  return event.type === 'javascript';
    623 }
    624 
    625 function getStackTraceLocations(
    626  stackTrace?: Bidi.Script.StackTrace,
    627 ): ConsoleMessageLocation[] {
    628  const stackTraceLocations: ConsoleMessageLocation[] = [];
    629  if (stackTrace) {
    630    for (const callFrame of stackTrace.callFrames) {
    631      stackTraceLocations.push({
    632        url: callFrame.url,
    633        lineNumber: callFrame.lineNumber,
    634        columnNumber: callFrame.columnNumber,
    635      });
    636    }
    637  }
    638  return stackTraceLocations;
    639 }