workway
Version:
A general purpose, Web Worker driven, remote namespace with classes and methods.
224 lines (206 loc) • 6.24 kB
JavaScript
;
// used core modules
var EventEmitter = require('events').EventEmitter;
var crypto = require('crypto');
var fs = require('fs');
var http = require('http');
var path = require('path');
var vm = require('vm');
// extra dependencies
var Flatted = require('flatted');
var pocketIO = require('pocket.io');
var workerjs = fs.readFileSync(
path.resolve(__dirname, '..', 'worker.js')
).toString();
// used as communication channel
var SECRET = crypto.randomBytes(32).toString('hex');
// the client side is enriched at runtime
var jsContent = fs.readFileSync(path.join(__dirname, 'client.js'))
.toString()
.replace(
/\$\{(SECRET|JSON)\}/g,
function ($0, $1) { return this[$1]; }.bind({
SECRET: SECRET,
JSON: fs.readFileSync(require.resolve('flatted/min.js')).toString()
})
);
var workers = '';
process.on('unhandledRejection', uncaught);
process.on('uncaughtException', uncaught);
// return a new Worker sandbox
function createSandbox(filename, socket) {
var self = new EventEmitter;
var listeners = new WeakMap;
self.addEventListener = function (type, listener) {
if (!listeners.has(listener)) {
var facade = function (event) {
if (!event.canceled) listener.apply(this, arguments);
};
listeners.set(listener, facade);
self.on(type, facade);
}
};
self.removeEventListener = function (type, listener) {
self.removeListener(type, listeners.get(listener));
};
self.__filename = filename;
self.__dirname = path.dirname(filename);
self.postMessage = function postMessage(data) { message(socket, data); };
self.console = console;
self.process = process;
self.Buffer = Buffer;
self.clearImmediate = clearImmediate;
self.clearInterval = clearInterval;
self.clearTimeout = clearTimeout;
self.setImmediate = setImmediate;
self.setInterval = setInterval;
self.setTimeout = setTimeout;
self.module = module;
self.global = self;
self.self = self;
self.require = function (file) {
switch (true) {
case file === 'workway':
return self.workway;
case /^[./]/.test(file):
file = path.resolve(self.__dirname, file);
default:
return require(file);
}
};
return self;
}
function cleanedStack(stack) {
return ''.replace.call(stack, /:uid-\d+-[a-f0-9]{16}/g, '');
}
// notify the socket there was an error
function error(socket, err) {
socket.emit(SECRET + ':error', {
message: err.message,
stack: cleanedStack(err.stack)
});
}
// send serialized data to the client
function message(socket, data) {
socket.emit(SECRET + ':message', data);
}
// used to send /node-worker.js client file
function responder(request, response, next) {
response.writeHead(200, 'OK', {
'Content-Type': 'application/javascript'
});
response.end(jsContent);
if (next) next();
}
uid.i = 0;
uid.map = Object.create(null);
uid.delete = function (sandbox) {
Object.keys(uid.map).some(function (key) {
var found = uid.map[key] === sandbox;
if (found) delete uid.map[key];
return found;
});
};
function uid(filename, socket) {
var id = filename + ':uid-'.concat(++uid.i, '-', crypto.randomBytes(8).toString('hex'));
uid.map[id] = socket;
return id;
}
function uncaught(err) {
console.error(err);
if (/([\S]+?(:uid-\d+-[a-f0-9]{16}))/.test(err.stack)) {
var socket = uid.map[RegExp.$1];
if (socket) error(socket, err);
}
}
function Event(data) {
this.canceled = false;
this.data = data;
}
Event.prototype.stopImmediatePropagation = function () {
this.canceled = true;
};
module.exports = {
authorize: function (folder) {
if (workers.length) throw new Error('workway already authorized');
workers = folder;
return this;
},
app: function (app) {
var io;
var native = app instanceof http.Server;
if (native) {
io = pocketIO(app, {JSON: Flatted});
var request = app._events.request;
app._events.request = function (req) {
return /^\/workway@node\.js(?:\?|$)/.test(req.url) ?
responder.apply(this, arguments) :
request.apply(this, arguments);
};
} else {
var wrap = http.Server(app);
io = pocketIO(wrap, {JSON: Flatted});
app.get('/workway@node.js', responder);
Object.defineProperty(app, 'listen', {
configurable: true,
value: function () {
wrap.listen.apply(wrap, arguments);
return app;
}
});
}
io.on('connection', function (socket) {
var sandbox;
var queue = [];
function message(data) {
if (sandbox) {
try {
var event = new Event(data);
sandbox.emit('message', event);
if ('onmessage' in sandbox && !event.canceled) {
sandbox.onmessage(event);
}
} catch(err) {
error(socket, err);
}
}
else queue.push(data);
}
socket.on(SECRET, message);
socket.on(SECRET + ':setup', function (worker) {
var filename = path.resolve(path.join(workers, worker));
if (filename.indexOf(workers)) {
error(socket, {
message: 'Unauthorized worker: ' + worker,
stack: ''
});
} else {
fs.readFile(filename, function (err, content) {
if (err) {
error(socket, {
message: 'Worker not found: ' + worker,
stack: err.stack
});
} else {
sandbox = createSandbox(filename, socket);
vm.createContext(sandbox);
vm.runInContext(workerjs, sandbox);
vm.runInContext(content, sandbox, {
filename: uid(worker, socket),
displayErrors: true
});
while (queue.length) {
setTimeout(message, queue.length * 100, queue.pop());
}
}
});
}
});
socket.on('disconnect', function () {
uid.delete(socket);
sandbox = null;
});
});
return app;
}
};