diff --git a/src/helper.js b/src/helper.js deleted file mode 100644 index 10a2eacd0b45f..0000000000000 --- a/src/helper.js +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Copyright 2017 Google Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -const {TimeoutError} = require('./Errors'); -const debugError = require('debug')(`puppeteer:error`); -const fs = require('fs'); - -class Helper { - /** - * @param {Function|string} fun - * @param {!Array<*>} args - * @return {string} - */ - static evaluationString(fun, ...args) { - if (Helper.isString(fun)) { - assert(args.length === 0, 'Cannot evaluate a string with arguments'); - return /** @type {string} */ (fun); - } - return `(${fun})(${args.map(serializeArgument).join(',')})`; - - /** - * @param {*} arg - * @return {string} - */ - function serializeArgument(arg) { - if (Object.is(arg, undefined)) - return 'undefined'; - return JSON.stringify(arg); - } - } - - /** - * @param {!Protocol.Runtime.ExceptionDetails} exceptionDetails - * @return {string} - */ - static getExceptionMessage(exceptionDetails) { - if (exceptionDetails.exception) - return exceptionDetails.exception.description || exceptionDetails.exception.value; - let message = exceptionDetails.text; - if (exceptionDetails.stackTrace) { - for (const callframe of exceptionDetails.stackTrace.callFrames) { - const location = callframe.url + ':' + callframe.lineNumber + ':' + callframe.columnNumber; - const functionName = callframe.functionName || ''; - message += `\n at ${functionName} (${location})`; - } - } - return message; - } - - /** - * @param {!Protocol.Runtime.RemoteObject} remoteObject - * @return {*} - */ - static valueFromRemoteObject(remoteObject) { - assert(!remoteObject.objectId, 'Cannot extract value when objectId is given'); - if (remoteObject.unserializableValue) { - if (remoteObject.type === 'bigint' && typeof BigInt !== 'undefined') - return BigInt(remoteObject.unserializableValue.replace('n', '')); - switch (remoteObject.unserializableValue) { - case '-0': - return -0; - case 'NaN': - return NaN; - case 'Infinity': - return Infinity; - case '-Infinity': - return -Infinity; - default: - throw new Error('Unsupported unserializable value: ' + remoteObject.unserializableValue); - } - } - return remoteObject.value; - } - - /** - * @param {!Puppeteer.CDPSession} client - * @param {!Protocol.Runtime.RemoteObject} remoteObject - */ - static async releaseObject(client, remoteObject) { - if (!remoteObject.objectId) - return; - await client.send('Runtime.releaseObject', {objectId: remoteObject.objectId}).catch(error => { - // Exceptions might happen in case of a page been navigated or closed. - // Swallow these since they are harmless and we don't leak anything in this case. - debugError(error); - }); - } - - /** - * @param {!Object} classType - */ - static installAsyncStackHooks(classType) { - for (const methodName of Reflect.ownKeys(classType.prototype)) { - const method = Reflect.get(classType.prototype, methodName); - if (methodName === 'constructor' || typeof methodName !== 'string' || methodName.startsWith('_') || typeof method !== 'function' || method.constructor.name !== 'AsyncFunction') - continue; - Reflect.set(classType.prototype, methodName, function(...args) { - const syncStack = {}; - Error.captureStackTrace(syncStack); - return method.call(this, ...args).catch(e => { - const stack = syncStack.stack.substring(syncStack.stack.indexOf('\n') + 1); - const clientStack = stack.substring(stack.indexOf('\n')); - if (e instanceof Error && e.stack && !e.stack.includes(clientStack)) - e.stack += '\n -- ASYNC --\n' + stack; - throw e; - }); - }); - } - } - - /** - * @param {!NodeJS.EventEmitter} emitter - * @param {(string|symbol)} eventName - * @param {function(?):void} handler - * @return {{emitter: !NodeJS.EventEmitter, eventName: (string|symbol), handler: function(?)}} - */ - static addEventListener(emitter, eventName, handler) { - emitter.on(eventName, handler); - return { emitter, eventName, handler }; - } - - /** - * @param {!Array<{emitter: !NodeJS.EventEmitter, eventName: (string|symbol), handler: function(?):void}>} listeners - */ - static removeEventListeners(listeners) { - for (const listener of listeners) - listener.emitter.removeListener(listener.eventName, listener.handler); - listeners.length = 0; - } - - /** - * @param {!Object} obj - * @return {boolean} - */ - static isString(obj) { - return typeof obj === 'string' || obj instanceof String; - } - - /** - * @param {!Object} obj - * @return {boolean} - */ - static isNumber(obj) { - return typeof obj === 'number' || obj instanceof Number; - } - - /** - * @param {function} nodeFunction - * @return {function} - */ - static promisify(nodeFunction) { - function promisified(...args) { - return new Promise((resolve, reject) => { - function callback(err, ...result) { - if (err) - return reject(err); - if (result.length === 1) - return resolve(result[0]); - return resolve(result); - } - nodeFunction.call(null, ...args, callback); - }); - } - return promisified; - } - - /** - * @param {!NodeJS.EventEmitter} emitter - * @param {(string|symbol)} eventName - * @param {function} predicate - * @param {number} timeout - * @param {!Promise} abortPromise - * @return {!Promise} - */ - static async waitForEvent(emitter, eventName, predicate, timeout, abortPromise) { - let eventTimeout, resolveCallback, rejectCallback; - const promise = new Promise((resolve, reject) => { - resolveCallback = resolve; - rejectCallback = reject; - }); - const listener = Helper.addEventListener(emitter, eventName, event => { - if (!predicate(event)) - return; - resolveCallback(event); - }); - if (timeout) { - eventTimeout = setTimeout(() => { - rejectCallback(new TimeoutError('Timeout exceeded while waiting for event')); - }, timeout); - } - function cleanup() { - Helper.removeEventListeners([listener]); - clearTimeout(eventTimeout); - } - const result = await Promise.race([promise, abortPromise]).then(r => { - cleanup(); - return r; - }, e => { - cleanup(); - throw e; - }); - if (result instanceof Error) - throw result; - return result; - } - - /** - * @template T - * @param {!Promise} promise - * @param {string} taskName - * @param {number} timeout - * @return {!Promise} - */ - static async waitWithTimeout(promise, taskName, timeout) { - let reject; - const timeoutError = new TimeoutError(`waiting for ${taskName} failed: timeout ${timeout}ms exceeded`); - const timeoutPromise = new Promise((resolve, x) => reject = x); - let timeoutTimer = null; - if (timeout) - timeoutTimer = setTimeout(() => reject(timeoutError), timeout); - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - if (timeoutTimer) - clearTimeout(timeoutTimer); - } - } - - /** - * @param {!Puppeteer.CDPSession} client - * @param {string} handle - * @param {?string} path - * @return {!Promise} - */ - static async readProtocolStream(client, handle, path) { - let eof = false; - let file; - if (path) - file = await openAsync(path, 'w'); - const bufs = []; - while (!eof) { - const response = await client.send('IO.read', {handle}); - eof = response.eof; - const buf = Buffer.from(response.data, response.base64Encoded ? 'base64' : undefined); - bufs.push(buf); - if (path) - await writeAsync(file, buf); - } - if (path) - await closeAsync(file); - await client.send('IO.close', {handle}); - let resultBuffer = null; - try { - resultBuffer = Buffer.concat(bufs); - } finally { - return resultBuffer; - } - } -} - -const openAsync = Helper.promisify(fs.open); -const writeAsync = Helper.promisify(fs.write); -const closeAsync = Helper.promisify(fs.close); - -/** - * @param {*} value - * @param {string=} message - */ -function assert(value, message) { - if (!value) - throw new Error(message); -} - -module.exports = { - helper: Helper, - assert, - debugError -}; diff --git a/src/helper.ts b/src/helper.ts new file mode 100644 index 0000000000000..f81db28e1d29e --- /dev/null +++ b/src/helper.ts @@ -0,0 +1,232 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Errors = require('./Errors'); + +import debug = require('debug'); +const debugError = debug('puppeteer:error'); +import fs = require('fs'); +import promisify = require('./promisify'); + +const {TimeoutError} = Errors; + +const openAsync = promisify(fs.open); +const writeAsync = promisify(fs.write); +const closeAsync = promisify(fs.close); + +function assert(value: unknown, message?: string): void { + if (!value) + throw new Error(message); +} + +interface AnyClass { + prototype: object; +} + + +function getExceptionMessage(exceptionDetails: Protocol.Runtime.ExceptionDetails): string { + if (exceptionDetails.exception) + return exceptionDetails.exception.description || exceptionDetails.exception.value; + let message = exceptionDetails.text; + if (exceptionDetails.stackTrace) { + for (const callframe of exceptionDetails.stackTrace.callFrames) { + const location = callframe.url + ':' + callframe.lineNumber + ':' + callframe.columnNumber; + const functionName = callframe.functionName || ''; + message += `\n at ${functionName} (${location})`; + } + } + return message; +} + +function valueFromRemoteObject(remoteObject: Protocol.Runtime.RemoteObject): unknown { + assert(!remoteObject.objectId, 'Cannot extract value when objectId is given'); + if (remoteObject.unserializableValue) { + if (remoteObject.type === 'bigint' && typeof BigInt !== 'undefined') + return BigInt(remoteObject.unserializableValue.replace('n', '')); + switch (remoteObject.unserializableValue) { + case '-0': + return -0; + case 'NaN': + return NaN; + case 'Infinity': + return Infinity; + case '-Infinity': + return -Infinity; + default: + throw new Error('Unsupported unserializable value: ' + remoteObject.unserializableValue); + } + } + return remoteObject.value; +} + +async function releaseObject(client: Puppeteer.CDPSession, remoteObject: Protocol.Runtime.RemoteObject): Promise { + if (!remoteObject.objectId) + return; + await client.send('Runtime.releaseObject', {objectId: remoteObject.objectId}).catch(error => { + // Exceptions might happen in case of a page been navigated or closed. + // Swallow these since they are harmless and we don't leak anything in this case. + debugError(error); + }); +} + +function installAsyncStackHooks(classType: AnyClass): void { + for (const methodName of Reflect.ownKeys(classType.prototype)) { + const method = Reflect.get(classType.prototype, methodName); + if (methodName === 'constructor' || typeof methodName !== 'string' || methodName.startsWith('_') || typeof method !== 'function' || method.constructor.name !== 'AsyncFunction') + continue; + Reflect.set(classType.prototype, methodName, function(...args) { + const syncStack = { + stack: '' + }; + Error.captureStackTrace(syncStack); + return method.call(this, ...args).catch(e => { + const stack = syncStack.stack.substring(syncStack.stack.indexOf('\n') + 1); + const clientStack = stack.substring(stack.indexOf('\n')); + if (e instanceof Error && e.stack && !e.stack.includes(clientStack)) + e.stack += '\n -- ASYNC --\n' + stack; + throw e; + }); + }); + } +} + + +function addEventListener(emitter: NodeJS.EventEmitter, eventName: string|symbol, handler: (...args: any[]) => void): { emitter: NodeJS.EventEmitter; eventName: string|symbol; handler: (...args: any[]) => void} { + emitter.on(eventName, handler); + return { emitter, eventName, handler }; +} + +function removeEventListeners(listeners: Array<{emitter: NodeJS.EventEmitter; eventName: string|symbol; handler: (...args: any[]) => void}>): void { + for (const listener of listeners) + listener.emitter.removeListener(listener.eventName, listener.handler); + listeners.length = 0; +} + +function isString(obj: unknown): obj is string { + return typeof obj === 'string' || obj instanceof String; +} + +function isNumber(obj: unknown): obj is number { + return typeof obj === 'number' || obj instanceof Number; +} + +async function waitForEvent(emitter: NodeJS.EventEmitter, eventName: string|symbol, predicate: (event: T) => boolean, timeout: number, abortPromise: Promise): Promise { + let eventTimeout, resolveCallback, rejectCallback; + const promise = new Promise((resolve, reject) => { + resolveCallback = resolve; + rejectCallback = reject; + }); + const listener = addEventListener(emitter, eventName, event => { + if (!predicate(event)) + return; + resolveCallback(event); + }); + if (timeout) { + eventTimeout = setTimeout(() => { + rejectCallback(new TimeoutError('Timeout exceeded while waiting for event')); + }, timeout); + } + function cleanup(): void { + removeEventListeners([listener]); + clearTimeout(eventTimeout); + } + const result = await Promise.race([promise, abortPromise]).then(r => { + cleanup(); + return r; + }, e => { + cleanup(); + throw e; + }); + if (result instanceof Error) + throw result; + + return result; +} + +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(',')})`; +} + +async function waitWithTimeout(promise: Promise, taskName: string, timeout: number): Promise { + let reject; + const timeoutError = new TimeoutError(`waiting for ${taskName} failed: timeout ${timeout}ms exceeded`); + const timeoutPromise = new Promise((resolve, x) => reject = x); + let timeoutTimer = null; + if (timeout) + timeoutTimer = setTimeout(() => reject(timeoutError), timeout); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutTimer) + clearTimeout(timeoutTimer); + } +} + +async function readProtocolStream(client: Puppeteer.CDPSession, handle: string, path?: string): Promise { + let eof = false; + let file; + if (path) + file = await openAsync(path, 'w'); + const bufs = []; + while (!eof) { + const response = await client.send('IO.read', {handle}); + eof = response.eof; + const buf = Buffer.from(response.data, response.base64Encoded ? 'base64' : undefined); + bufs.push(buf); + if (path) + await writeAsync(file, buf); + } + if (path) + await closeAsync(file); + await client.send('IO.close', {handle}); + let resultBuffer = null; + try { + resultBuffer = Buffer.concat(bufs); + } finally { + return resultBuffer; + } +} + + +export = { + helper: { + promisify, + evaluationString, + readProtocolStream, + waitWithTimeout, + waitForEvent, + isString, + isNumber, + addEventListener, + removeEventListeners, + valueFromRemoteObject, + installAsyncStackHooks, + getExceptionMessage, + releaseObject, + }, + assert, + debugError +}; diff --git a/src/promisify.ts b/src/promisify.ts new file mode 100644 index 0000000000000..e6dc3db76109e --- /dev/null +++ b/src/promisify.ts @@ -0,0 +1,51 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +/* This is a poor typed implementation of promisify. It would be much + * nicer to use util.promisfy from Node but we need it to work in the + * browser as we bundle via Browserify and Browserify doesn't seem to + * bundle the util module when it does. The long term goal for our web + * bundle isn't to use Browserify but something more modern (probably + * Rollup?) and so rather than delay the TypeScript migration with a big + * tangent around web bundling we'll use this for now and loop back once + * src/ is 100% TypeScript. + * + * TODO (jacktfranklin@) swap this for util.promisify so we get much + * better type support from TypeScript + */ + +type CallbackFunc = (...args: any[]) => void; +type PromisifiedFunc = (...args: any[]) => Promise; + +function promisify(func: CallbackFunc): PromisifiedFunc { + function promisified(...args): Promise { + return new Promise((resolve, reject) => { + function callback(err: Error | null, ...result: ResultType[]): void { + if (err) + return reject(err); + if (result.length === 1) + return resolve(result[0]); + return resolve(result); + } + func.call(null, ...args, callback); + }); + } + + return promisified; +} + +export = promisify;