├── .gitignore ├── package.json ├── LICENSE ├── CHANGELOG.md ├── README.md ├── index.js └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghost-town", 3 | "version": "3.0.0", 4 | "description": "Simple queued & clustered PhantomJS processing.", 5 | "keywords": ["cluster", "phantomjs", "queue"], 6 | "homepage": "https://github.com/Buzzvil/ghost-town", 7 | "bugs": "https://github.com/Buzzvil/ghost-town/issues", 8 | "license": "MIT", 9 | "author": "Teddy Cross (https://teddy.io)", 10 | "main": "./index", 11 | "repository": "Buzzvil/ghost-town", 12 | "scripts": { 13 | "test": "mocha -R list -s 60s" 14 | }, 15 | "dependencies": { 16 | "phantom": "~2.1.12" 17 | }, 18 | "devDependencies": { 19 | "async": "~2.0.0", 20 | "chai": "~3.5.0", 21 | "mocha": "~2.5.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Buzzvil 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 3.0.0 / 2016-07-22 2 | ================== 3 | 4 | Ghost Town 3 is a major release with breaking changes in every dependency. Please carefully review each before upgrading! For the `phantom` migration guide, see: https://github.com/amir20/phantomjs-node#migrating-from-10x 5 | 6 | * Support Node 4+. 7 | * Support PhantomJS 2.1+. 8 | * Update `phantom` to `~2.1.12`. 9 | * Remove the `phantomPort` option. `phantom` now uses pipes. 10 | 11 | 2.1.0 / 2015-02-27 12 | ================== 13 | 14 | * Add the `workerShift` option. Use it if PhantomJS is occasionally never returning. 15 | 16 | 2.0.0 / 2015-02-05 17 | ================== 18 | 19 | Ghost Town 2 is a major refactor release made up of several small but breaking changes. Please carefully review each before upgrading! 20 | 21 | * Add tests! 22 | * Add an optional `asap` argument to `Master#queue()`. 23 | * Update `phantom` to `~0.7.2`. Now works with PhantomJS 2! 24 | * Remove `phantom`'s default PhantomJS stdout and stderr logging. Listen to the `.stdout` and `.stderr` streams on the `Worker#phantom` object instead. 25 | * Change the Ghost Town worker management algorithm so that it trusts workers less. May fix race conditions. 26 | * Change the `phantomFlags` option to accept an object instead of an array. Separate the key and value so that `"--disk-cache=true"` becomes `"disk-cache": "true"`. 27 | * Change some option defaults. `workerCount`: `os.cpus().length` to `4`. `pageDeath`: `120000` to `30000`. `workerDeath`: `20` to `25`. 28 | * Rename `Master#running` to `Master#isRunning`. 29 | * Rename all private properties to be private. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![license](https://img.shields.io/npm/l/ghost-town.svg?style=flat)](http://opensource.org/licenses/MIT) [![version](https://img.shields.io/npm/v/ghost-town.svg?style=flat)](https://www.npmjs.com/package/ghost-town) [![dependencies](https://img.shields.io/david/buzzvil/ghost-town.svg?style=flat)](https://david-dm.org/buzzvil/ghost-town) 2 | Simple queued & clustered PhantomJS processing. https://www.npmjs.com/package/ghost-town 3 | 4 | *Now with 100% creepier dependencies! Check out Ghost Town 3's breaking changes in [CHANGELOG.md](CHANGELOG.md).* 5 | 6 | --- 7 | 8 | Need highly scalable PhantomJS processing? Ghost Town makes it frighteningly easy! For example, on-demand page rendering, dispatched through Thrift: 9 | 10 | var town = require("ghost-town")(); 11 | 12 | if (town.isMaster) { 13 | thrift.createServer(Renderer, { 14 | render: function (html, width, height, next) { 15 | town.queue({ 16 | html: html, 17 | width: width, 18 | height: height 19 | }, function (err, data) { 20 | next(err, !err && new Buffer(data, "base64")); 21 | }); 22 | } 23 | }).listen(1337); 24 | } else { 25 | town.on("queue", function (page, data, next) { 26 | // sequential page setup 27 | // page.property("viewportSize", ...) 28 | // page.property("customHeaders", ...) 29 | // page.property("onLoadFinished", ...) 30 | // page.property("content", ...) 31 | 32 | page.renderBase64("jpeg").then(function (res) { 33 | next(null, res); 34 | }).catch(next); 35 | }); 36 | } 37 | 38 | Ghost Town uses Node's Cluster API, so the master and worker share their code. On the master side, queue items and handle their results. On the worker side, process items and return their results. 39 | 40 | Requires Node 4+ and PhantomJS 2.1+. 41 | 42 | --- 43 | 44 | `town(options)` 45 | 46 | * `phantomBinary`: String path to the PhantomJS executable. Default: Automatic via `$PATH`. 47 | * `phantomFlags`: Object of strings to use for the PhantomJS options. 48 | (For example, `--key=val` becomes `{ key: "val" }`.) Default: `{}`. 49 | * `workerCount`: Number of workers to maintain. One or two per CPU is recommended. Default: `4`. 50 | * `workerDeath`: Number of items to process before restarting a worker. Default: `25`. 51 | * `workerShift`: Number of milliseconds to wait before restarting a worker. Default: `-1` (forever). 52 | * `pageCount`: Number of pages to process at a time. If your processing is mostly asynchronous (vs. e.g. render blocked), increasing this is recommended. Default: `1`. 53 | * `pageDeath`: Number of milliseconds to wait before before requeuing an item. If your processing is time-sensitive, decreasing this is recommended. Default: `30000`. 54 | * `pageTries`: Number of times to retry items that have timed out. If your processing could fail forever, configuring this is recommended. Default: `-1` (unlimited). 55 | 56 | Starts Ghost Town and returns a `Master` or a `Worker` instance exposing the following. 57 | 58 | * `Master#isRunning` is set by `Master#start()` and `Master#stop()`. 59 | * `Master#isMaster` and `Worker#isMaster` can be used to separate master- and worker-specific code. 60 | * `Worker#phantom` is the PhantomJS wrapper object provided by [phantom](https://www.npmjs.com/package/phantom). 61 | 62 | `Master#start()` and `Master#stop()` 63 | Starts or stops processing. These spawn or kill workers and PhantomJS processes, so they're useful for managing resource usage or gracefully shutting down Node. 64 | 65 | `Master#queue(data, [asap], next)` 66 | Queue an item for processing by a worker. `data` is passed to `Worker!queue()`, and `next(err, data)` is called when complete. Optionally pass `true` to `asap` to prepend to the queue. 67 | 68 | `Worker!queue(page, data, next)` 69 | Fired when a worker receives an item to process. `page` is the PhantomJS page, `data` is what was passed to `Master#queue()`, and `next(err, data)` passes it back. 70 | 71 | --- 72 | 73 | © 2016 [Buzzvil](http://www.buzzvil.com), shared under the [MIT license](http://www.opensource.org/licenses/MIT). 74 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const cluster = require("cluster"); 4 | const events = require("events"); 5 | const phantom = require("phantom"); 6 | 7 | function is (type, val, def) { 8 | return val !== null && typeof val === type ? val : def; 9 | } 10 | 11 | class Master extends events.EventEmitter { 12 | constructor (opts) { 13 | opts = is("object", opts, {}); 14 | 15 | super(); 16 | 17 | this.isMaster = true; 18 | this.isRunning = false; 19 | 20 | this._workerCount = is("number", opts.workerCount, 4); 21 | this._workerQueue = []; 22 | 23 | this._itemTimeout = is("number", opts.pageDeath, 30000); 24 | this._itemRetries = is("number", opts.pageTries, -1); 25 | this._itemClicker = 0; 26 | this._itemQueue = []; 27 | this._items = {}; 28 | 29 | cluster.on("exit", this._onExit.bind(this)); 30 | 31 | this.start(); 32 | } 33 | 34 | _onMessage (msg) { 35 | if (is("object", msg, {}).ghost !== "town") { 36 | return; 37 | } 38 | 39 | const item = this._items[msg.id]; 40 | 41 | if (item) { 42 | delete this._items[msg.id]; 43 | clearTimeout(item.timeout); 44 | item.done(msg.err, msg.data); 45 | } 46 | 47 | this._workerQueue.push(cluster.workers[msg.worker]); 48 | this._process(); 49 | } 50 | 51 | _onTimeout (item) { 52 | delete this._items[item.id]; 53 | 54 | if (item.retries === this._itemRetries) { 55 | item.done(new Error("[ghost-town] max pageTries")); 56 | } else { 57 | this.queue(item.data, true, item.done, item.retries + 1); 58 | } 59 | } 60 | 61 | _onExit (worker) { 62 | for (let id in this._items) { 63 | const item = this._items[id]; 64 | 65 | if (item.worker === worker) { 66 | delete this._items[id]; 67 | clearTimeout(item.timeout); 68 | this.queue(item.data, true, item.done, item.retries); 69 | } 70 | } 71 | 72 | if (this.isRunning) { 73 | cluster.fork().on("message", this._onMessage.bind(this)); 74 | } 75 | } 76 | 77 | start () { 78 | if (this.isRunning) { 79 | return; 80 | } 81 | 82 | this.isRunning = true; 83 | 84 | for (let i = this._workerCount; i--;) { 85 | this._onExit(); 86 | } 87 | } 88 | 89 | stop () { 90 | this.isRunning = false; 91 | 92 | for (let key in cluster.workers) { 93 | cluster.workers[key].kill(); 94 | } 95 | } 96 | 97 | queue (data, asap, next, tries) { 98 | const item = { 99 | id: this._itemClicker++, 100 | timeout: -1, 101 | retries: tries || 0, 102 | data: data, 103 | done: next || asap, 104 | }; 105 | 106 | this._itemQueue[next && asap ? "unshift" : "push"](item); 107 | this._process(); 108 | } 109 | 110 | _process () { 111 | while (this._workerQueue.length && this._itemQueue.length) { 112 | const worker = this._workerQueue.shift(); 113 | 114 | if (!worker || !worker.process.connected) { 115 | continue; 116 | } 117 | 118 | const item = this._itemQueue.shift(); 119 | 120 | item.worker = worker; 121 | item.timeout = setTimeout(this._onTimeout.bind(this, item), this._itemTimeout); 122 | this._items[item.id] = item; 123 | 124 | worker.send({ 125 | ghost: "town", 126 | id: item.id, 127 | data: item.data, 128 | }); 129 | } 130 | } 131 | } 132 | 133 | class Worker extends events.EventEmitter { 134 | constructor (opts) { 135 | opts = is("object", opts, {}); 136 | 137 | super(); 138 | 139 | this.isMaster = false; 140 | 141 | this._workerDeath = is("number", opts.workerDeath, 25); 142 | this._workerShift = is("number", opts.workerShift, -1); 143 | 144 | this._pageCount = is("number", opts.pageCount, 1); 145 | this._pageClicker = 0; 146 | this._pages = {}; 147 | 148 | const flagArr = []; 149 | const flagObj = is("object", opts.phantomFlags, {}); 150 | 151 | for (let key in flagObj) { 152 | flagArr.push("--" + key + "=" + flagObj[key]); 153 | } 154 | 155 | phantom.create(flagArr, { 156 | phantomPath: opts.phantomBinary, 157 | }).then((proc) => { 158 | this.phantom = proc; 159 | 160 | for (let i = this._pageCount; i--;) { 161 | process.send({ 162 | ghost: "town", 163 | worker: cluster.worker.id, 164 | }); 165 | } 166 | }); 167 | 168 | process.on("message", this._onMessage.bind(this)); 169 | 170 | if (this._workerShift !== -1) { 171 | setTimeout(this._exit.bind(this), this._workerShift); 172 | } 173 | } 174 | 175 | _onMessage (msg) { 176 | if (is("object", msg, {}).ghost !== "town") { 177 | return; 178 | } 179 | 180 | this.phantom.createPage().then((page) => { 181 | this._pageClicker++; 182 | this._pages[msg.id] = page; 183 | this.emit("queue", page, msg.data, this._done.bind(this, msg.id)); 184 | }); 185 | } 186 | 187 | _done (id, err, data) { 188 | if (!this._pages[id]) { 189 | return; 190 | } 191 | 192 | this._pages[id].close(); 193 | delete this._pages[id]; 194 | 195 | process.send({ 196 | ghost: "town", 197 | worker: cluster.worker.id, 198 | id: id, 199 | err: err, 200 | data: data, 201 | }); 202 | 203 | if (this._pageClicker >= this._workerDeath) { 204 | this._exit(); 205 | } 206 | } 207 | 208 | _exit () { 209 | this.phantom.process.on("exit", process.exit); 210 | this.phantom.exit(); 211 | } 212 | } 213 | 214 | module.exports = (opts) => { 215 | return cluster.isMaster ? new Master(opts) : new Worker(opts); 216 | }; 217 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const async = require("async"); 4 | const child = require("child_process"); 5 | const cluster = require("cluster"); 6 | const expect = require("chai").expect; 7 | const ghost = require("./"); 8 | 9 | let townSend; 10 | 11 | if (cluster.isMaster) { 12 | cluster.setupMaster({ 13 | exec: __filename, 14 | }); 15 | 16 | cluster.on("online", function (worker) { 17 | worker.send(townSend || null); 18 | }); 19 | 20 | afterEach(function (next) { 21 | let live = 1; 22 | 23 | function step () { 24 | if (!--live) { 25 | child.exec("killall phantomjs", next.bind(null, null)); 26 | } 27 | } 28 | 29 | cluster.removeAllListeners("exit"); 30 | 31 | for (let key in cluster.workers) { 32 | live++; 33 | cluster.workers[key].on("exit", step).kill(); 34 | } 35 | 36 | step(); 37 | }); 38 | 39 | describe("Master", function () { 40 | this.timeout(5000); 41 | 42 | describe("constructor", function () { 43 | it("should support workerCount", function () { 44 | ghost({ workerCount: 8 }); 45 | 46 | expect(Object.keys(cluster.workers)).to.have.length(8); 47 | }); 48 | 49 | it("should support pageDeath", function (next) { 50 | townSend = {}; 51 | let town = ghost({ pageDeath: 100 }); 52 | 53 | async.waterfall([function (next) { 54 | // Give Ghost Town a dummy task it can keep timing out on 55 | // until phantom is warmed up and actually ready 56 | town.queue(0, function () { next(); }); 57 | }, function (next) { 58 | // Prevent retries to get an immediate test result 59 | town._itemRetries = 0; 60 | town.queue(0, function (err) { 61 | expect(err).to.be.null; 62 | next(); 63 | }); 64 | }, function (next) { 65 | town.queue(100, function (err) { 66 | expect(err).to.be.an.instanceof(Error); 67 | next(); 68 | }); 69 | }], next); 70 | }); 71 | 72 | it("should support pageTries (-1)", function (next) { 73 | townSend = {}; 74 | let town = ghost({ workerCount: 1, pageDeath: 0 }); 75 | let keys = Object.keys(cluster.workers); 76 | let trys = 0; 77 | 78 | cluster.workers[keys[0]].on("message", function (msg) { 79 | trys++; 80 | 81 | if (trys > 25) { 82 | next(); 83 | } 84 | }); 85 | 86 | town.queue(0, function () { 87 | next(Error("shouldn't have completed")); 88 | }); 89 | }); 90 | 91 | it("should support pageTries (x)", function (next) { 92 | townSend = {}; 93 | let town = ghost({ pageDeath: 0, pageTries: 42 }); 94 | 95 | town.queue(0, function (err) { 96 | expect(err).to.be.an.instanceof(Error); 97 | 98 | next(); 99 | }); 100 | }); 101 | }); 102 | 103 | describe("#start()", function () { 104 | it("should start Ghost Town", function () { 105 | let town = ghost(); 106 | 107 | expect(town).to.respondTo("start"); 108 | expect(town.isRunning).to.be.true; 109 | }); 110 | 111 | it("should start all workers", function () { 112 | ghost({ workerCount: 5 }); 113 | 114 | expect(Object.keys(cluster.workers)).to.have.length(5); 115 | }); 116 | 117 | it("should restart Ghost Town and all workers", function () { 118 | let town = ghost({ workerCount: 5 }); 119 | 120 | town.stop(); 121 | town._workerCount = 11; 122 | town.start(); 123 | 124 | expect(town.isRunning).to.be.true; 125 | expect(Object.keys(cluster.workers)).to.have.length(11); 126 | }); 127 | 128 | it("should be idempotent", function () { 129 | let town = ghost({ workerCount: 5 }); 130 | 131 | town.start(); 132 | town.start(); 133 | town.start(); 134 | 135 | expect(Object.keys(cluster.workers)).to.have.length(5); 136 | }); 137 | }); 138 | 139 | describe("#stop()", function () { 140 | it("should stop Ghost Town", function () { 141 | let town = ghost(); 142 | 143 | town.stop(); 144 | 145 | expect(town).to.respondTo("stop"); 146 | expect(town.isRunning).to.be.false; 147 | }); 148 | 149 | it("should stop all workers", function () { 150 | let town = ghost(); 151 | 152 | town.stop(); 153 | 154 | expect(cluster.workers).to.be.empty; 155 | }); 156 | }); 157 | 158 | describe("#queue()", function () { 159 | it("should process items", function () { 160 | let town = ghost(); 161 | 162 | expect(town).to.respondTo("queue"); 163 | }); 164 | 165 | it("should return results", function (next) { 166 | townSend = {}; 167 | let town = ghost(); 168 | 169 | town.queue(42, function (err, val) { 170 | expect(err).to.be.null; 171 | expect(val).to.equal(42); 172 | 173 | next(); 174 | }); 175 | }); 176 | 177 | it("should support prepending", function () { 178 | let town = ghost(); 179 | let curr = town._itemQueue; 180 | 181 | town.queue(0, function () {}); 182 | town.queue(0, function () {}); 183 | 184 | let orig = curr.slice(); 185 | 186 | town.queue(0, town.queue); 187 | 188 | expect(curr).to.deep.equal([ 189 | orig[0], 190 | orig[1], 191 | curr[2] 192 | ]); 193 | 194 | town.queue(0, true, town.start); 195 | town.queue(0, false, town.stop); 196 | 197 | expect(curr).to.deep.equal([ 198 | curr[0], 199 | orig[0], 200 | orig[1], 201 | curr[3], 202 | curr[4] 203 | ]); 204 | 205 | expect(curr).to.have.deep.property("[0].done", town.start); 206 | expect(curr).to.have.deep.property("[3].done", town.queue); 207 | expect(curr).to.have.deep.property("[4].done", town.stop); 208 | }); 209 | }); 210 | }); 211 | 212 | describe("Worker", function () { 213 | this.timeout(5000); 214 | 215 | describe("constructor", function () { 216 | // Pending release of https://github.com/amir20/phantomjs-node/pull/507 217 | it("should support phantomBinary"); 218 | 219 | it("should support phantomFlags", function (next) { 220 | townSend = { _test: "pid", phantomFlags: { "disk-cache": "true" } }; 221 | let town = ghost({ workerCount: 1 }); 222 | 223 | town.queue(null, function (err, val) { 224 | expect(err).to.be.null; 225 | 226 | child.exec("ps -p " + val + " -o command | sed 1d", function (err, out) { 227 | expect(out).to.contain("--disk-cache=true"); 228 | 229 | next(); 230 | }); 231 | }); 232 | }); 233 | 234 | it("should support workerDeath", function (next) { 235 | townSend = { workerDeath: 10 }; 236 | let town = ghost({ workerCount: 1 }); 237 | let orig = Object.keys(cluster.workers); 238 | 239 | expect(orig).to.have.length(1); 240 | 241 | async.timesSeries(11, function (n, next) { 242 | expect(cluster.workers).to.have.keys(orig); 243 | town.queue(0, next); 244 | }, function () { 245 | expect(cluster.workers).to.not.have.keys(orig); 246 | 247 | next(); 248 | }); 249 | }); 250 | 251 | it("should support workerShift", function (next) { 252 | townSend = { workerShift: 10 }; 253 | let town = ghost({ workerCount: 1 }); 254 | let orig = Object.keys(cluster.workers); 255 | 256 | expect(orig).to.have.length(1); 257 | 258 | setTimeout(function () { 259 | expect(cluster.workers).to.not.have.keys(orig); 260 | 261 | next(); 262 | }, 2000); 263 | }); 264 | 265 | it("should support pageCount", function (next) { 266 | townSend = { pageCount: 3 }; 267 | let town = ghost({ workerCount: 1 }); 268 | 269 | town.queue(0, function () { setImmediate(function () { 270 | town.queue(0); 271 | town.queue(0); 272 | town.queue(0); 273 | 274 | expect(town._itemQueue).to.be.empty; 275 | 276 | town.queue(0); 277 | town.queue(0); 278 | 279 | expect(town._itemQueue).to.have.length(2); 280 | 281 | next(); 282 | }); }); 283 | }); 284 | }); 285 | 286 | describe("!queue", function () { 287 | it("should pass arguments", function (next) { 288 | townSend = { _test: "passArgs" }; 289 | let town = ghost(); 290 | 291 | town.queue(42, next); 292 | }); 293 | 294 | it("should have an idempotent callback", function (next) { 295 | townSend = { _test: "passOnce" }; 296 | let town = ghost({ workerCount: 3 }); 297 | 298 | town.queue(0, function (err, val) { 299 | setTimeout(function () { 300 | expect(town._workerQueue).to.have.length(3); 301 | 302 | next(); 303 | }, 500); 304 | }); 305 | }); 306 | }); 307 | }); 308 | 309 | describe("Ghost Town", function () { 310 | this.timeout(25000); 311 | 312 | it("should handle heavy loads", function (next) { 313 | townSend = {}; 314 | let town = ghost(); 315 | 316 | async.times(500, function (n, next) { 317 | town.queue(n / 100, function (err, val) { 318 | if (err) { 319 | next(new Error("shouldn't have errored: " + err)); 320 | } else if (val !== n / 100) { 321 | next(new Error("returned the wrong data")); 322 | } else { 323 | next(); 324 | } 325 | }); 326 | }, next); 327 | }); 328 | 329 | it("should handle many timeouts", function (next) { 330 | townSend = {}; 331 | let town = ghost({ pageDeath: 100, pageTries: 0 }); 332 | 333 | async.times(100, function (n, next) { 334 | town.queue(n % 1 ? (n / 100) : 101, function (err, val) { 335 | if (n % 1) { 336 | if (err) { 337 | next(new Error("shouldn't have timed out")); 338 | } else if (val !== n / 100) { 339 | next(new Error("returned the wrong data")); 340 | } else { 341 | next(); 342 | } 343 | } else if (!err) { 344 | next(new Error("should have timed out")); 345 | } else { 346 | next(); 347 | } 348 | }); 349 | }, next); 350 | }); 351 | }); 352 | } else { 353 | const townTest = { 354 | pid: function (town, page, data, next) { 355 | next(null, town.phantom.process.pid); 356 | }, 357 | passArgs: function (town, page, data, next) { 358 | expect(page).to.be.an("object"); 359 | expect(page.renderBase64).to.be.a("function"); 360 | 361 | expect(data).to.equal(42); 362 | 363 | expect(next).to.be.a("function"); 364 | 365 | next(); 366 | }, 367 | passOnce: function (town, page, data, next) { 368 | next(); 369 | next(); 370 | next(); 371 | }, 372 | }; 373 | 374 | process.once("message", function (opts) { 375 | const town = ghost(opts).on("queue", function (page, data, next) { 376 | if (opts._test) { 377 | try { 378 | townTest[opts._test](town, page, data, next); 379 | } catch (err) { 380 | next(err); 381 | } 382 | } else { 383 | setTimeout(next, data, null, data); 384 | } 385 | }); 386 | }); 387 | } 388 | --------------------------------------------------------------------------------