Version 1
[yaffs-website] / node_modules / phridge / lib / Phantom.js
1 "use strict";
2
3 var EventEmitter = require("events").EventEmitter;
4 var os = require("os");
5 var instances = require("./instances.js");
6 var Page = require("./Page.js");
7 var serializeFn = require("./serializeFn.js");
8 var phantomMethods = require("./phantom/methods.js");
9
10 var pageId = 0;
11 var slice = Array.prototype.slice;
12 var pingInterval = 100;
13 var nextRequestId = 0;
14
15 /**
16  * Provides methods to run code within a given PhantomJS child-process.
17  *
18  * @constructor
19  * @param {ChildProcess} childProcess
20  */
21 function Phantom(childProcess) {
22     Phantom.prototype.constructor.apply(this, arguments);
23 }
24
25 Phantom.prototype = Object.create(EventEmitter.prototype);
26
27 /**
28  * The ChildProcess-instance returned by node.
29  *
30  * @type {child_process.ChildProcess}
31  */
32 Phantom.prototype.childProcess = null;
33
34 /**
35  * Boolean flag which indicates that this process is about to exit or has already exited.
36  *
37  * @type {boolean}
38  * @private
39  */
40 Phantom.prototype._isDisposed = false;
41
42 /**
43  * The current scheduled ping id as returned by setTimeout()
44  *
45  * @type {*}
46  * @private
47  */
48 Phantom.prototype._pingTimeoutId = null;
49
50 /**
51  * The number of currently pending requests. This is necessary so we can stop the interval
52  * when no requests are pending.
53  *
54  * @type {number}
55  * @private
56  */
57 Phantom.prototype._pending = 0;
58
59 /**
60  * An object providing the resolve- and reject-function of all pending requests. Thus we can
61  * resolve or reject a pending promise in a different scope.
62  *
63  * @type {Object}
64  * @private
65  */
66 Phantom.prototype._pendingDeferreds = null;
67
68 /**
69  * A reference to the unexpected error which caused PhantomJS to exit.
70  * Will be appended to the error message for pending deferreds.
71  *
72  * @type {Error}
73  * @private
74  */
75 Phantom.prototype._unexpectedError = null;
76
77 /**
78  * Initializes a new Phantom instance.
79  *
80  * @param {child_process.ChildProcess} childProcess
81  */
82 Phantom.prototype.constructor = function (childProcess) {
83     EventEmitter.call(this);
84
85     this._receive = this._receive.bind(this);
86     this._write = this._write.bind(this);
87     this._afterExit = this._afterExit.bind(this);
88     this._onUnexpectedError = this._onUnexpectedError.bind(this);
89
90     this.childProcess = childProcess;
91     this._pendingDeferreds = {};
92
93     instances.push(this);
94
95     // Listen for stdout messages dedicated to phridge
96     childProcess.phridge.on("data", this._receive);
97
98     // Add handlers for unexpected events
99     childProcess.on("exit", this._onUnexpectedError);
100     childProcess.on("error", this._onUnexpectedError);
101     childProcess.stdin.on("error", this._onUnexpectedError);
102     childProcess.stdout.on("error", this._onUnexpectedError);
103     childProcess.stderr.on("error", this._onUnexpectedError);
104 };
105
106 /**
107  * Stringifies the given function fn, sends it to PhantomJS and runs it in the scope of PhantomJS.
108  * You may prepend any number of arguments which will be passed to fn inside of PhantomJS. Please note that all
109  * arguments should be stringifyable with JSON.stringify().
110  *
111  * @param {...*} args
112  * @param {Function} fn
113  * @returns {Promise}
114  */
115 Phantom.prototype.run = function (args, fn) {
116     var self = this;
117
118     args = arguments;
119
120     return new Promise(function (resolve, reject) {
121         args = slice.call(args);
122         fn = args.pop();
123
124         self._send(
125             {
126                 action: "run",
127                 data: {
128                     src: serializeFn(fn, args)
129                 }
130             },
131             args.length === fn.length
132         ).then(resolve, reject);
133     });
134 };
135
136 /**
137  * Returns a new instance of a Page which can be used to run code in the context of a specific page.
138  *
139  * @returns {Page}
140  */
141 Phantom.prototype.createPage = function () {
142     var self = this;
143
144     return new Page(self, pageId++);
145 };
146
147 /**
148  * Creates a new instance of Page, opens the given url and resolves when the page has been loaded.
149  *
150  * @param {string} url
151  * @returns {Promise}
152  */
153 Phantom.prototype.openPage = function (url) {
154     var page = this.createPage();
155
156     return page.run(url, phantomMethods.openPage)
157         .then(function () {
158             return page;
159         });
160 };
161
162 /**
163  * Exits the PhantomJS process cleanly and cleans up references.
164  *
165  * @see http://msdn.microsoft.com/en-us/library/system.idisposable.aspx
166  * @returns {Promise}
167  */
168 Phantom.prototype.dispose = function () {
169     var self = this;
170
171     return new Promise(function dispose(resolve, reject) {
172         if (self._isDisposed) {
173             resolve();
174             return;
175         }
176
177         // Remove handler for unexpected exits and add regular exit handlers
178         self.childProcess.removeListener("exit", self._onUnexpectedError);
179         self.childProcess.on("exit", self._afterExit);
180         self.childProcess.on("exit", resolve);
181
182         self.removeAllListeners();
183
184         self.run(phantomMethods.exitPhantom).catch(reject);
185
186         self._beforeExit();
187     });
188 };
189
190 /**
191  * Prepares the given message and writes it to childProcess.stdin.
192  *
193  * @param {Object} message
194  * @param {boolean} fnIsSync
195  * @returns {Promise}
196  * @private
197  */
198 Phantom.prototype._send = function (message, fnIsSync) {
199     var self = this;
200
201     return new Promise(function (resolve, reject) {
202         message.from = new Error().stack
203             .split(/\n/g)
204             .slice(1)
205             .join("\n");
206         message.id = nextRequestId++;
207
208         self._pendingDeferreds[message.id] = {
209             resolve: resolve,
210             reject: reject
211         };
212         if (!fnIsSync) {
213             self._schedulePing();
214         }
215         self._pending++;
216
217         self._write(message);
218     });
219 };
220
221 /**
222  * Helper function that stringifies the given message-object, appends an end of line character
223  * and writes it to childProcess.stdin.
224  *
225  * @param {Object} message
226  * @private
227  */
228 Phantom.prototype._write = function (message) {
229     this.childProcess.stdin.write(JSON.stringify(message) + os.EOL, "utf8");
230 };
231
232 /**
233  * Parses the given message via JSON.parse() and resolves or rejects the pending promise.
234  *
235  * @param {string} message
236  * @private
237  */
238 Phantom.prototype._receive = function (message) {
239     // That's our initial hi message which should be ignored by this method
240     if (message === "hi") {
241         return;
242     }
243
244     // Not wrapping with try-catch here because if this message is invalid
245     // we have no chance to map it back to a pending promise.
246     // Luckily this JSON can't be invalid because it has been JSON.stringified by PhantomJS.
247     message = JSON.parse(message);
248
249     // pong messages are special
250     if (message.status === "pong") {
251         this._pingTimeoutId = null;
252
253         // If we're still waiting for a message, we need to schedule a new ping
254         if (this._pending > 0) {
255             this._schedulePing();
256         }
257         return;
258     }
259     this._resolveDeferred(message);
260 };
261
262 /**
263  * Takes the required actions to respond on the given message.
264  *
265  * @param {Object} message
266  * @private
267  */
268 Phantom.prototype._resolveDeferred = function (message) {
269     var deferred;
270
271     deferred = this._pendingDeferreds[message.id];
272
273     // istanbul ignore next because this is tested in a separated process and thus isn't recognized by istanbul
274     if (!deferred) {
275         // This happens when resolve() or reject() have been called twice
276         if (message.status === "success") {
277             throw new Error("Cannot call resolve() after the promise has already been resolved or rejected");
278         } else if (message.status === "fail") {
279             throw new Error("Cannot call reject() after the promise has already been resolved or rejected");
280         }
281     }
282
283     delete this._pendingDeferreds[message.id];
284     this._pending--;
285
286     if (message.status === "success") {
287         deferred.resolve(message.data);
288     } else {
289         deferred.reject(message.data);
290     }
291 };
292
293 /**
294  * Sends a ping to the PhantomJS process after a given delay.
295  * Check out lib/phantom/start.js for an explanation of the ping action.
296  *
297  * @private
298  */
299 Phantom.prototype._schedulePing = function () {
300     if (this._pingTimeoutId !== null) {
301         // There is already a ping scheduled. It's unnecessary to schedule another one.
302         return;
303     }
304     if (this._isDisposed) {
305         // No need to schedule a ping, this instance is about to be disposed.
306         // Catches rare edge cases where a pong message is received right after the instance has been disposed.
307         // @see https://github.com/peerigon/phridge/issues/41
308         return;
309     }
310     this._pingTimeoutId = setTimeout(this._write, pingInterval, { action: "ping" });
311 };
312
313 /**
314  * This function is executed before the process is actually killed.
315  * If the process was killed autonomously, however, it gets executed postmortem.
316  *
317  * @private
318  */
319 Phantom.prototype._beforeExit = function () {
320     var index;
321
322     this._isDisposed = true;
323
324     index = instances.indexOf(this);
325     index !== -1 && instances.splice(index, 1);
326     clearTimeout(this._pingTimeoutId);
327
328     // Seal the run()-method so that future calls will automatically be rejected.
329     this.run = runGuard;
330 };
331
332 /**
333  * This function is executed after the process actually exited.
334  *
335  * @private
336  */
337 Phantom.prototype._afterExit = function () {
338     var deferreds = this._pendingDeferreds;
339     var errorMessage = "Cannot communicate with PhantomJS process: ";
340     var error;
341
342     if (this._unexpectedError) {
343         errorMessage += this._unexpectedError.message;
344         error = new Error(errorMessage);
345         error.originalError = this._unexpectedError;
346     } else {
347         errorMessage += "Unknown reason";
348         error = new Error(errorMessage);
349     }
350
351     this.childProcess = null;
352
353     // When there are still any deferreds, we must reject them now
354     Object.keys(deferreds).forEach(function forEachPendingDeferred(id) {
355         deferreds[id].reject(error);
356         delete deferreds[id];
357     });
358 };
359
360 /**
361  * Will be called as soon as an unexpected IO error happened on the attached PhantomJS process. Cleans up everything
362  * and emits an unexpectedError event afterwards.
363  *
364  * Unexpected IO errors usually happen when the PhantomJS process was killed by another party. This can occur
365  * on some OS when SIGINT is sent to the whole process group. In these cases, node throws EPIPE errors.
366  * (https://github.com/peerigon/phridge/issues/34).
367  *
368  * @private
369  * @param {Error} error
370  */
371 Phantom.prototype._onUnexpectedError = function (error) {
372     var errorMessage;
373
374     if (this._isDisposed) {
375         return;
376     }
377
378     errorMessage = "PhantomJS exited unexpectedly";
379     if (error) {
380         error.message = errorMessage + ": " + error.message;
381     } else {
382         error = new Error(errorMessage);
383     }
384     this._unexpectedError = error;
385
386     this._beforeExit();
387     // Chainsaw against PhantomJS zombies
388     this.childProcess.kill("SIGKILL");
389     this._afterExit();
390
391     this.emit("unexpectedExit", error);
392 };
393
394 /**
395  * Will be used as "seal" for the run method to prevent run() calls after dispose.
396  * Appends the original error when there was unexpected error.
397  *
398  * @returns {Promise}
399  * @this Phantom
400  */
401 function runGuard() {
402     var err = new Error("Cannot run function");
403     var cause = this._unexpectedError ? this._unexpectedError.message : "Phantom instance is already disposed";
404
405     err.message += ": " + cause;
406     err.originalError = this._unexpectedError;
407
408     return Promise.reject(err);
409 }
410
411 module.exports = Phantom;