util.ts (11162B)
1 /** 2 * @license 3 * Copyright 2017 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import type {OperatorFunction} from '../../third_party/rxjs/rxjs.js'; 8 import { 9 filter, 10 from, 11 fromEvent, 12 map, 13 mergeMap, 14 NEVER, 15 Observable, 16 timer, 17 } from '../../third_party/rxjs/rxjs.js'; 18 import type {CDPSession} from '../api/CDPSession.js'; 19 import {environment} from '../environment.js'; 20 import {packageVersion} from '../generated/version.js'; 21 import {assert} from '../util/assert.js'; 22 import {mergeUint8Arrays, stringToTypedArray} from '../util/encoding.js'; 23 24 import {debug} from './Debug.js'; 25 import {TimeoutError} from './Errors.js'; 26 import type {EventEmitter, EventType} from './EventEmitter.js'; 27 import type { 28 LowerCasePaperFormat, 29 ParsedPDFOptions, 30 PDFOptions, 31 } from './PDFOptions.js'; 32 import {paperFormats} from './PDFOptions.js'; 33 34 /** 35 * @internal 36 */ 37 export const debugError = debug('puppeteer:error'); 38 39 /** 40 * @internal 41 */ 42 export const DEFAULT_VIEWPORT = Object.freeze({width: 800, height: 600}); 43 44 /** 45 * @internal 46 */ 47 const SOURCE_URL = Symbol('Source URL for Puppeteer evaluation scripts'); 48 49 /** 50 * @internal 51 */ 52 export class PuppeteerURL { 53 static INTERNAL_URL = 'pptr:internal'; 54 55 static fromCallSite( 56 functionName: string, 57 site: NodeJS.CallSite, 58 ): PuppeteerURL { 59 const url = new PuppeteerURL(); 60 url.#functionName = functionName; 61 url.#siteString = site.toString(); 62 return url; 63 } 64 65 static parse = (url: string): PuppeteerURL => { 66 url = url.slice('pptr:'.length); 67 const [functionName = '', siteString = ''] = url.split(';'); 68 const puppeteerUrl = new PuppeteerURL(); 69 puppeteerUrl.#functionName = functionName; 70 puppeteerUrl.#siteString = decodeURIComponent(siteString); 71 return puppeteerUrl; 72 }; 73 74 static isPuppeteerURL = (url: string): boolean => { 75 return url.startsWith('pptr:'); 76 }; 77 78 #functionName!: string; 79 #siteString!: string; 80 81 get functionName(): string { 82 return this.#functionName; 83 } 84 85 get siteString(): string { 86 return this.#siteString; 87 } 88 89 toString(): string { 90 return `pptr:${[ 91 this.#functionName, 92 encodeURIComponent(this.#siteString), 93 ].join(';')}`; 94 } 95 } 96 97 /** 98 * @internal 99 */ 100 export const withSourcePuppeteerURLIfNone = <T extends NonNullable<unknown>>( 101 functionName: string, 102 object: T, 103 ): T => { 104 if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) { 105 return object; 106 } 107 const original = Error.prepareStackTrace; 108 Error.prepareStackTrace = (_, stack) => { 109 // First element is the function. 110 // Second element is the caller of this function. 111 // Third element is the caller of the caller of this function 112 // which is precisely what we want. 113 return stack[2]; 114 }; 115 const site = new Error().stack as unknown as NodeJS.CallSite; 116 Error.prepareStackTrace = original; 117 return Object.assign(object, { 118 [SOURCE_URL]: PuppeteerURL.fromCallSite(functionName, site), 119 }); 120 }; 121 122 /** 123 * @internal 124 */ 125 export const getSourcePuppeteerURLIfAvailable = < 126 T extends NonNullable<unknown>, 127 >( 128 object: T, 129 ): PuppeteerURL | undefined => { 130 if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) { 131 return object[SOURCE_URL as keyof T] as PuppeteerURL; 132 } 133 return undefined; 134 }; 135 136 /** 137 * @internal 138 */ 139 export const isString = (obj: unknown): obj is string => { 140 return typeof obj === 'string' || obj instanceof String; 141 }; 142 143 /** 144 * @internal 145 */ 146 export const isNumber = (obj: unknown): obj is number => { 147 return typeof obj === 'number' || obj instanceof Number; 148 }; 149 150 /** 151 * @internal 152 */ 153 export const isPlainObject = (obj: unknown): obj is Record<any, unknown> => { 154 return typeof obj === 'object' && obj?.constructor === Object; 155 }; 156 157 /** 158 * @internal 159 */ 160 export const isRegExp = (obj: unknown): obj is RegExp => { 161 return typeof obj === 'object' && obj?.constructor === RegExp; 162 }; 163 164 /** 165 * @internal 166 */ 167 export const isDate = (obj: unknown): obj is Date => { 168 return typeof obj === 'object' && obj?.constructor === Date; 169 }; 170 171 /** 172 * @internal 173 */ 174 export function evaluationString( 175 // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 176 fun: Function | string, 177 ...args: unknown[] 178 ): string { 179 if (isString(fun)) { 180 assert(args.length === 0, 'Cannot evaluate a string with arguments'); 181 return fun; 182 } 183 184 function serializeArgument(arg: unknown): string { 185 if (Object.is(arg, undefined)) { 186 return 'undefined'; 187 } 188 return JSON.stringify(arg); 189 } 190 191 return `(${fun})(${args.map(serializeArgument).join(',')})`; 192 } 193 194 /** 195 * @internal 196 */ 197 export async function getReadableAsTypedArray( 198 readable: ReadableStream<Uint8Array>, 199 path?: string, 200 ): Promise<Uint8Array | null> { 201 const buffers: Uint8Array[] = []; 202 const reader = readable.getReader(); 203 if (path) { 204 const fileHandle = await environment.value.fs.promises.open(path, 'w+'); 205 try { 206 while (true) { 207 const {done, value} = await reader.read(); 208 if (done) { 209 break; 210 } 211 buffers.push(value); 212 await fileHandle.writeFile(value); 213 } 214 } finally { 215 await fileHandle.close(); 216 } 217 } else { 218 while (true) { 219 const {done, value} = await reader.read(); 220 if (done) { 221 break; 222 } 223 buffers.push(value); 224 } 225 } 226 try { 227 const concat = mergeUint8Arrays(buffers); 228 if (concat.length === 0) { 229 return null; 230 } 231 return concat; 232 } catch (error) { 233 debugError(error); 234 return null; 235 } 236 } 237 238 /** 239 * @internal 240 */ 241 242 /** 243 * @internal 244 */ 245 export async function getReadableFromProtocolStream( 246 client: CDPSession, 247 handle: string, 248 ): Promise<ReadableStream<Uint8Array>> { 249 return new ReadableStream({ 250 async pull(controller) { 251 const {data, base64Encoded, eof} = await client.send('IO.read', { 252 handle, 253 }); 254 255 controller.enqueue(stringToTypedArray(data, base64Encoded ?? false)); 256 if (eof) { 257 await client.send('IO.close', {handle}); 258 controller.close(); 259 } 260 }, 261 }); 262 } 263 264 /** 265 * @internal 266 */ 267 export function validateDialogType( 268 type: string, 269 ): 'alert' | 'confirm' | 'prompt' | 'beforeunload' { 270 let dialogType = null; 271 const validDialogTypes = new Set([ 272 'alert', 273 'confirm', 274 'prompt', 275 'beforeunload', 276 ]); 277 278 if (validDialogTypes.has(type)) { 279 dialogType = type; 280 } 281 assert(dialogType, `Unknown javascript dialog type: ${type}`); 282 return dialogType as 'alert' | 'confirm' | 'prompt' | 'beforeunload'; 283 } 284 285 /** 286 * @internal 287 */ 288 export function timeout(ms: number, cause?: Error): Observable<never> { 289 return ms === 0 290 ? NEVER 291 : timer(ms).pipe( 292 map(() => { 293 throw new TimeoutError(`Timed out after waiting ${ms}ms`, {cause}); 294 }), 295 ); 296 } 297 298 /** 299 * @internal 300 */ 301 export const UTILITY_WORLD_NAME = 302 '__puppeteer_utility_world__' + packageVersion; 303 304 /** 305 * @internal 306 */ 307 export const SOURCE_URL_REGEX = 308 /^[\x20\t]*\/\/[@#] sourceURL=\s{0,10}(\S*?)\s{0,10}$/m; 309 /** 310 * @internal 311 */ 312 export function getSourceUrlComment(url: string): string { 313 return `//# sourceURL=${url}`; 314 } 315 316 /** 317 * @internal 318 */ 319 export const NETWORK_IDLE_TIME = 500; 320 321 /** 322 * @internal 323 */ 324 export function parsePDFOptions( 325 options: PDFOptions = {}, 326 lengthUnit: 'in' | 'cm' = 'in', 327 ): ParsedPDFOptions { 328 const defaults: Omit<ParsedPDFOptions, 'width' | 'height' | 'margin'> = { 329 scale: 1, 330 displayHeaderFooter: false, 331 headerTemplate: '', 332 footerTemplate: '', 333 printBackground: false, 334 landscape: false, 335 pageRanges: '', 336 preferCSSPageSize: false, 337 omitBackground: false, 338 outline: false, 339 tagged: true, 340 waitForFonts: true, 341 }; 342 343 let width = 8.5; 344 let height = 11; 345 if (options.format) { 346 const format = 347 paperFormats[options.format.toLowerCase() as LowerCasePaperFormat][ 348 lengthUnit 349 ]; 350 assert(format, 'Unknown paper format: ' + options.format); 351 width = format.width; 352 height = format.height; 353 } else { 354 width = convertPrintParameterToInches(options.width, lengthUnit) ?? width; 355 height = 356 convertPrintParameterToInches(options.height, lengthUnit) ?? height; 357 } 358 359 const margin = { 360 top: convertPrintParameterToInches(options.margin?.top, lengthUnit) || 0, 361 left: convertPrintParameterToInches(options.margin?.left, lengthUnit) || 0, 362 bottom: 363 convertPrintParameterToInches(options.margin?.bottom, lengthUnit) || 0, 364 right: 365 convertPrintParameterToInches(options.margin?.right, lengthUnit) || 0, 366 }; 367 368 // Quirk https://bugs.chromium.org/p/chromium/issues/detail?id=840455#c44 369 if (options.outline) { 370 options.tagged = true; 371 } 372 373 return { 374 ...defaults, 375 ...options, 376 width, 377 height, 378 margin, 379 }; 380 } 381 382 /** 383 * @internal 384 */ 385 export const unitToPixels = { 386 px: 1, 387 in: 96, 388 cm: 37.8, 389 mm: 3.78, 390 }; 391 392 function convertPrintParameterToInches( 393 parameter?: string | number, 394 lengthUnit: 'in' | 'cm' = 'in', 395 ): number | undefined { 396 if (typeof parameter === 'undefined') { 397 return undefined; 398 } 399 let pixels; 400 if (isNumber(parameter)) { 401 // Treat numbers as pixel values to be aligned with phantom's paperSize. 402 pixels = parameter; 403 } else if (isString(parameter)) { 404 const text = parameter; 405 let unit = text.substring(text.length - 2).toLowerCase(); 406 let valueText = ''; 407 if (unit in unitToPixels) { 408 valueText = text.substring(0, text.length - 2); 409 } else { 410 // In case of unknown unit try to parse the whole parameter as number of pixels. 411 // This is consistent with phantom's paperSize behavior. 412 unit = 'px'; 413 valueText = text; 414 } 415 const value = Number(valueText); 416 assert(!isNaN(value), 'Failed to parse parameter value: ' + text); 417 pixels = value * unitToPixels[unit as keyof typeof unitToPixels]; 418 } else { 419 throw new Error( 420 'page.pdf() Cannot handle parameter type: ' + typeof parameter, 421 ); 422 } 423 return pixels / unitToPixels[lengthUnit]; 424 } 425 426 /** 427 * @internal 428 */ 429 export function fromEmitterEvent< 430 Events extends Record<EventType, unknown>, 431 Event extends keyof Events, 432 >(emitter: EventEmitter<Events>, eventName: Event): Observable<Events[Event]> { 433 return new Observable(subscriber => { 434 const listener = (event: Events[Event]) => { 435 subscriber.next(event); 436 }; 437 emitter.on(eventName, listener); 438 return () => { 439 emitter.off(eventName, listener); 440 }; 441 }); 442 } 443 444 /** 445 * @internal 446 */ 447 export function fromAbortSignal( 448 signal?: AbortSignal, 449 cause?: Error, 450 ): Observable<never> { 451 return signal 452 ? fromEvent(signal, 'abort').pipe( 453 map(() => { 454 if (signal.reason instanceof Error) { 455 signal.reason.cause = cause; 456 throw signal.reason; 457 } 458 459 throw new Error(signal.reason, {cause}); 460 }), 461 ) 462 : NEVER; 463 } 464 465 /** 466 * @internal 467 */ 468 export function filterAsync<T>( 469 predicate: (value: T) => boolean | PromiseLike<boolean>, 470 ): OperatorFunction<T, T> { 471 return mergeMap<T, Observable<T>>((value): Observable<T> => { 472 return from(Promise.resolve(predicate(value))).pipe( 473 filter(isMatch => { 474 return isMatch; 475 }), 476 map(() => { 477 return value; 478 }), 479 ); 480 }); 481 }