tor-browser

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

ExposedFunction.ts (6935B)


      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 {EventEmitter} from '../common/EventEmitter.js';
     10 import type {Awaitable, FlattenHandle} from '../common/types.js';
     11 import {debugError} from '../common/util.js';
     12 import {DisposableStack} from '../util/disposable.js';
     13 import {interpolateFunction, stringifyFunction} from '../util/Function.js';
     14 
     15 import type {Connection} from './core/Connection.js';
     16 import {BidiElementHandle} from './ElementHandle.js';
     17 import type {BidiFrame} from './Frame.js';
     18 import {BidiJSHandle} from './JSHandle.js';
     19 
     20 type CallbackChannel<Args, Ret> = (
     21  value: [
     22    resolve: (ret: FlattenHandle<Awaited<Ret>>) => void,
     23    reject: (error: unknown) => void,
     24    args: Args,
     25  ],
     26 ) => void;
     27 
     28 /**
     29 * @internal
     30 */
     31 export class ExposableFunction<Args extends unknown[], Ret> {
     32  static async from<Args extends unknown[], Ret>(
     33    frame: BidiFrame,
     34    name: string,
     35    apply: (...args: Args) => Awaitable<Ret>,
     36    isolate = false,
     37  ): Promise<ExposableFunction<Args, Ret>> {
     38    const func = new ExposableFunction(frame, name, apply, isolate);
     39    await func.#initialize();
     40    return func;
     41  }
     42 
     43  readonly #frame;
     44 
     45  readonly name;
     46  readonly #apply;
     47  readonly #isolate;
     48 
     49  readonly #channel;
     50 
     51  #scripts: Array<[BidiFrame, Bidi.Script.PreloadScript]> = [];
     52  #disposables = new DisposableStack();
     53 
     54  constructor(
     55    frame: BidiFrame,
     56    name: string,
     57    apply: (...args: Args) => Awaitable<Ret>,
     58    isolate = false,
     59  ) {
     60    this.#frame = frame;
     61    this.name = name;
     62    this.#apply = apply;
     63    this.#isolate = isolate;
     64 
     65    this.#channel = `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}`;
     66  }
     67 
     68  async #initialize() {
     69    const connection = this.#connection;
     70    const channel = {
     71      type: 'channel' as const,
     72      value: {
     73        channel: this.#channel,
     74        ownership: Bidi.Script.ResultOwnership.Root,
     75      },
     76    };
     77 
     78    const connectionEmitter = this.#disposables.use(
     79      new EventEmitter(connection),
     80    );
     81    connectionEmitter.on(
     82      Bidi.ChromiumBidi.Script.EventNames.Message,
     83      this.#handleMessage,
     84    );
     85 
     86    const functionDeclaration = stringifyFunction(
     87      interpolateFunction(
     88        (callback: CallbackChannel<Args, Ret>) => {
     89          Object.assign(globalThis, {
     90            [PLACEHOLDER('name') as string]: function (...args: Args) {
     91              return new Promise<FlattenHandle<Awaited<Ret>>>(
     92                (resolve, reject) => {
     93                  callback([resolve, reject, args]);
     94                },
     95              );
     96            },
     97          });
     98        },
     99        {name: JSON.stringify(this.name)},
    100      ),
    101    );
    102 
    103    const frames = [this.#frame];
    104    for (const frame of frames) {
    105      frames.push(...frame.childFrames());
    106    }
    107 
    108    await Promise.all(
    109      frames.map(async frame => {
    110        const realm = this.#isolate ? frame.isolatedRealm() : frame.mainRealm();
    111        try {
    112          const [script] = await Promise.all([
    113            frame.browsingContext.addPreloadScript(functionDeclaration, {
    114              arguments: [channel],
    115              sandbox: realm.sandbox,
    116            }),
    117            realm.realm.callFunction(functionDeclaration, false, {
    118              arguments: [channel],
    119            }),
    120          ]);
    121          this.#scripts.push([frame, script]);
    122        } catch (error) {
    123          // If it errors, the frame probably doesn't support call function. We
    124          // fail gracefully.
    125          debugError(error);
    126        }
    127      }),
    128    );
    129  }
    130 
    131  get #connection(): Connection {
    132    return this.#frame.page().browser().connection;
    133  }
    134 
    135  #handleMessage = async (params: Bidi.Script.MessageParameters) => {
    136    if (params.channel !== this.#channel) {
    137      return;
    138    }
    139    const realm = this.#getRealm(params.source);
    140    if (!realm) {
    141      // Unrelated message.
    142      return;
    143    }
    144 
    145    using dataHandle = BidiJSHandle.from<
    146      [
    147        resolve: (ret: FlattenHandle<Awaited<Ret>>) => void,
    148        reject: (error: unknown) => void,
    149        args: Args,
    150      ]
    151    >(params.data, realm);
    152 
    153    using stack = new DisposableStack();
    154    const args = [];
    155 
    156    let result;
    157    try {
    158      using argsHandle = await dataHandle.evaluateHandle(([, , args]) => {
    159        return args;
    160      });
    161 
    162      for (const [index, handle] of await argsHandle.getProperties()) {
    163        stack.use(handle);
    164 
    165        // Element handles are passed as is.
    166        if (handle instanceof BidiElementHandle) {
    167          args[+index] = handle;
    168          stack.use(handle);
    169          continue;
    170        }
    171 
    172        // Everything else is passed as the JS value.
    173        args[+index] = handle.jsonValue();
    174      }
    175      result = await this.#apply(...((await Promise.all(args)) as Args));
    176    } catch (error) {
    177      try {
    178        if (error instanceof Error) {
    179          await dataHandle.evaluate(
    180            ([, reject], name, message, stack) => {
    181              const error = new Error(message);
    182              error.name = name;
    183              if (stack) {
    184                error.stack = stack;
    185              }
    186              reject(error);
    187            },
    188            error.name,
    189            error.message,
    190            error.stack,
    191          );
    192        } else {
    193          await dataHandle.evaluate(([, reject], error) => {
    194            reject(error);
    195          }, error);
    196        }
    197      } catch (error) {
    198        debugError(error);
    199      }
    200      return;
    201    }
    202 
    203    try {
    204      await dataHandle.evaluate(([resolve], result) => {
    205        resolve(result);
    206      }, result);
    207    } catch (error) {
    208      debugError(error);
    209    }
    210  };
    211 
    212  #getRealm(source: Bidi.Script.Source) {
    213    const frame = this.#findFrame(source.context as string);
    214    if (!frame) {
    215      // Unrelated message.
    216      return;
    217    }
    218    return frame.realm(source.realm);
    219  }
    220 
    221  #findFrame(id: string) {
    222    const frames = [this.#frame];
    223    for (const frame of frames) {
    224      if (frame._id === id) {
    225        return frame;
    226      }
    227      frames.push(...frame.childFrames());
    228    }
    229    return;
    230  }
    231 
    232  [Symbol.dispose](): void {
    233    void this[Symbol.asyncDispose]().catch(debugError);
    234  }
    235 
    236  async [Symbol.asyncDispose](): Promise<void> {
    237    this.#disposables.dispose();
    238    await Promise.all(
    239      this.#scripts.map(async ([frame, script]) => {
    240        const realm = this.#isolate ? frame.isolatedRealm() : frame.mainRealm();
    241        try {
    242          await Promise.all([
    243            realm.evaluate(name => {
    244              delete (globalThis as any)[name];
    245            }, this.name),
    246            ...frame.childFrames().map(childFrame => {
    247              return childFrame.evaluate(name => {
    248                delete (globalThis as any)[name];
    249              }, this.name);
    250            }),
    251            frame.browsingContext.removePreloadScript(script),
    252          ]);
    253        } catch (error) {
    254          debugError(error);
    255        }
    256      }),
    257    );
    258  }
    259 }