swank-js
Version:
Swank backend for Node.JS and in-browser JavaScript.
502 lines (454 loc) • 18.3 kB
JavaScript
// -*- mode: js2 -*-
//
// Copyright (c) 2010 Ivan Shvedunov. All rights reserved.
// Copyright (c) 2012 Robert Krahn. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions
// are met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
//
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following
// disclaimer in the documentation and/or other materials
// provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED
// OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
// GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
// NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
var net = require("net"), http = require('http'), io = require('socket.io'), util = require("util"),
url = require('url'), fs = require('fs');
var swh = require("./swank-handler");
var swp = require("./swank-protocol");
var ua = require("./user-agent");
var config = require("./config");
var DEFAULT_TARGET_HOST = "localhost";
var DEFAULT_TARGET_PORT = 8080;
var CONFIG_FILE_NAME = "~/.swankjsrc";
var cfg = new config.Config(CONFIG_FILE_NAME);
var executive = new swh.Executive({ config: cfg });
var swankServer = net.createServer(
function (stream) {
var handler = new swh.Handler(executive);
var parser = new swp.SwankParser(
function onMessage (message) {
handler.receive(message);
});
handler.on(
"response", function (response) {
var responseBuf = swp.buildMessage(response);
console.log("response: %s", responseBuf.toString());
stream.write(responseBuf);
});
stream.on(
"data", function (data) {
parser.execute(data);
});
stream.on(
"end", function () {
// FIXME: notify handler -> executive
// TBD: destroy the handler
handler.removeAllListeners("response");
});
});
swankServer.listen(process.argv[2] || 4005, process.argv[3] || "localhost");
function BrowserRemote (clientInfo, client) {
var userAgent = ua.recognize(clientInfo.userAgent);
this.name = userAgent.replace(/ /g, "") + (clientInfo.address ? (":" + clientInfo.address) : "");
this._prompt = userAgent.toUpperCase().replace(/ /g, '-');
this.pendingRequests = {};
this.client = client;
this.client.on(
"message", function(m) {
// TBD: handle parse errors
// TBD: validate incoming message (id, etc.)
m = JSON.parse(m);
if (m.op !== "ping") // don't show pings
console.log("message from browser: %s", JSON.stringify(m));
switch(m.op) {
case "output":
this.output(m.str);
break;
case "result":
if (!this.pendingRequests.hasOwnProperty(m.id)) {
console.log("WARNING: late result response from the browser");
break;
}
delete this.pendingRequests[m.id];
if (m.error) {
this.output(m.error + "\n");
this.sendResult(m.id, []);
} else
this.sendResult(m.id, m.values);
this.sweepRequests();
break;
case "ping":
this.client.send(JSON.stringify({ "pong": m.id }));
break;
default:
console.log("WARNING: cannot interpret the client message");
}
}.bind(this));
this.client.on(
"disconnect", function() {
console.log("client disconnected: %s", this.id());
this.disconnect();
}.bind(this));
}
util.inherits(BrowserRemote, swh.Remote);
BrowserRemote.prototype.REQUEST_TIMEOUT = 3000;
BrowserRemote.prototype.sweepRequests = function sweepRequests (all) {
Object.keys(this.pendingRequests).forEach(
function (id) {
if (all || this.pendingRequests[id] < new Date().getTime() - this.REQUEST_TIMEOUT) {
console.log("request %s didn't finish", id);
this.sendResult(id, []);
delete this.pendingRequests[id];
}
}, this);
};
BrowserRemote.prototype.prompt = function prompt () {
return this._prompt;
};
BrowserRemote.prototype.kind = function kind () {
return "browser";
};
BrowserRemote.prototype.id = function id () {
return this.name;
};
BrowserRemote.prototype.evaluate = function evaluate (id, str) {
this.client.send(JSON.stringify({ "id": id, "code": str }));
this.pendingRequests[id] = new Date().getTime();
};
BrowserRemote.prototype.completion = function completion (id, str) {
this.client.send(JSON.stringify({ "id": id, "completion": str }));
this.pendingRequests[id] = new Date().getTime();
};
BrowserRemote.prototype.disconnect = function disconnect () {
this.sweepRequests(true);
swh.Remote.prototype.disconnect.call(this);
};
// proxy code from http://www.catonmat.net/http-proxy-in-nodejs
function HttpListener (cfg) {
this.config = cfg;
}
HttpListener.prototype.clientVersion = "0.1";
HttpListener.prototype.cachedFiles = {};
HttpListener.prototype.clientFiles = {
'json2.js': 'client/json2.js',
'stacktrace.js': 'client/stacktrace.js',
'swank-js.js': 'client/swank-js.js',
'load.js': 'client/load.js',
'swank-js-inject.js': 'client/swank-js-inject.js',
'browser-tests.js': 'client/browser-tests.js',
'test.html': 'client/test.html',
'completion.js': 'completion.js'
};
HttpListener.prototype.types = {
html: "text/html; charset=utf-8",
js: "text/javascript; charset=utf-8"
};
HttpListener.prototype.scriptBlock =
new Buffer('<script type="text/javascript" src="/swank-js/swank-js-inject.js"></script>');
HttpListener.prototype.findClosingTag = function findClosingTag (buffer, name) {
// note: this function is suitable for <head> and <body> tags,
// because they don't contain any repeating letters, but
// it will not work for tags that have such letters
var chars = [];
var endChar = ">".charCodeAt(0);
name = "</" + name.toLowerCase();
for (var i = 0; i < name.length; ++i)
chars.push(name.charCodeAt(i));
var A_CODE = "A".charCodeAt(0), Z_CODE = "Z".charCodeAt(0), CODE_INC = "a".charCodeAt(0) - A_CODE;
function codeToLower (x) {
return x >= A_CODE && x <= Z_CODE ? x + CODE_INC : x;
}
for (i = 0; i < buffer.length - chars.length - 1;) {
var found = true;
if (buffer[i++] != chars[0]) // note: no lowercasing for matching against '<'
continue;
for (var j = 1; j < chars.length; ++j, ++i) {
if (codeToLower(buffer[i]) != chars[j]) {
found = false;
break;
}
}
if (found) {
for (var k = i; k < buffer.length; ++k) {
if (buffer[k] == endChar)// note: no lowercasing for matching against '>'
return i - chars.length;
}
}
}
return -1;
};
HttpListener.prototype.injectScripts = function injectScripts (buffer, url) {
var p = this.findClosingTag(buffer, "head");
if (p < 0) {
p = this.findClosingTag(buffer, "body");
if (p < 0) {
// html blocks without head / body tags aren't that uncommon
// console.log("WARNING: unable to inject script block: %s", url);
return buffer;
}
}
var newBuf = new Buffer(buffer.length + this.scriptBlock.length);
buffer.copy(newBuf, 0, 0, p);
this.scriptBlock.copy(newBuf, p, 0);
buffer.copy(newBuf, p + this.scriptBlock.length, p);
return newBuf;
};
HttpListener.prototype.proxyRequest = function proxyRequest (request, response) {
var self = this;
this.config.get(
"targetUrl",
function (targetUrl) {
self.doProxyRequest(targetUrl, request, response);
});
};
HttpListener.prototype.doProxyRequest = function doProxyRequest (targetUrl, request, response) {
var self = this;
var headersSent = false;
var done = false;
var hostname = DEFAULT_TARGET_HOST;
var port = DEFAULT_TARGET_PORT;
var parsedUrl = null;
try {
parsedUrl = url.parse(targetUrl);
} catch (e) {}
if (parsedUrl && parsedUrl.hostname) {
hostname = parsedUrl.hostname;
port = parsedUrl.port ? parsedUrl.port - 0 : 80;
}
request.headers["host"] = hostname + (port == 80 ? "" : ":" + port);
delete request.headers["accept-encoding"]; // we don't want gzipped pages, do we?
// note on http client error handling:
// http://rentzsch.tumblr.com/post/664884799/node-js-handling-refused-http-client-connections
var proxy = http.createClient(port, hostname);
proxy.addListener(
'error', function handleError (e) {
console.log("proxy error: %s", e);
if (done)
return;
if (headersSent) {
response.end();
return;
}
response.writeHead(502, {'Content-Type': 'text/plain; charset=utf-8'});
response.end("swank-js: unable to forward the request");
});
console.log("PROXY: %s %s", request.method, request.url);
var proxyRequest = proxy.request(request.method, request.url, request.headers);
proxyRequest.addListener(
'response', function (proxyResponse) {
var contentType = proxyResponse.headers["content-type"];
var statusCode = proxyResponse.statusCode;
console.log("==> status %s", statusCode);
var headers = {};
for (k in proxyResponse.headers) {
if (proxyResponse.headers.hasOwnProperty(k))
headers[k] = proxyResponse.headers[k];
}
var chunks = proxyResponse.statusCode == 200 && contentType && /^text\/html\b|^application\/xhtml\+xml/.test(contentType) ?
[] : null;
if (chunks === null) {
// FIXME: without this, there were problems with redirects.
// I don't quite understand why...
response.writeHead(statusCode, headers);
headersSent = true;
}
proxyResponse.addListener(
'data', function (chunk) {
if (chunks !== null) {
chunks.push(chunk);
return;
}
if (!headersSent) {
response.writeHead(statusCode, headers);
headersSent = true;
}
response.write(chunk, 'binary');
});
proxyResponse.addListener(
'end', function() {
if (chunks !== null) {
console.log("^^MOD: %s %s", request.method, request.url);
var buf = new Buffer(chunks.reduce(function (s, chunk) { return s += chunk.length; }, 0));
var p = 0;
chunks.forEach(
function (chunk) {
chunk.copy(buf, p, 0);
p += chunk.length;
});
buf = self.injectScripts(buf, request.url);
headers["content-length"] = buf.length;
response.writeHead(statusCode, headers);
headersSent = true;
response.write(buf, 'binary');
} else if (!headersSent) {
response.writeHead(statusCode, headers);
headersSent = true;
}
response.end();
done = true;
});
});
request.addListener(
'data', function(chunk) {
proxyRequest.write(chunk, 'binary');
});
request.addListener(
'end', function() {
proxyRequest.end();
});
};
HttpListener.prototype.getBookMarklets = function getBookMarklets() {
//TBD: Allow IPv6 Bookmarklets?
var ifaces = require('os').networkInterfaces();
var ips = [];
//TBD: This sucks and can probably be done much better in a functional style
for (dev in ifaces) {
console.log ("found device "+dev);
ifaces[dev].forEach(function(details) {
console.log(details);
if (details.family=='IPv4') {
ips.push(details.address);
}
});
}
//console.log(ips);
//TODO: the port is a magic number. Needs to stop being magic.
var out = ips.map(function(ip){
var bookmarklet = escape("(function(d){window.swank_server='http://"+ip+":8009/';if(!d.getElementById('swank-js-inj')){var h=d.getElementsByTagName('head')[0],s=d.createElement('script');s.id='swank-js-inj';s.type='text/javascript';s.src=swank_server+'swank-js/swank-js-inject.js';h.appendChild(s);}})(document);");
return '<li><a href="javascript:'+bookmarklet +'"> Connet to slime on '+ip+'</a><br/>javascript:'+bookmarklet+'</li>';
});
//console.log(out.join('\n'));
return out.join('\n');
}
HttpListener.prototype.sendCachedFile = function sendCachedFile (req, res, path) {
if (req.headers['if-none-match'] == this.clientVersion) {
res.writeHead(304);
res.end();
} else {
// sorry for this, but there is only one replacement, and only one file to replace, so
// for now... some bad code. But if there needs to be a new replacement, or replacements
// on more then one file, this'll need updating.
var out = ((path == 'client/test.html') ? (this.cachedFiles[path].content+'').replace('<!--[[bookmarklets]]-->',this.getBookMarklets())
: this.cachedFiles[path].content);
//console.log(out);
//TBD: Remove the setting of the length header earlier on in the process.
this.cachedFiles[path].headers['Content-Length'] = Buffer(out).length;
res.writeHead(200, this.cachedFiles[path].headers);
res.end(out, this.cachedFiles[path].encoding);
}
};
HttpListener.prototype.notFound = function notFound (res) {
res.writeHead(404, {'Content-Type': 'text/plain; charset=utf-8'});
res.end("file not found");
};
HttpListener.prototype.serveClient = function serveClient(req, res) {
var self = this;
var path = url.parse(req.url).pathname, parts, cn;
// console.log("%s %s", req.method, req.url);
if (path && path.indexOf("/swank-js/") != 0) {
//console.log("--> proxy");
this.proxyRequest(req, res);
return;
}
//console.log("--> internal");
var file = path.substr(1).split('/').slice(1);
var localPath = this.clientFiles[file];
if (req.method == 'GET' && localPath !== undefined) {
// TBD: reenable caching, check datetime of the file
// if (path in this.cachedFiles){
// this.sendCachedFile(req, res, path);
// return;
// }
fs.readFile(
__dirname + '/' + localPath, function(err, data) {
if (err) {
console.log("error: %s", err);
self.notFound(res);
} else {
var ext = localPath.split('.').pop();
self.cachedFiles[localPath] = {
// right now there is no difference between cached files
// and files inside of the client dir. That should probably change
// soon.
headers: {
'Content-Length': data.length,
'Content-Type': self.types[ext],
'ETag': self.clientVersion
},
content: data,
encoding: ext == 'swf' ? 'binary' : 'utf8'
};
self.sendCachedFile(req, res, localPath);
}
});
} else {
console.log("bad request for /swank-js/ path");
this.notFound(res);
}
};
var httpListener = new HttpListener(cfg);
var httpServer = http.createServer(httpListener.serveClient.bind(httpListener));
httpServer.listen(8009);
io = io.listen(httpServer);
io.sockets.on(
"connection", function (client) {
// new client is here!
console.log("client connected");
function handleHandshake (message) {
message = JSON.parse(message);
client.removeListener("message", handleHandshake);
if (!message.hasOwnProperty("op") || message.op != "handshake")
console.warn("WARNING: skipping pre-handshake message: %j", message);
else {
var address = null;
if (client.connection && client.connection.remoteAddress)
address = client.connection.remoteAddress || "noaddress";
var remote = new BrowserRemote({ address: address, userAgent: message.userAgent || "" }, client);
executive.attachRemote(remote);
console.log("added remote: %s", remote.fullName());
}
};
client.on("message", handleHandshake);
});
// TBD: handle reader errors
// function location determination:
// for code loaded from scripts: direct (if possible)
// for 'compiled' code: load the code by adding <script> tag loaded from the swank-js' webserver, its name should encode the real path and line offset
// for code entered via REPL: none
// PREPROCESS STACK TRACES!!!
// https://github.com/emwendelin/Javascript-Stacktrace
// ALSO: http://blog.yoursway.com/2009/07/3-painful-ways-to-obtain-stack-trace-in.html -- onerror in ie gives the innermost frame
// it should be also possible to 'soft-trace' functions so that they extend Exception objects with caller info as it passes through them
// TBD: unix domain sockets, normal slime startup
// TBD: http request logging (for specific remote)
// TBD: sudden disconnections (flashsocket), sometimes after lots of output (?) --
// Error: You are trying to call recursively into the Flash Player which is not allowed. In most cases the JavaScript setTimeout function, can be used as a workaround.
// TBD: autoreconnect + connection error handling
// ALSO: are htmlfile, jsonp-polling modes etc supposed to disconnect after each message?
// TBD: add SwankJS scripts to all passing html pages (into <head> or <body>)
// TBD: it should be possible to serve local files instead of proxying
// (maybe using https://github.com/felixge/node-paperboy )
// TBD: handle edge case: new sticky remote connects, old sticky remote disconnects
// (late disconnect) - as of now, swank-js switches to node.js, but it should
// instead upon remote detachment see whether another remote with the same name
// is available
// TBD: handle/add X-Forwarded-For headers
// TBD: fix all assert calls: we need (actual, expected) not (expected, actual)
// TBD: invoke SwankJS.setup() only when DOM is ready (at least in IE)
// TBD: timeouts for browser requests