swank.js
1 // -*- mode: js2 -*- 2 // 3 // Copyright (c) 2010 Ivan Shvedunov. All rights reserved. 4 // 5 // Redistribution and use in source and binary forms, with or without 6 // modification, are permitted provided that the following conditions 7 // are met: 8 // 9 // * Redistributions of source code must retain the above copyright 10 // notice, this list of conditions and the following disclaimer. 11 // 12 // * Redistributions in binary form must reproduce the above 13 // copyright notice, this list of conditions and the following 14 // disclaimer in the documentation and/or other materials 15 // provided with the distribution. 16 // 17 // THIS SOFTWARE IS PROVIDED BY THE AUTHOR 'AS IS' AND ANY EXPRESSED 18 // OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 20 // ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 21 // DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE 23 // GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 24 // INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 25 // WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 26 // NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 29 var net = require("net"), http = require('http'), io = require('socket.io'), util = require("util"), 30 url = require('url'), fs = require('fs'); 31 var swh = require("./swank-handler"); 32 var swp = require("./swank-protocol"); 33 var ua = require("./user-agent"); 34 var config = require("./config"); 35 36 var DEFAULT_TARGET_HOST = "localhost"; 37 var DEFAULT_TARGET_PORT = 8080; 38 var CONFIG_FILE_NAME = "~/.swankjsrc"; 39 40 var cfg = new config.Config(CONFIG_FILE_NAME); 41 var executive = new swh.Executive({ config: cfg }); 42 43 var swankServer = net.createServer( 44 function (stream) { 45 stream.setEncoding("utf-8"); 46 var handler = new swh.Handler(executive); 47 var parser = new swp.SwankParser( 48 function onMessage (message) { 49 handler.receive(message); 50 }); 51 handler.on( 52 "response", function (response) { 53 var responseText = swp.buildMessage(response); 54 console.log("response: %s", responseText); 55 stream.write(responseText); 56 }); 57 stream.on( 58 "data", function (data) { 59 parser.execute(data); 60 }); 61 stream.on( 62 "end", function () { 63 // FIXME: notify handler -> executive 64 // TBD: destroy the handler 65 handler.removeAllListeners("response"); 66 }); 67 }); 68 swankServer.listen(process.argv[2] || 4005, process.argv[3] || "localhost"); 69 70 function BrowserRemote (clientInfo, client) { 71 var userAgent = ua.recognize(clientInfo.userAgent); 72 this.name = userAgent.replace(/ /g, "") + (clientInfo.address ? (":" + clientInfo.address) : ""); 73 this._prompt = userAgent.toUpperCase().replace(/ /g, '-'); 74 this.client = client; 75 this.client.on( 76 "message", function(m) { 77 // TBD: handle parse errors 78 // TBD: validate incoming message (id, etc.) 79 console.log("message from browser: %s", JSON.stringify(m)); 80 switch(m.op) { 81 case "output": 82 this.output(m.str); 83 break; 84 case "result": 85 if (m.error) { 86 this.output(m.error + "\n"); 87 this.sendResult(m.id, []); 88 break; 89 } 90 this.sendResult(m.id, m.values); 91 break; 92 default: 93 console.log("WARNING: cannot interpret the client message"); 94 } 95 }.bind(this)); 96 this.client.on( 97 "disconnect", function() { 98 console.log("client disconnected: %s", this.id()); 99 this.disconnect(); 100 }.bind(this)); 101 } 102 103 util.inherits(BrowserRemote, swh.Remote); 104 105 BrowserRemote.prototype.prompt = function prompt () { 106 return this._prompt; 107 }; 108 109 BrowserRemote.prototype.kind = function kind () { 110 return "browser"; 111 }; 112 113 BrowserRemote.prototype.id = function id () { 114 return this.name; 115 }; 116 117 BrowserRemote.prototype.evaluate = function evaluate (id, str) { 118 this.client.send({ id: id, code: str }); 119 }; 120 121 // proxy code from http://www.catonmat.net/http-proxy-in-nodejs 122 123 function HttpListener (cfg) { 124 this.config = cfg; 125 } 126 127 HttpListener.prototype.clientVersion = "0.1"; 128 129 HttpListener.prototype.cachedFiles = {}; 130 131 HttpListener.prototype.clientFiles = { 132 'json2.js': 'json2.js', 133 'stacktrace.js': 'stacktrace.js', 134 'swank-js.js': 'swank-js.js', 135 'load.js': 'load.js', 136 'test.html': 'test.html' 137 }; 138 139 HttpListener.prototype.types = { 140 html: "text/html; charset=utf-8", 141 js: "text/javascript; charset=utf-8" 142 }; 143 144 HttpListener.prototype.scriptBlock = 145 new Buffer( 146 '<script type="text/javascript" src="/swank-js/json2.js"></script>' + 147 '<script type="text/javascript" src="/socket.io/socket.io.js"></script>' + 148 '<script type="text/javascript" src="/swank-js/stacktrace.js"></script>' + 149 '<script type="text/javascript" src="/swank-js/swank-js.js"></script>'); 150 151 HttpListener.prototype.findClosingTag = function findClosingTag (buffer, name) { 152 // note: this function is suitable for <head> and <body> tags, 153 // because they don't contain any repeating letters, but 154 // it will not work for tags that have such letters 155 var chars = []; 156 var endChar = ">".charCodeAt(0); 157 name = "</" + name.toLowerCase(); 158 for (var i = 0; i < name.length; ++i) 159 chars.push(name.charCodeAt(i)); 160 var A_CODE = "A".charCodeAt(0), Z_CODE = "Z".charCodeAt(0), CODE_INC = "a".charCodeAt(0) - A_CODE; 161 function codeToLower (x) { 162 return x >= A_CODE && x <= Z_CODE ? x + CODE_INC : x; 163 } 164 for (i = 0; i < buffer.length - chars.length - 1;) { 165 var found = true; 166 if (buffer[i++] != chars[0]) // note: no lowercasing for matching against '<' 167 continue; 168 169 for (var j = 1; j < chars.length; ++j, ++i) { 170 if (codeToLower(buffer[i]) != chars[j]) { 171 found = false; 172 break; 173 } 174 } 175 if (found) { 176 for (var k = i; k < buffer.length; ++k) { 177 if (buffer[k] == endChar)// note: no lowercasing for matching against '>' 178 return i - chars.length; 179 } 180 } 181 } 182 return -1; 183 }; 184 185 HttpListener.prototype.injectScripts = function injectScripts (buffer, url) { 186 var p = this.findClosingTag(buffer, "head"); 187 if (p < 0) { 188 p = this.findClosingTag(buffer, "body"); 189 if (p < 0) { 190 // html blocks without head / body tags aren't that uncommon 191 // console.log("WARNING: unable to inject script block: %s", url); 192 return buffer; 193 } 194 } 195 var newBuf = new Buffer(buffer.length + this.scriptBlock.length); 196 buffer.copy(newBuf, 0, 0, p); 197 this.scriptBlock.copy(newBuf, p, 0); 198 buffer.copy(newBuf, p + this.scriptBlock.length, p); 199 return newBuf; 200 }; 201 202 HttpListener.prototype.proxyRequest = function proxyRequest (request, response) { 203 var self = this; 204 this.config.get( 205 "targetUrl", 206 function (targetUrl) { 207 self.doProxyRequest(targetUrl, request, response); 208 }); 209 }; 210 211 HttpListener.prototype.doProxyRequest = function doProxyRequest (targetUrl, request, response) { 212 var self = this; 213 var headersSent = false; 214 var done = false; 215 216 var hostname = DEFAULT_TARGET_HOST; 217 var port = DEFAULT_TARGET_PORT; 218 var parsedUrl = null; 219 try { 220 parsedUrl = url.parse(targetUrl); 221 } catch (e) {} 222 if (parsedUrl && parsedUrl.hostname) { 223 hostname = parsedUrl.hostname; 224 port = parsedUrl.port ? parsedUrl.port - 0 : 80; 225 } 226 227 request.headers["host"] = hostname + (port == 80 ? "" : ":" + port); 228 delete request.headers["accept-encoding"]; // we don't want gzipped pages, do we? 229 230 // note on http client error handling: 231 // http://rentzsch.tumblr.com/post/664884799/node-js-handling-refused-http-client-connections 232 var proxy = http.createClient(port, hostname); 233 proxy.addListener( 234 'error', function handleError (e) { 235 console.log("proxy error: %s", e); 236 if (done) 237 return; 238 if (headersSent) { 239 response.end(); 240 return; 241 } 242 response.writeHead(502, {'Content-Type': 'text/plain; charset=utf-8'}); 243 response.end("swank-js: unable to forward the request"); 244 }); 245 246 console.log("PROXY: %s %s", request.method, request.url); 247 var proxyRequest = proxy.request(request.method, request.url, request.headers); 248 249 proxyRequest.addListener( 250 'response', function (proxyResponse) { 251 var contentType = proxyResponse.headers["content-type"]; 252 var statusCode = proxyResponse.statusCode; 253 console.log("==> status %s", statusCode); 254 var headers = {}; 255 for (k in proxyResponse.headers) { 256 if (proxyResponse.headers.hasOwnProperty(k)) 257 headers[k] = proxyResponse.headers[k]; 258 } 259 var chunks = proxyResponse.statusCode == 200 && contentType && /^text\/html\b|^application\/xhtml\+xml/.test(contentType) ? 260 [] : null; 261 if (chunks === null) { 262 // FIXME: without this, there were problems with redirects. 263 // I don't quite understand why... 264 response.writeHead(statusCode, headers); 265 headersSent = true; 266 } 267 proxyResponse.addListener( 268 'data', function (chunk) { 269 if (chunks !== null) { 270 chunks.push(chunk); 271 return; 272 } 273 if (!headersSent) { 274 response.writeHead(statusCode, headers); 275 headersSent = true; 276 } 277 response.write(chunk, 'binary'); 278 }); 279 proxyResponse.addListener( 280 'end', function() { 281 if (chunks !== null) { 282 console.log("^^MOD: %s %s", request.method, request.url); 283 var buf = new Buffer(chunks.reduce(function (s, chunk) { return s += chunk.length; }, 0)); 284 var p = 0; 285 chunks.forEach( 286 function (chunk) { 287 chunk.copy(buf, p, 0); 288 p += chunk.length; 289 }); 290 buf = self.injectScripts(buf, request.url); 291 headers["content-length"] = buf.length; 292 response.writeHead(statusCode, headers); 293 headersSent = true; 294 response.write(buf, 'binary'); 295 } else if (!headersSent) { 296 response.writeHead(statusCode, headers); 297 headersSent = true; 298 } 299 300 response.end(); 301 done = true; 302 }); 303 }); 304 request.addListener( 305 'data', function(chunk) { 306 proxyRequest.write(chunk, 'binary'); 307 }); 308 request.addListener( 309 'end', function() { 310 proxyRequest.end(); 311 }); 312 }; 313 314 HttpListener.prototype.sendCachedFile = function sendCachedFile (req, res, path) { 315 if (req.headers['if-none-match'] == this.clientVersion) { 316 res.writeHead(304); 317 res.end(); 318 } else { 319 res.writeHead(200, this.cachedFiles[path].headers); 320 res.end(this.cachedFiles[path].content, this.cachedFiles[path].encoding); 321 } 322 }; 323 324 HttpListener.prototype.notFound = function notFound (res) { 325 res.writeHead(404, {'Content-Type': 'text/plain; charset=utf-8'}); 326 res.end("file not found"); 327 }; 328 329 HttpListener.prototype.serveClient = function serveClient(req, res) { 330 var self = this; 331 var path = url.parse(req.url).pathname, parts, cn; 332 // console.log("%s %s", req.method, req.url); 333 if (path && path.indexOf("/swank-js/") != 0) { 334 // console.log("--> proxy"); 335 this.proxyRequest(req, res); 336 return; 337 } 338 var file = path.substr(1).split('/').slice(1); 339 340 var localPath = this.clientFiles[file]; 341 if (req.method == 'GET' && localPath !== undefined){ 342 // TBD: reenable caching, check datetime of the file 343 // if (path in this.cachedFiles){ 344 // this.sendCachedFile(req, res, path); 345 // return; 346 // } 347 348 fs.readFile( 349 __dirname + '/client/' + localPath, function(err, data) { 350 if (err) { 351 console.log("error: %s", err); 352 self.notFound(res); 353 } else { 354 var ext = localPath.split('.').pop(); 355 self.cachedFiles[localPath] = { 356 headers: { 357 'Content-Length': data.length, 358 'Content-Type': self.types[ext], 359 'ETag': self.clientVersion 360 }, 361 content: data, 362 encoding: ext == 'swf' ? 'binary' : 'utf8' 363 }; 364 self.sendCachedFile(req, res, localPath); 365 } 366 }); 367 } else { 368 console.log("bad request for /swank-js/ path"); 369 this.notFound(res); 370 } 371 }; 372 373 var httpListener = new HttpListener(cfg); 374 var httpServer = http.createServer(httpListener.serveClient.bind(httpListener)); 375 376 httpServer.listen(8009); 377 378 var socket = io.listen(httpServer); 379 socket.on( 380 "connection", function (client) { 381 // new client is here! 382 console.log("client connected"); 383 function handleHandshake (message) { 384 client.removeListener("message", handleHandshake); 385 if (!message.hasOwnProperty("op") || !message.op == "handshake") 386 console.warn("WARNING: bad handshake message: %j", message); 387 else { 388 var address = null; 389 if (client.connection && client.connection.remoteAddress) 390 address = client.connection.remoteAddress; 391 var remote = new BrowserRemote({ address: address, userAgent: message.userAgent }, client); 392 executive.attachRemote(remote); 393 console.log("added remote: %s", remote.fullName()); 394 } 395 }; 396 client.on("message", handleHandshake); 397 }); 398 399 // TBD: handle reader errors 400 401 // function location determination: 402 // for code loaded from scripts: direct (if possible) 403 // 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 404 // for code entered via REPL: none 405 // PREPROCESS STACK TRACES!!! 406 // https://github.com/emwendelin/Javascript-Stacktrace 407 // ALSO: http://blog.yoursway.com/2009/07/3-painful-ways-to-obtain-stack-trace-in.html -- onerror in ie gives the innermost frame 408 // it should be also possible to 'soft-trace' functions so that they extend Exception objects with caller info as it passes through them 409 // TBD: unix domain sockets, normal slime startup 410 // TBD: http request logging (for specific remote) 411 // TBD: sudden disconnections (flashsocket), sometimes after lots of output (?) -- 412 // 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. 413 // TBD: autoreconnect + connection error handling 414 // ALSO: are htmlfile, jsonp-polling modes etc supposed to disconnect after each message? 415 // TBD: add SwankJS scripts to all passing html pages (into <head> or <body>) 416 // TBD: it should be possible to serve local files instead of proxying 417 // (maybe using https://github.com/felixge/node-paperboy ) 418 // TBD: handle edge case: new sticky remote connects, old sticky remote disconnects 419 // (late disconnect) - as of now, swank-js switches to node.js, but it should 420 // instead upon remote detachment see whether another remote with the same name 421 // is available 422 // TBD: handle/add X-Forwarded-For headers 423 // TBD: fix all assert calls: we need (actual, expected) not (expected, actual) 424 // TBD: invoke SwankJS.setup() only when DOM is ready (at least in IE) 425 // TBD: timeouts for browser requests