You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

361 lines
11 KiB

'use strict';Object.defineProperty(exports, "__esModule", { value: true });
var _phantomjsPrebuilt = require('phantomjs-prebuilt');var _phantomjsPrebuilt2 = _interopRequireDefault(_phantomjsPrebuilt);
var _child_process = require('child_process');
var _os = require('os');var _os2 = _interopRequireDefault(_os);
var _path = require('path');var _path2 = _interopRequireDefault(_path);
var _split = require('split');var _split2 = _interopRequireDefault(_split);
var _winston = require('winston');var _winston2 = _interopRequireDefault(_winston);
var _events = require('events');var _events2 = _interopRequireDefault(_events);
var _page = require('./page');var _page2 = _interopRequireDefault(_page);
var _command = require('./command');var _command2 = _interopRequireDefault(_command);
var _out_object = require('./out_object');var _out_object2 = _interopRequireDefault(_out_object);function _interopRequireDefault(obj) {return obj && obj.__esModule ? obj : { default: obj };}
const defaultLogLevel = process.env.DEBUG === 'true' ? 'debug' : 'info';
const defaultPathToShim = _path2.default.normalize(`${__dirname}/shim/index.js`);
const NOOP = 'NOOP';
/**
* Creates a logger using winston
*/
function createLogger({ logLevel = defaultLogLevel } = {}) {
return new _winston2.default.Logger({
transports: [
new _winston2.default.transports.Console({
level: logLevel,
colorize: true })] });
}
/**
* A phantom instance that communicates with phantomjs
*/
class Phantom {
// eslint-disable-line camelcase
/**
* Creates a new instance of Phantom
*
* @param args command args to pass to phantom process
* @param [phantomPath] path to phantomjs executable
* @param [logger] object containing functions used for logging
* @param [logLevel] log level to apply on the logger (if unset or default)
*/
// eslint-disable-next-line
constructor(
args = [],
{
phantomPath = _phantomjsPrebuilt2.default.path,
shimPath = defaultPathToShim,
logLevel = defaultLogLevel,
logger = createLogger({ logLevel }) } =
{})
{
if (!Array.isArray(args)) {
throw new Error('Unexpected type of parameters. Expecting args to be array.');
}
if (typeof phantomPath !== 'string') {
throw new Error('PhantomJS binary was not found. ' +
'This generally means something went wrong when installing phantomjs-prebuilt. Exiting.');
}
if (typeof shimPath !== 'string') {
throw new Error('Path to shim file was not found. ' +
'Are you sure you entered the path correctly? Exiting.');
}
if (!logger.info && !logger.debug && !logger.error && !logger.warn) {
throw new Error('logger must be a valid object.');
}
// eslint-disable-next-line no-unused-vars
const noop = (...msg) => undefined;
this.logger = {
info: logger.info ? (...msg) => logger.info(...msg) : noop,
debug: logger.debug ? (...msg) => logger.debug(...msg) : noop,
error: logger.error ? (...msg) => logger.error(...msg) : noop,
warn: logger.warn ? (...msg) => logger.warn(...msg) : noop };
this.logger.debug(`Starting ${phantomPath} ${args.concat([shimPath]).join(' ')}`);
this.process = (0, _child_process.spawn)(phantomPath, args.concat([shimPath]), { env: process.env });
this.process.stdin.setDefaultEncoding('utf-8');
this.commands = new Map();
this.events = new Map();
this.process.stdout.pipe((0, _split2.default)()).on('data', data => {
const message = data.toString('utf8');
if (message[0] === '>') {
// Server end has finished NOOP, lets allow NOOP again..
if (message === `>${NOOP}`) {
this.logger.debug('Received NOOP command.');
this.isNoOpInProgress = false;
return;
}
const json = message.substr(1);
this.logger.debug('Parsing: %s', json);
const parsedJson = JSON.parse(json);
const command = this.commands.get(parsedJson.id);
if (command != null) {
const { deferred } = command;
if (deferred != null) {
if (parsedJson.error === undefined) {
deferred.resolve(parsedJson.response);
} else {
deferred.reject(new Error(parsedJson.error));
}
} else {
this.logger.error(`deferred object not found for command.id: ${parsedJson.id}`);
}
this.commands.delete(command.id);
} else {
this.logger.error(`command not found for command.id: ${parsedJson.id}`);
}
} else if (message.indexOf('<event>') === 0) {
const json = message.substr(7);
this.logger.debug('Parsing: %s', json);
const event = JSON.parse(json);
const emitter = this.events.get(event.target);
if (emitter) {
emitter.emit(...[event.type].concat(event.args));
}
} else if (message && message.length > 0) {
this.logger.info(message);
}
});
this.process.stderr.on('data', data => this.logger.error(data.toString('utf8')));
this.process.on('exit', code => {
this.logger.debug(`Child exited with code {${code}}`);
this.rejectAllCommands(`Phantom process stopped with exit code ${code}`);
});
this.process.on('error', error => {
this.logger.error(`Could not spawn [${phantomPath}] executable. ` +
'Please make sure phantomjs is installed correctly.');
this.logger.error(error);
this.kill(`Process got an error: ${error}`);
process.exit(1);
});
this.process.stdin.on('error', e => {
this.logger.debug(`Child process received error ${e}, sending kill signal`);
this.kill(`Error reading from stdin: ${e}`);
});
this.process.stdout.on('error', e => {
this.logger.debug(`Child process received error ${e}, sending kill signal`);
this.kill(`Error reading from stdout: ${e}`);
});
this.heartBeatId = setInterval(this.heartBeat.bind(this), 100);
}
/**
* Returns a value in the global space of phantom process
*/
windowProperty(...args) {
return this.execute('phantom', 'windowProperty', args);
}
/**
* Returns a new instance of Promise which resolves to a {@link Page}.
* @returns {Promise.<Page>}
*/
createPage() {
const { logger } = this;
return this.execute('phantom', 'createPage').then(response => {
let page = new _page2.default(this, response.pageId);
if (typeof Proxy !== 'function') {
throw new Error('Expected object Proxy to be defined. Make sure you are using Node 6+.');
}
page = new Proxy(page, {
set(target, prop) {
logger.warn(`Using page.${prop} = ...; is not supported. Use page.property('${prop}', ...) ` +
'instead. See the README file for more examples of page#property.');
return false;
} });
return page;
});
}
/**
* Creates a special object that can be used for returning data back from PhantomJS
* @returns {OutObject}
*/
createOutObject() {
return new _out_object2.default(this);
}
/**
* Used for creating a callback in phantomjs for content header and footer
* @param obj
*/
// eslint-disable-next-line class-methods-use-this
callback(obj) {
return {
transform: true,
target: obj,
method: 'callback',
parent: 'phantom' };
}
/**
* Executes a command object
* @param command the command to run
* @returns {Promise}
*/
executeCommand(command) {
this.commands.set(command.id, command);
const json = JSON.stringify(command, (key, val) => {
if (key[0] === '$') {
// if key starts with $ then ignore because it's private
return undefined;
} else if (typeof val === 'function') {
if (!Object.prototype.hasOwnProperty.call(val, 'prototype')) {
this.logger.warn('Arrow functions such as () => {} are not supported in PhantomJS. ' +
'Please use function(){} or compile to ES5.');
throw new Error('Arrow functions such as () => {} are not supported in PhantomJS.');
}
return val.toString();
}
return val;
});
const promise = new Promise((res, rej) => {
command.deferred = { resolve: res, reject: rej }; // eslint-disable-line no-param-reassign
});
this.logger.debug('Sending: %s', json);
this.process.stdin.write(json + _os2.default.EOL, 'utf8');
return promise;
}
/**
* Executes a command
*
* @param target target object to execute against
* @param name the name of the method execute
* @param args an array of args to pass to the method
* @returns {Promise}
*/
execute(target, name, args = []) {
return this.executeCommand(new _command2.default(target, name, args));
}
/**
* Adds an event listener to a target object (currently only works on pages)
*
* @param event the event type
* @param target target object to execute against
* @param runOnPhantom would the callback run in phantomjs or not
* @param callback the event callback
* @param args an array of args to pass to the callback
*/
on(event, target, runOnPhantom, callback, args = []) {
const eventDescriptor = { type: event };
if (runOnPhantom) {
eventDescriptor.event = callback;
eventDescriptor.args = args;
} else {
const emitter = this.getEmitterForTarget(target);
emitter.on(event, (...eArgs) => {
const params = eArgs.concat(args);
return callback(...params);
});
}
return this.execute(target, 'addEvent', [eventDescriptor]);
}
/**
* Removes an event from a target object
*
* @param event
* @param target
*/
off(event, target) {
const emitter = this.getEmitterForTarget(target);
emitter.removeAllListeners(event);
return this.execute(target, 'removeEvent', [{ type: event }]);
}
getEmitterForTarget(target) {
let emitter = this.events.get(target);
if (emitter == null) {
emitter = new _events2.default();
this.events.set(target, emitter);
}
return emitter;
}
cookies() {
return this.execute('phantom', 'property', ['cookies']);
}
/**
* Cleans up and end the phantom process
*/
exit() {
clearInterval(this.heartBeatId);
if (this.commands.size > 0) {
this.logger.warn('exit() was called before waiting for commands to finish. ' +
'Make sure you are not calling exit() prematurely.');
}
return this.execute('phantom', 'invokeMethod', ['exit']);
}
/**
* Clean up and force kill this process
*/
kill(errmsg = 'Phantom process was killed') {
this.rejectAllCommands(errmsg);
this.process.kill('SIGKILL');
}
heartBeat() {
if (!this.isNoOpInProgress) {
this.isNoOpInProgress = true;
this.logger.debug('Sending NOOP command.');
this.process.stdin.write(NOOP + _os2.default.EOL, 'utf8');
}
}
/**
* rejects all commands in this.commands
*/
rejectAllCommands(msg = 'Phantom exited prematurely') {
// prevent heartbeat from preventing this from terminating
clearInterval(this.heartBeatId);
this.commands.forEach(command => {
const { params: [name] } = command;
if (name !== 'exit' && command.deferred) {
command.deferred.reject(new Error(msg));
}
});
}}exports.default = Phantom;