/** * @license * Copyright 2017 Google Inc. * SPDX-License-Identifier: Apache-2.0 */ import type {OperatorFunction} from '../../third_party/rxjs/rxjs.js'; import { filter, from, fromEvent, map, mergeMap, NEVER, Observable, timer, } from '../../third_party/rxjs/rxjs.js'; import type {CDPSession} from '../api/CDPSession.js'; import {environment} from '../environment.js'; import {packageVersion} from '../generated/version.js'; import {assert} from '../util/assert.js'; import {mergeUint8Arrays} from '../util/encoding.js'; import {debug} from './Debug.js'; import {TimeoutError} from './Errors.js'; import type {EventEmitter, EventType} from './EventEmitter.js'; import type { LowerCasePaperFormat, ParsedPDFOptions, PDFOptions, } from './PDFOptions.js'; import {paperFormats} from './PDFOptions.js'; /** * @internal */ export const debugError = debug('puppeteer:error'); /** * @internal */ export const DEFAULT_VIEWPORT = Object.freeze({width: 800, height: 600}); /** * @internal */ const SOURCE_URL = Symbol('Source URL for Puppeteer evaluation scripts'); /** * @internal */ export class PuppeteerURL { static INTERNAL_URL = 'pptr:internal'; static fromCallSite( functionName: string, site: NodeJS.CallSite ): PuppeteerURL { const url = new PuppeteerURL(); url.#functionName = functionName; url.#siteString = site.toString(); return url; } static parse = (url: string): PuppeteerURL => { url = url.slice('pptr:'.length); const [functionName = '', siteString = ''] = url.split(';'); const puppeteerUrl = new PuppeteerURL(); puppeteerUrl.#functionName = functionName; puppeteerUrl.#siteString = decodeURIComponent(siteString); return puppeteerUrl; }; static isPuppeteerURL = (url: string): boolean => { return url.startsWith('pptr:'); }; #functionName!: string; #siteString!: string; get functionName(): string { return this.#functionName; } get siteString(): string { return this.#siteString; } toString(): string { return `pptr:${[ this.#functionName, encodeURIComponent(this.#siteString), ].join(';')}`; } } /** * @internal */ export const withSourcePuppeteerURLIfNone = <T extends NonNullable<unknown>>( functionName: string, object: T ): T => { if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) { return object; } const original = Error.prepareStackTrace; Error.prepareStackTrace = (_, stack) => { // First element is the function. // Second element is the caller of this function. // Third element is the caller of the caller of this function // which is precisely what we want. return stack[2]; }; const site = new Error().stack as unknown as NodeJS.CallSite; Error.prepareStackTrace = original; return Object.assign(object, { [SOURCE_URL]: PuppeteerURL.fromCallSite(functionName, site), }); }; /** * @internal */ export const getSourcePuppeteerURLIfAvailable = < T extends NonNullable<unknown>, >( object: T ): PuppeteerURL | undefined => { if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) { return object[SOURCE_URL as keyof T] as PuppeteerURL; } return undefined; }; /** * @internal */ export const isString = (obj: unknown): obj is string => { return typeof obj === 'string' || obj instanceof String; }; /** * @internal */ export const isNumber = (obj: unknown): obj is number => { return typeof obj === 'number' || obj instanceof Number; }; /** * @internal */ export const isPlainObject = (obj: unknown): obj is Record<any, unknown> => { return typeof obj === 'object' && obj?.constructor === Object; }; /** * @internal */ export const isRegExp = (obj: unknown): obj is RegExp => { return typeof obj === 'object' && obj?.constructor === RegExp; }; /** * @internal */ export const isDate = (obj: unknown): obj is Date => { return typeof obj === 'object' && obj?.constructor === Date; }; /** * @internal */ export function evaluationString( fun: Function | string, ...args: unknown[] ): string { if (isString(fun)) { assert(args.length === 0, 'Cannot evaluate a string with arguments'); return fun; } function serializeArgument(arg: unknown): string { if (Object.is(arg, undefined)) { return 'undefined'; } return JSON.stringify(arg); } return `(${fun})(${args.map(serializeArgument).join(',')})`; } /** * @internal */ export async function getReadableAsTypedArray( readable: ReadableStream<Uint8Array>, path?: string ): Promise<Uint8Array | null> { const buffers: Uint8Array[] = []; const reader = readable.getReader(); if (path) { const fileHandle = await environment.value.fs.promises.open(path, 'w+'); try { while (true) { const {done, value} = await reader.read(); if (done) { break; } buffers.push(value); await fileHandle.writeFile(value); } } finally { await fileHandle.close(); } } else { while (true) { const {done, value} = await reader.read(); if (done) { break; } buffers.push(value); } } try { const concat = mergeUint8Arrays(buffers); if (concat.length === 0) { return null; } return concat; } catch (error) { debugError(error); return null; } } /** * @internal */ /** * @internal */ export async function getReadableFromProtocolStream( client: CDPSession, handle: string ): Promise<ReadableStream<Uint8Array>> { return new ReadableStream({ async pull(controller) { function getUnit8Array(data: string, isBase64: boolean): Uint8Array { if (isBase64) { return Uint8Array.from(atob(data), m => { return m.codePointAt(0)!; }); } const encoder = new TextEncoder(); return encoder.encode(data); } const {data, base64Encoded, eof} = await client.send('IO.read', { handle, }); controller.enqueue(getUnit8Array(data, base64Encoded ?? false)); if (eof) { await client.send('IO.close', {handle}); controller.close(); } }, }); } /** * @internal */ export function validateDialogType( type: string ): 'alert' | 'confirm' | 'prompt' | 'beforeunload' { let dialogType = null; const validDialogTypes = new Set([ 'alert', 'confirm', 'prompt', 'beforeunload', ]); if (validDialogTypes.has(type)) { dialogType = type; } assert(dialogType, `Unknown javascript dialog type: ${type}`); return dialogType as 'alert' | 'confirm' | 'prompt' | 'beforeunload'; } /** * @internal */ export function timeout(ms: number, cause?: Error): Observable<never> { return ms === 0 ? NEVER : timer(ms).pipe( map(() => { throw new TimeoutError(`Timed out after waiting ${ms}ms`, {cause}); }) ); } /** * @internal */ export const UTILITY_WORLD_NAME = '__puppeteer_utility_world__' + packageVersion; /** * @internal */ export const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m; /** * @internal */ export function getSourceUrlComment(url: string): string { return `//# sourceURL=${url}`; } /** * @internal */ export const NETWORK_IDLE_TIME = 500; /** * @internal */ export function parsePDFOptions( options: PDFOptions = {}, lengthUnit: 'in' | 'cm' = 'in' ): ParsedPDFOptions { const defaults: Omit<ParsedPDFOptions, 'width' | 'height' | 'margin'> = { scale: 1, displayHeaderFooter: false, headerTemplate: '', footerTemplate: '', printBackground: false, landscape: false, pageRanges: '', preferCSSPageSize: false, omitBackground: false, outline: false, tagged: true, waitForFonts: true, }; let width = 8.5; let height = 11; if (options.format) { const format = paperFormats[options.format.toLowerCase() as LowerCasePaperFormat]; assert(format, 'Unknown paper format: ' + options.format); width = format.width; height = format.height; } else { width = convertPrintParameterToInches(options.width, lengthUnit) ?? width; height = convertPrintParameterToInches(options.height, lengthUnit) ?? height; } const margin = { top: convertPrintParameterToInches(options.margin?.top, lengthUnit) || 0, left: convertPrintParameterToInches(options.margin?.left, lengthUnit) || 0, bottom: convertPrintParameterToInches(options.margin?.bottom, lengthUnit) || 0, right: convertPrintParameterToInches(options.margin?.right, lengthUnit) || 0, }; // Quirk https://bugs.chromium.org/p/chromium/issues/detail?id=840455#c44 if (options.outline) { options.tagged = true; } return { ...defaults, ...options, width, height, margin, }; } /** * @internal */ export const unitToPixels = { px: 1, in: 96, cm: 37.8, mm: 3.78, }; function convertPrintParameterToInches( parameter?: string | number, lengthUnit: 'in' | 'cm' = 'in' ): number | undefined { if (typeof parameter === 'undefined') { return undefined; } let pixels; if (isNumber(parameter)) { // Treat numbers as pixel values to be aligned with phantom's paperSize. pixels = parameter; } else if (isString(parameter)) { const text = parameter; let unit = text.substring(text.length - 2).toLowerCase(); let valueText = ''; if (unit in unitToPixels) { valueText = text.substring(0, text.length - 2); } else { // In case of unknown unit try to parse the whole parameter as number of pixels. // This is consistent with phantom's paperSize behavior. unit = 'px'; valueText = text; } const value = Number(valueText); assert(!isNaN(value), 'Failed to parse parameter value: ' + text); pixels = value * unitToPixels[unit as keyof typeof unitToPixels]; } else { throw new Error( 'page.pdf() Cannot handle parameter type: ' + typeof parameter ); } return pixels / unitToPixels[lengthUnit]; } /** * @internal */ export function fromEmitterEvent< Events extends Record<EventType, unknown>, Event extends keyof Events, >(emitter: EventEmitter<Events>, eventName: Event): Observable<Events[Event]> { return new Observable(subscriber => { const listener = (event: Events[Event]) => { subscriber.next(event); }; emitter.on(eventName, listener); return () => { emitter.off(eventName, listener); }; }); } /** * @internal */ export function fromAbortSignal( signal?: AbortSignal, cause?: Error ): Observable<never> { return signal ? fromEvent(signal, 'abort').pipe( map(() => { if (signal.reason instanceof Error) { signal.reason.cause = cause; throw signal.reason; } throw new Error(signal.reason, {cause}); }) ) : NEVER; } /** * @internal */ export function filterAsync<T>( predicate: (value: T) => boolean | PromiseLike<boolean> ): OperatorFunction<T, T> { return mergeMap<T, Observable<T>>((value): Observable<T> => { return from(Promise.resolve(predicate(value))).pipe( filter(isMatch => { return isMatch; }), map(() => { return value; }) ); }); }