/ emacs.d / swank-js / swank.js
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