// -*- 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( '' + '' + '' + ''); HttpListener.prototype.findClosingTag = function findClosingTag (buffer, name) { // note: this function is suitable for and 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 = "= 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