config/emacs.d/swank-js/swank.js
2011-06-26 15:55:20 -07:00

425 lines
No EOL
15 KiB
JavaScript

// -*- mode: js2 -*-
//
// Copyright (c) 2010 Ivan Shvedunov. 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) {
stream.setEncoding("utf-8");
var handler = new swh.Handler(executive);
var parser = new swp.SwankParser(
function onMessage (message) {
handler.receive(message);
});
handler.on(
"response", function (response) {
var responseText = swp.buildMessage(response);
console.log("response: %s", responseText);
stream.write(responseText);
});
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.client = client;
this.client.on(
"message", function(m) {
// TBD: handle parse errors
// TBD: validate incoming message (id, etc.)
console.log("message from browser: %s", JSON.stringify(m));
switch(m.op) {
case "output":
this.output(m.str);
break;
case "result":
if (m.error) {
this.output(m.error + "\n");
this.sendResult(m.id, []);
break;
}
this.sendResult(m.id, m.values);
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.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({ id: id, code: str });
};
// 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': 'json2.js',
'stacktrace.js': 'stacktrace.js',
'swank-js.js': 'swank-js.js',
'load.js': 'load.js',
'test.html': 'test.html'
};
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/json2.js"></script>' +
'<script type="text/javascript" src="/socket.io/socket.io.js"></script>' +
'<script type="text/javascript" src="/swank-js/stacktrace.js"></script>' +
'<script type="text/javascript" src="/swank-js/swank-js.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.sendCachedFile = function sendCachedFile (req, res, path) {
if (req.headers['if-none-match'] == this.clientVersion) {
res.writeHead(304);
res.end();
} else {
res.writeHead(200, this.cachedFiles[path].headers);
res.end(this.cachedFiles[path].content, 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;
}
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 + '/client/' + localPath, function(err, data) {
if (err) {
console.log("error: %s", err);
self.notFound(res);
} else {
var ext = localPath.split('.').pop();
self.cachedFiles[localPath] = {
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);
var socket = io.listen(httpServer);
socket.on(
"connection", function (client) {
// new client is here!
console.log("client connected");
function handleHandshake (message) {
client.removeListener("message", handleHandshake);
if (!message.hasOwnProperty("op") || !message.op == "handshake")
console.warn("WARNING: bad handshake message: %j", message);
else {
var address = null;
if (client.connection && client.connection.remoteAddress)
address = client.connection.remoteAddress;
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