diff --git a/.gitignore b/.gitignore index 0744a13eb..af33c7795 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ addon/Profile dump.rdb app/http/public/recorder.css build +test-build togetherjs.mozillalabs.com addon/togetherjs.xpi togetherjs/togetherjs.css diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..407aee6b0 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +language: node_js +node_js: + - "0.10" +before_install: + - npm install -g npm + - npm install -g grunt-cli diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..6121a6659 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +Thanks for your interest in contributing to TogetherJS! + +If you found a bug, if at all possible give us a URL where we can try +TogetherJS. Don't worry about tags or milestones or assigning the +ticket. But a URL is extremely helpful! + +## Contributing + +The [Contributing](https://togetherjs.com/docs/contributing.html) +document on the site gives some more information. Some relevant +points: + +* [Javascript style guide](https://github.com/ianb/javascript) + +* If you want to work on a ticket, please leave a comment to that + effect. It gives us a chance to suggest where you'd look in the + code to implement the feature or fix the bug, and makes it less + likely that people's contributions will conflict. + +* Anything in the + [Blue Sky](https://github.com/mozilla/togetherjs/issues?milestone=23&page=1&state=open) + you are likely to find something that we're interested in having in + TogetherJS, but that we aren't working on. + +* If you have an idea of your own you'd like to implement, please open + a ticket describing it. That will let other people know you are + working on it, and give other people an opportunity to give feedback + or implementation notes. + +## Where to start? + +You should look at the +[Contribution Wanted](https://github.com/mozilla/togetherjs/issues?labels=contribution-wanted&milestone=&page=1&state=open) +tag to see tickets that fall into two categories: + +1. A good introductory task to get started on. + +2. Something that requires particular skills (that the core team does + not have) that would make a contribution particular valuable. diff --git a/Gruntfile.js b/Gruntfile.js index f7120118a..ef6670166 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -13,6 +13,8 @@ var vars = { base: "" }; +var TESTDIR = "test-build"; + module.exports = function (grunt) { if (! grunt.option("dest")) { @@ -20,7 +22,7 @@ module.exports = function (grunt) { } var dumpLineNumbers = false; - if (!! grunt.option("less-line-numbers")) { + if (grunt.option("less-line-numbers")) { grunt.verbose.writeln("Enabling LESS line numbers"); dumpLineNumbers = true; } @@ -57,6 +59,7 @@ module.exports = function (grunt) { }); } + var libs = []; grunt.file.expand( ["togetherjs/*.js", "!togetherjs/randomutil.js", "!togetherjs/recorder.js", "!togetherjs/togetherjs.js"] @@ -65,6 +68,12 @@ module.exports = function (grunt) { filename = filename.replace(/\.js$/, ""); libs.push(filename); }); + var langs = []; + grunt.file.expand("togetherjs/locale/*.json").forEach(function (langFilename) { + var lang = path.basename(langFilename).replace(/\.json/, ""); + langs.push(lang); + libs.push("templates-" + lang); + }); grunt.initConfig({ pkg: grunt.file.readJSON('package.json'), @@ -72,8 +81,8 @@ module.exports = function (grunt) { less: { development: { files: { - "build/togetherjs/togetherjs.css": "togetherjs/togetherjs.less", - "build/togetherjs/recorder.css": "togetherjs/recorder.less" + "<%= grunt.option('dest') || 'build' %>/togetherjs/togetherjs.css": "togetherjs/togetherjs.less", + "<%= grunt.option('dest') || 'build' %>/togetherjs/recorder.css": "togetherjs/recorder.less" }, options: { dumpLineNumbers: dumpLineNumbers @@ -85,18 +94,7 @@ module.exports = function (grunt) { compile: { options: { baseUrl: "togetherjs/", - paths: { - jquery: "libs/jquery-1.8.3.min", - walkabout: "libs/walkabout/walkabout", - esprima: "libs/walkabout/lib/esprima", - falafel: "libs/walkabout/lib/falafel", - tinycolor: "libs/tinycolor", - whrandom: "libs/whrandom/random", - jqueryui: "libs/jquery-ui.min", - jquerypunch: "libs/jquery.ui.touch-punch.min", - // Make sure we get the built form of this one: - templates: path.join("..", grunt.option("dest"), "togetherjs/templates") - }, + //paths: requirejsPaths, include: ["libs/almond"].concat(libs), //Wrap any build bundle in a start and end text specified by wrap. //Use this to encapsulate the module code so that define/require are @@ -135,7 +133,7 @@ module.exports = function (grunt) { options: { csslintrc: ".csslint.rc" }, - src: ["build/togetherjs/togetherjs.css"] + src: [path.join(grunt.option("dest"), "togetherjs/togetherjs.css")] }, watch: { @@ -150,11 +148,34 @@ module.exports = function (grunt) { files: ["togetherjs/**/*", "Gruntfile.js", "site/**/*", "!**/*_flymake*", "!**/*~", "!**/.*"], tasks: ["build", "buildsite"] }, + // FIXME: I thought I wouldn't have to watch for + // togetherjs/**/*.js, but because the hard links are regularly + // broken by git, this needs to be run often, and it's easy to + // forget. Then between git action the build will be over-run, + // but that's harmless. minimal: { - files: ["togetherjs/**/*.less", "togetherjs/togetherjs.js", "togetherjs/**/*.html", "!**/*_flymake*"], + files: ["togetherjs/**/*.less", "togetherjs/togetherjs.js", "togetherjs/templates-localized.js", + "togetherjs/**/*.html", "togetherjs/**/*.js", "!**/*_flymake*", "togetherjs/locales/**/*.json"], tasks: ["build"] } - } + }, + + 'http-server': { + 'test': { + // the server root directory + root: '.', + cache: 30, + //showDir: true, + //autoIndex: true, + // run in parallel with other tasks + runInBackground: true + } + }, + + 'phantom-tests': grunt.file.expand({ + cwd:"togetherjs/tests/" + }, "test_*.js", "func_*.js", "interactive.js", "!test_ot.js"). + reduce(function(o, k) { o[k] = {}; return o; }, {}) }); @@ -165,8 +186,38 @@ module.exports = function (grunt) { grunt.loadNpmTasks("grunt-contrib-watch"); grunt.loadNpmTasks('grunt-contrib-copy'); + grunt.registerTask("config-requirejs", function() { + // configure the requirejs paths based on the current options + var requirejsPaths = { + jquery: "libs/jquery-1.11.1.min", + walkabout: "libs/walkabout/walkabout", + esprima: "libs/walkabout/lib/esprima", + falafel: "libs/walkabout/lib/falafel", + tinycolor: "libs/tinycolor", + whrandom: "libs/whrandom/random", + jqueryui: "libs/jquery-ui.min", + jquerypunch: "libs/jquery.ui.touch-punch.min", + // Make sure we get the built form of this one: + templates: path.join("..", grunt.option("dest"), "togetherjs/templates") + }; + langs.forEach(function(lang) { + requirejsPaths["templates-" + lang] = + path.join("..", grunt.option("dest"), "togetherjs", "templates-" + lang); + }); + grunt.config.merge({ + requirejs: { + compile: { + options: { + paths: requirejsPaths + } + } + } + }); + grunt.task.run("requirejs"); + }); + grunt.registerTask("copylib", "copy the library", function () { - var pattern = ["**", "!togetherjs.js", "!templates.js", "!**/*.less", "!#*", "!**/*_flymake*", "!**/*.md"]; + var pattern = ["**", "!togetherjs.js", "!templates-localized.js", "!**/*.less", "!#*", "!**/*_flymake*", "!**/*.md", "!**/*.tmp", "!**/#*"]; grunt.log.writeln("Copying files from " + "togetherjs/".cyan + " to " + path.join(grunt.option("dest"), "togetherjs").cyan); if (grunt.option("exclude-tests")) { pattern.push("!tests/"); @@ -189,7 +240,7 @@ module.exports = function (grunt) { ["**"]); }); - grunt.registerTask("build", ["copylib", "maybeless", "substitute", "requirejs"]); + grunt.registerTask("build", ["copylib", "maybeless", "substitute", "config-requirejs"]); grunt.registerTask("buildsite", ["copysite", "render", "rendermd", "docco"]); grunt.registerTask("devwatch", ["build", "watch:minimal"]); // For some reason doing ["build", "buildsite", "watch:site"] @@ -199,7 +250,7 @@ module.exports = function (grunt) { function escapeString(s) { if (typeof s != "string") { - throw "Not a string"; + throw new Error("Not a string: " + s); } var data = JSON.stringify(s); return data.substr(1, data.length-2); @@ -207,29 +258,38 @@ module.exports = function (grunt) { grunt.registerTask( "substitute", - "Substitute templates.js and parameters in togetherjs.js", + "Substitute templates-localized.js and parameters in togetherjs.js", function () { // FIXME: I could use grunt.file.copy(..., {process: function (content, path) {}}) here - var baseUrl = grunt.option("base-url") || ""; + var baseUrl = grunt.option("base-url") || ""; // baseURL to be entered by the user if (! baseUrl) { grunt.log.writeln("No --base-url, using auto-detect"); } - var destBase = grunt.option("dest") || "build"; - var hubUrl = grunt.option("hub-url") || process.env.HUB_URL || "https://hub.togetherjs.com"; + var destBase = grunt.option("dest") || "build"; // where to put the built files. If not indicated then into build/ + var hubUrl = grunt.option("hub-url") || process.env.HUB_URL || "https://hub.togetherjs.com"; // URL of the hub server grunt.log.writeln("Using hub URL " + hubUrl.cyan); var gitCommit = process.env.GIT_COMMIT || ""; var subs = { __interface_html__: grunt.file.read("togetherjs/interface.html"), - __help_txt__: grunt.file.read("togetherjs/help.txt"), + __help_txt__: grunt.file.read("togetherjs/help.txt"), __walkthrough_html__: grunt.file.read("togetherjs/walkthrough.html"), __baseUrl__: baseUrl, __hubUrl__: hubUrl, __gitCommit__: gitCommit }; + + function substituteContent(content, s) { + for (var v in s) { + var re = new RegExp(v, "g"); + if (typeof s[v] != "string") { + grunt.log.error("Substitution variable " + v.cyan + " is not a string") + } + content = content.replace(re, escapeString(s[v])); + } + return content; + } + var filenames = { - "togetherjs/templates.js": { - src: "togetherjs/templates.js" - }, "togetherjs.js": { src: "togetherjs/togetherjs.js", extraVariables: {__min__: "no"} @@ -239,6 +299,7 @@ module.exports = function (grunt) { extraVariables: {__min__: "yes"} } }; + for (var dest in filenames) { var info = filenames[dest]; var src = info.src; @@ -246,23 +307,55 @@ module.exports = function (grunt) { dest = destBase + "/" + dest; var content = fs.readFileSync(src, "UTF-8"); var s = subs; + if (extraVariables) { s = Object.create(subs); for (var a in extraVariables) { s[a] = extraVariables[a]; } } - for (var v in s) { - var re = new RegExp(v, "g"); - content = content.replace(re, escapeString(s[v])); - } + content = substituteContent(content, s); grunt.log.writeln("writing " + src.cyan + " to " + dest.cyan); grunt.file.write(dest, content); } + + grunt.file.expand("togetherjs/locale/*.json").forEach(function (langFilename) { + var templates = grunt.file.read("togetherjs/templates-localized.js"); + var lang = path.basename(langFilename).replace(/\.json/, ""); + var translation = JSON.parse(grunt.file.read(langFilename)); + var dest = path.join(grunt.option("dest"), "togetherjs/templates-" + lang + ".js"); + + var translatedInterface = translateFile("togetherjs/interface.html", translation); + var translatedHelp = translateFile("togetherjs/help.txt", translation); + var translatedWalkthrough = translateFile("togetherjs/walkthrough.html", translation); + + var vars = subs; + + subs.__interface_html__ = translatedInterface; + subs.__help_txt__ = translatedHelp; + subs.__walkthrough_html__ = translatedWalkthrough; + subs.__names__ = translation.names; + templates = substituteContent(templates, subs); + + grunt.file.write(dest, templates); + grunt.log.writeln("writing " + dest.cyan + " based on " + langFilename.cyan); + }); + return true; } ); + + function translateFile(source, translation) { + var env = new nunjucks.Environment(new nunjucks.FileSystemLoader("./")); + var tmpl = env.getTemplate(source); + return tmpl.render({ + gettext: function (string) { + return translation[string] || string; + } + }); + } + grunt.registerTask("maybeless", "Maybe compile togetherjs.less", function () { var sources = grunt.file.expand(["togetherjs/**/*.less", "site/**/*.less"]); var found = false; @@ -310,6 +403,7 @@ module.exports = function (grunt) { if (tmplVars.absoluteLinks) { tmplVars.base = "/"; } + tmplVars.base = tmplVars.base.replace(/\\/g, '/'); var tmpl = env.getTemplate(source); var result = tmpl.render(tmplVars); grunt.file.write(dest, result); @@ -317,9 +411,9 @@ module.exports = function (grunt) { }); function parseMarkdownOutput(doc) { - var title = (/

(.*)<\/h1>/i).exec(doc); + var title = (/]*>(.*)<\/h1>/i).exec(doc); title = title[1]; - var body = doc.replace(/

.*<\/h1>/i, ""); + var body = doc.replace(/]*>.*<\/h1>/i, ""); return { title: title, body: body @@ -393,6 +487,7 @@ module.exports = function (grunt) { if (tmplVars.base && tmplVars.base.search(/\/$/) == -1) { tmplVars.base += "/"; } + tmplVars.base = tmplVars.base.replace(/\\/g, '/'); var result = tmpl.render(tmplVars); grunt.file.write(dest, result); }); @@ -426,7 +521,7 @@ module.exports = function (grunt) { var dest = grunt.option("dest") + "/source/" + source + ".html"; grunt.log.writeln("Rendering " + source.cyan + " to " + dest.cyan); var code = grunt.file.read("togetherjs/" + source); - var sections = docco.parse(source, code); + var sections = docco.parse(source, code, {languages:{}}); doccoFormat(source, sections); sections.forEach(function (section, i) { section.index = i; @@ -455,6 +550,28 @@ module.exports = function (grunt) { grunt.file.write(grunt.option("dest") + "/source/index.html", tmpl.render(tmplVars)); }); + grunt.registerTask("buildaddon", "Build the Firefox addon and move the XPI into the site", function () { + var done = this.async(); + grunt.util.spawn({ + cmd: "cfx", + args: ["xpi"], + opts: { + cwd: "addon/" + } + }, function (error, result, code) { + if (error) { + grunt.log.error("Error running cfx xpi: " + error.toString().cyan); + grunt.fail.fatal("Error creating XPI"); + done(); + return; + } + var dest = path.join(grunt.option("dest"), "togetherjs.xpi"); + grunt.file.copy("addon/togetherjs.xpi", dest); + grunt.log.writeln("Created " + dest.cyan); + done(); + }); + }); + grunt.registerTask("publish", "Publish to togetherjs.mozillalabs.com/public/", function () { if (! grunt.file.isDir("togetherjs.mozillalabs.com")) { grunt.log.writeln("Error: you must check out togetherjs.mozillalabs.com"); @@ -482,7 +599,7 @@ module.exports = function (grunt) { grunt.option("dest", "togetherjs.mozillalabs.com/public"); grunt.option("exclude-tests", true); grunt.option("no-hardlink", true); - grunt.task.run(["build", "buildsite"]); + grunt.task.run(["build", "buildsite", "buildaddon"]); grunt.task.run(["movecss"]); grunt.log.writeln("To actually publish you must do:"); grunt.log.writeln(" $ cd togetherjs.mozillalabs.com/"); @@ -531,4 +648,141 @@ module.exports = function (grunt) { }); }); + grunt.loadNpmTasks('grunt-contrib-watch'); + + grunt.registerTask('dev', function() { + grunt.util.spawn({ + cmd: 'node', + args: ['devserver.js'] + }); + grunt.task.run('watch'); + }); + + grunt.registerTask("test", "Run jshint and test suite", ["jshint", "phantom"]); + grunt.loadNpmTasks('grunt-http-server'); + + grunt.registerTask("phantom", ["phantom-setup", "phantom-tests"]); + + grunt.registerTask("phantom-setup", "Run jdoctest test suite in phantomjs", + function() { + var done = this.async(); + // find unused ports for web server and hub + var freeport = require("freeport"); + freeport(function(err1, hubPort) { + freeport(function(err2, webPort) { + if (err1 || err2) { return done(err1 || err2); } + + // build togetherjs using these default ports + grunt.option("base-url", "http://localhost:"+webPort+"/"+TESTDIR+"/"); + grunt.option("hub-url", "http://localhost:"+hubPort); + grunt.option("no-hardlink", true); + grunt.option("dest", TESTDIR); + // make sure the web server will use the right port + grunt.config.merge({ + 'http-server': { + test: { + port: webPort, + host: "localhost" + } + } + }); + // spawn a hub, using the hub port + var hub = require("./hub/server"); + hub.startServer(hubPort, "localhost"); + // build & start the web server + grunt.task.run("build", "http-server:test"); + // ok, now we can run the tests in phantomjs! + done(); + }); + }); + }); + + // PhantomJS event handlers + var phantomjs = require("grunt-lib-phantomjs").init(grunt); + var phantomStatus; + + phantomjs.on('fail.load', function(url) { + phantomjs.halt(); + grunt.verbose.write('Running PhantomJS...').or.write('...'); + grunt.log.error('PhantomJS unable to load "' + url + '" URI.'); + phantomStatus.failed += 1; + phantomStatus.total += 1; + }); + + phantomjs.on('fail.timeout', function() { + phantomjs.halt(); + grunt.log.writeln(); + grunt.log.error('PhantomJS timed out.'); + phantomStatus.failed += 1; + phantomStatus.total += 1; + }); + + phantomjs.on('doctestjs.pass', function(result) { + phantomStatus.total += 1; + grunt.verbose.ok("Passed: "+result.example.summary); + }); + + phantomjs.on('doctestjs.fail', function(result) { + phantomStatus.failed += 1; + phantomStatus.total += 1; + grunt.log.error("Failed: "+result.example.expr); + grunt.log.subhead("Expected:"); + grunt.log.writeln(result.example.expected); + grunt.log.subhead("Got:"); + grunt.log.writeln(result.got); + }); + + phantomjs.on('doctestjs.end', function() { + phantomjs.halt(); + }); + + // Pass through console.log statements (when verbose) + phantomjs.on('console', grunt.verbose.writeln); + + grunt.registerMultiTask("phantom-tests", function() { + grunt.task.requires('phantom-setup'); + var url = grunt.option('base-url') + + "togetherjs/tests/index.html?name=" + this.target; + grunt.verbose.writeln("Running tests at: "+url); + + // Merge task-specific and/or target-specific options with these defaults. + var options = this.options({ + // PhantomJS timeout, in ms. + timeout: 10000, + // JDoctest-PhantomJS bridge file to be injected. + inject: path.join(__dirname, 'phantomjs', 'bridge.js'), + //screenshot: true, + page: { + // leave room for the togetherjs sidebar + viewportSize: { width: 1024, height: 1024 } + } + }); + + // Reset test status + phantomStatus = {failed: 0, passed: 0, total: 0, start: Date.now()}; + + // Start phantomjs on this URL + var done = this.async(); + phantomjs.spawn(url, { + options: options, + done: function() { + var duration = Date.now() - phantomStatus.start; + // Log results. + if (phantomStatus.failed > 0) { + grunt.warn(phantomStatus.failed + '/' + phantomStatus.total + + ' assertions failed (' + duration + 'ms)'); + } else if (phantomStatus.total === 0) { + grunt.warn('0/0 assertions ran (' + duration + 'ms)'); + } else { + grunt.verbose.writeln(); + grunt.log.ok(phantomStatus.total + ' assertions passed (' + duration + 'ms)'); + } + // All done! + done(); + } + }); + }); + + grunt.registerTask('default', 'start'); + }; diff --git a/Procfile b/Procfile new file mode 100644 index 000000000..c7450d3ff --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: node hub/server.js diff --git a/README.md b/README.md index 10f8c921d..f89b1526d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -TogetherJS - Who you call when you get stuck -=========================================== +TogetherJS - Surprisingly easy collaboration +============================================ What is TogetherJS? ----------------- @@ -62,7 +62,11 @@ Now you can build TogetherJS, like: $ grunt build buildsite --no-hardlink ``` -This will create a copy of the entire `togetherjs.com` site in `build/`. You'll need to setup a local web server of your own pointed to the `build/` directory. +This will create a copy of the entire `togetherjs.com` site in `build/`. You'll need to setup a local web server of your own pointed to the `build/` directory. To start a server on port 8080, run: + +```sh +$ node devserver.js +``` If you want to develop with TogetherJS you probably want the files built continually. To do this use: diff --git a/addon/package.json b/addon/package.json index c34d647ea..a041c3c90 100644 --- a/addon/package.json +++ b/addon/package.json @@ -1,31 +1,32 @@ { "preferences": [ { - "description": "This is where to find the script to inject", - "type": "string", - "name": "togetherjsJs", - "value": "https://togetherjs.mozillalabs.com/togetherjs.js", - "title": "togetherjs.js location" - }, + "title": "togetherjs.js location", + "type": "string", + "description": "This is where to find the script to inject", + "value": "https://togetherjs.com/togetherjs.js", + "name": "togetherjsJs" + }, { - "description": "Override the location of the hub", - "type": "string", - "name": "hubBase", - "value": "https://hub.togetherjs.mozillalabs.com", - "title": "Hub URL" - }, + "title": "Hub URL", + "type": "string", + "description": "Override the location of the hub", + "value": "https://hub.togetherjs.com", + "name": "hubBase" + }, { - "description": "Domains on which to automatically start TogetherJS (comma-separated)", - "type": "string", - "name": "autoDomains", - "value": "", - "title": "Auto domains" + "title": "Auto domains", + "type": "string", + "description": "Domains on which to automatically start TogetherJS (comma-separated)", + "value": "", + "name": "autoDomains" } - ], - "name": "togetherjs", - "license": "MPL 2.0", - "author": "", - "version": "0.1", - "fullName": "TogetherJS", - "description": "Runs TogetherJS" + ], + "license": "MPL 2.0", + "author": "", + "description": "Runs TogetherJS", + "version": "0.1", + "fullName": "TogetherJS", + "id": "jid1-9cBzV13kcAXp8A", + "name": "togetherjs" } diff --git a/devserver.js b/devserver.js new file mode 100644 index 000000000..e30388034 --- /dev/null +++ b/devserver.js @@ -0,0 +1,38 @@ +var +http = require("http"), +url = require("url"), +path = require("path"), +fs = require("fs"), +port = process.argv[2] || process.env['PORT'] || 8080; + +http.createServer(function(request, response) { + + var uri = url.parse(request.url).pathname + var filename = path.join(process.cwd(), 'build', uri); + + fs.exists(filename, function(exists) { + if(!exists) { + response.writeHead(404, {"Content-Type": "text/plain"}); + response.write("404 Not Found\n"); + response.end(); + return; + } + + if (fs.statSync(filename).isDirectory()) filename += '/index.html'; + + fs.readFile(filename, "binary", function(err, file) { + if(err) { + response.writeHead(500, {"Content-Type": "text/plain"}); + response.write(err + "\n"); + response.end(); + return; + } + + response.writeHead(200); + response.write(file, "binary"); + response.end(); + }); + }); +}).listen(parseInt(port, 10)); + +console.log("Static file server running at\n => http://localhost:" + port + "/\nCTRL + C to shutdown"); diff --git a/hub/server.js b/hub/server.js index 60ff9aebb..58f7448f9 100644 --- a/hub/server.js +++ b/hub/server.js @@ -10,9 +10,11 @@ if ( process.env.NEW_RELIC_HOME ) { var SAMPLE_STATS_INTERVAL = 60*1000; // 1 minute var SAMPLE_LOAD_INTERVAL = 5*60*1000; // 5 minutes var EMPTY_ROOM_LOG_TIMEOUT = 3*60*1000; // 3 minutes +var WEBSOCKET_COMPAT = true; -var WebSocketServer = require('websocket').server; -var WebSocketRouter = require('websocket').router; +var WebSocketServer = WEBSOCKET_COMPAT ? + require("./websocket-compat").server : + require("websocket").server; var http = require('http'); var parseUrl = require('url').parse; var fs = require('fs'); @@ -111,7 +113,7 @@ var server = http.createServer(function(request, response) { response.end("OK " + load.connections + " connections " + load.sessions + " sessions; " + load.solo + " are single-user and " + - load.empty + " not counted because they are empty"); + (load.sessions - load.solo) + " active sessions"); } else if (url.pathname == '/server-source') { response.writeHead(200, {"Content-Type": "text/plain"}); response.end(thisSource); @@ -350,7 +352,9 @@ setInterval(function () { }, SAMPLE_STATS_INTERVAL); setInterval(function () { - logger.info("LOAD", JSON.stringify(getLoad())); + var load = getLoad(); + load.time = Date.now(); + logger.info("LOAD", JSON.stringify(load)); }, SAMPLE_LOAD_INTERVAL); function getLoad() { @@ -405,18 +409,19 @@ if (require.main == module) { .describe("port", "The port to server on (default $HUB_SERVER_PORT, $PORT, $VCAP_APP_PORT, or 8080") .describe("host", "The interface to serve on (default $HUB_SERVER_HOST, $HOST, $VCAP_APP_HOST, 127.0.0.1). Use 0.0.0.0 to make it public") .describe("log-level", "The level of logging to do, from 0 (very verbose) to 5 (nothing) (default $LOG_LEVEL or 0)") - .describe("log", "A file to log to (default stdout)") + .describe("log", "A file to log to (default $LOG_FILE or stdout)") .describe("stdout", "Log to both stdout and the log file"); var port = ops.argv.port || process.env.HUB_SERVER_PORT || process.env.VCAP_APP_PORT || process.env.PORT || 8080; var host = ops.argv.host || process.env.HUB_SERVER_HOST || process.env.VCAP_APP_HOST || process.env.HOST || '127.0.0.1'; - var logLevel = 0; - var stdout = ops.argv.stdout || !ops.argv.log; + var logLevel = process.env.LOG_LEVEL || 0; + var logFile = process.env.LOG_FILE || ops.argv.log; + var stdout = ops.argv.stdout || !logFile; if (ops.argv['log-level']) { logLevel = parseInt(ops.argv['log-level'], 10); } - logger = new Logger(logLevel, ops.argv.log, stdout); + logger = new Logger(logLevel, logFile, stdout); if (ops.argv.h || ops.argv.help) { console.log(ops.help()); process.exit(); diff --git a/hub/websocket-compat.js b/hub/websocket-compat.js new file mode 100644 index 000000000..6a85209e8 --- /dev/null +++ b/hub/websocket-compat.js @@ -0,0 +1,150 @@ +/* + * A hacked websocket module which retains compatibility with the old + * Hixie-76 version of the standard, needed for phantom JS (and, + * presumably, very old browsers). + * + * This file released into the public domain + * by C. Scott Ananian 2014-08-26 + * + * Based on https://gist.github.com/toshirot/1428579 + */ +var events = require("events"); +var util = require("util"); + +var WebSocketRequest = require('websocket').request; +var WebSocketServer = require('websocket').server; + +// Copy helpers from WebSocketServer to WebSocketRequest + +WebSocketRequest.prototype.connections = []; +WebSocketRequest.prototype.handleRequestAccepted = + WebSocketServer.prototype.handleRequestAccepted; +WebSocketRequest.prototype.handleConnectionClose = + WebSocketServer.prototype.handleConnectionClose; +WebSocketRequest.prototype.broadcastUTF = + WebSocketServer.prototype.broadcastUTF; + +var miksagoServerFactory = require('websocket-server'); +var miksagoConnection = require('../node_modules/websocket-server/lib/ws/connection'); + +var CompatWebSocketServer = function(options) { + events.EventEmitter.call(this); // superclass constructor + var self = this; + var handleConnection; + + // node-websocket-server (hixie-75 and hixie-76 support) + var miksagoServer = miksagoServerFactory.createServer(); + miksagoServer.server = options.httpServer; + miksagoServer.addListener('connection', function(connection) { + // Add remoteAddress property + connection.remoteAddress = connection._socket.remoteAddress; + + // We want to use "sendUTF" regardless of the server implementation + connection.sendUTF = connection.send; + handleConnection(connection); + }); + + // WebSocket-Node config (modern websocket support) + var wsServerConfig = { + // All options *except* 'httpServer' are required when bypassing + // WebSocketServer. + maxReceivedFrameSize: options.maxReceivedFrameSize || 0x10000, + maxReceivedMessageSize: options.maxReceivedMessageSize || 0x100000, + fragmentOutgoingMessages: true, + fragmentationThreshold: 0x4000, + keepalive: true, + keepaliveInterval: 20000, + assembleFragments: true, + // autoAcceptConnections is not applicable when bypassing WebSocketServer + // autoAcceptConnections: false, + disableNagleAlgorithm: true, + closeTimeout: 5000 + }; + + // Handle the upgrade event ourselves instead of using WebSocketServer + var wsRequest={}; + options.httpServer.on('upgrade', function(req, socket, head) { + if (typeof req.headers['sec-websocket-version'] !== 'undefined') { + + // WebSocket hybi-08/-09/-10 connection (WebSocket-Node) + wsRequest = new WebSocketRequest(socket, req, wsServerConfig); + try { + wsRequest.readHandshake(); + } catch (e) { + wsRequest.reject( + e.httpCode ? e.httpCode : 400, + e.message, + e.headers + ); + return; + } + wsRequest.once('requestAccepted', function(connection) { + wsRequest.handleRequestAccepted(connection); + }); + self.emit('request', wsRequest); + + } else { + + // WebSocket hixie-75/-76/hybi-00 connection (node-websocket-server) + if (req.method === 'GET' && + (req.headers.upgrade && req.headers.connection) && + req.headers.upgrade.toLowerCase() === 'websocket' && + req.headers.connection.toLowerCase() === 'upgrade') { + new miksagoConnection( + miksagoServer.manager, miksagoServer.options, req, socket, head + ); + } + } + }); + + // A connection handler for old-style websockets + handleConnection = function(connection) { + // fake a request + self.emit('request', new CompatRequest(self, connection)); + }; +}; +util.inherits(CompatWebSocketServer, events.EventEmitter); + +var CompatRequest = function(server, connection) { + this._server = server; + this._connection = connection; + this.origin = connection._options.origin || '*'; + this.httpRequest = connection._req; + // create wrapper right away in order to install event handlers promptly + this._connectionWrapper = new CompatConnection(server, connection); +}; +CompatRequest.prototype.reject = function(code, message) { + this._connection.reject(message || "no reason"); +}; +CompatRequest.prototype.accept = function(proto, origin) { + // this is faked: we've already accepted the connection + return this._connectionWrapper; +}; + +var CompatConnection = function(server, connection) { + var self = this; + events.EventEmitter.call(this); // superclass constructor + + this._server = server; + this._connection = connection; + this.remoteAddress = connection.remoteAddress; + + connection.addListener('message', function(wsMessage) { + // make the argument compatible with WebSocket-Node + self.emit('message', { + type: 'utf8', + utf8Data: wsMessage + }); + }); + + connection.addListener('close', function() { + self.emit('close'); + }); +}; +util.inherits(CompatConnection, events.EventEmitter); + +CompatConnection.prototype.sendUTF = function(message) { + return this._connection.sendUTF(message); +}; + +module.exports.server = CompatWebSocketServer; diff --git a/package.json b/package.json index a7d9e2815..6de95d71c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "less": "~1.3.1", "node-static": "~0.6.5", "websocket": "~1.0.7", + "websocket-server": "miksago/node-websocket-server#master", "express": "~3.0.6", "winston": "~0.6.2", "habitat": "~0.4.0", @@ -20,7 +21,8 @@ "universal-analytics": "~0.1.3", "less-middleware": "~0.1.9", "newrelic": "0.9.20", - "grunt-amd-check": "~0.5.1" + "grunt-amd-check": "~0.5.1", + "optimist": "~0.6.0" }, "devDependencies": { "grunt-contrib-less": "~0.5.1", @@ -30,17 +32,21 @@ "grunt-contrib-watch": "~0.4.3", "grunt": "~0.4.1", "grunt-contrib-copy": "~0.4.1", + "grunt-http-server": "~0.0.5", "nunjucks": "~0.1.8a", "marked": "~0.2.9", "docco": "~0.6.2", "highlight.js": "~7.3.0", - "optimist": "~0.6.0" + "optimist": "~0.6.0", + "freeport": "~1.0.3", + "grunt-lib-phantomjs": "~0.6.0" }, "engines": { "node": "~0.8", "npm": "~1.1.18" }, "scripts": { - "start": "node hub/server.js" + "start": "node hub/server.js", + "test": "grunt test" } } diff --git a/phantomjs/bridge.js b/phantomjs/bridge.js new file mode 100644 index 000000000..a4af2a618 --- /dev/null +++ b/phantomjs/bridge.js @@ -0,0 +1,82 @@ +(function (doctest) { + 'use strict'; + + // Function.bind is not defined in phantomjs (!) so polyfill it + if (!Function.prototype.bind) { + Function.prototype.bind = function (oThis) { + if (typeof this !== "function") { + // closest thing possible to the ECMAScript 5 + // internal IsCallable function + throw new TypeError("can't bind"); + } + + var aArgs = Array.prototype.slice.call(arguments, 1), + fToBind = this, + fNOP = function () {}, + fBound = function () { + return fToBind.apply(this instanceof fNOP && oThis + ? this + : oThis, + aArgs.concat(Array.prototype.slice.call(arguments))); + }; + + fNOP.prototype = this.prototype; + fBound.prototype = new fNOP(); + + return fBound; + }; + } + + // default phantomjs background is transparent + document.body.bgColor = 'white'; + + // Send messages to the parent PhantomJS process via alert! Good times!! + function sendMessage() { + var args = [].slice.call(arguments); + alert(JSON.stringify(args)); + } + + // doctestjs-specific stuff. First, be sure we don't autostart: + document.body.className = document.body.className.replace(/autodoctest/,''); + + // Now define a custom reporter which will pass the results up to grunt + var PhantomReporter = function(runner) { + this.runner = runner; + }; + PhantomReporter.prototype.logSuccess = function(example, got) { + this._send('doctestjs.pass', example, got); + }; + PhantomReporter.prototype.logFailure = function(example, got) { + this._send('doctestjs.fail', example, got); + }; + PhantomReporter.prototype._send = function(msg, example, got) { + sendMessage(msg, { + example: { + expr: example.expr, + summary: example.textSummary(), + expected: example.expected + }, + got: got + }); + }; + + // Start/finish the doctest runner. + window.doctestReporterHook = { + finish: function() { + sendMessage('doctestjs.end'); + } + }; + window.addEventListener('load', function() { + var runner = new doctest.Runner({ + Reporter: PhantomReporter + }); + var parser = new doctest.HTMLParser(runner); + parser.loadRemotes(function() { + runner.init(); + parser.parse(); + sendMessage('doctestjs.start'); + runner.run(); + }); + }); + +})(window.doctest); diff --git a/site/base.tmpl b/site/base.tmpl index 1347112ee..77695e2ed 100644 --- a/site/base.tmpl +++ b/site/base.tmpl @@ -12,6 +12,9 @@ + + {% block configs %}{% endblock %} + @@ -72,7 +75,7 @@ -