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 }