├── .npmignore ├── .gitignore ├── Makefile ├── package.json ├── Readme.md ├── client.js ├── History.md ├── browsers.json └── repl.js /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | static/build.js 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | # apps 3 | NODE ?= node 4 | NPM ?= npm 5 | BROWSERIFY ?= $(NODE) ./node_modules/.bin/browserify 6 | 7 | build: static/build.js 8 | 9 | node_modules: package.json 10 | $(NPM) install 11 | 12 | static/build.js: client.js node_modules 13 | mkdir -p static 14 | $(BROWSERIFY) $< > $@ 15 | 16 | clean: 17 | rm static/build.js 18 | 19 | .PHONY: install build clean 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser-repl", 3 | "version": "0.4.0", 4 | "description": "CLI utility to set up a remote browser repl", 5 | "dependencies": { 6 | "array-map": "0.0.0", 7 | "express": "3.4.8", 8 | "foreach": "2.0.4", 9 | "minimist": "0.0.7", 10 | "ngrok": "0.1.99", 11 | "socket.io": "1.3.5", 12 | "socket.io-client": "1.3.5", 13 | "to-array": "0.1.4", 14 | "util-inspect": "0.1.8", 15 | "wd": "0.3.11" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git://github.com/Automattic/browser-repl.git" 20 | }, 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/Automattic/browser-repl/issues" 24 | }, 25 | "bin": { 26 | "repl": "./repl.js" 27 | }, 28 | "scripts": { 29 | "prepublish": "make build" 30 | }, 31 | "devDependencies": { 32 | "browserify": "8.1.3" 33 | } 34 | } -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | # browser-repl 3 | 4 | CLI utility to set up a remote browser repl. 5 | 6 | ![](https://i.cloudup.com/M9JGEk9wS0.gif) 7 | 8 | ## How to use 9 | 10 | ```js 11 | $ npm install -g browser-repl 12 | $ export SAUCE_USERNAME="your username" 13 | $ export SAUCE_ACCESS_KEY="your key" 14 | $ repl ie6 15 | ``` 16 | 17 | Sign up for a free OSS account on [SauceLabs](http://saucelabs.com). 18 | 19 | ## How it works 20 | 21 | `browser-repl` is built on top of the `wd` module, which is an 22 | implementation of the webdriver protocol. 23 | 24 | Once a browser session is established, 25 | [socket.io](http://github.com/learnboost/socket.io) is used to establish 26 | a persistent connection that works on all browsers as fast as possible. 27 | 28 | The socket.io server is hosted locally, and a reverse tunnel is set up 29 | with [localtunnel](https://github.com/defunctzombie/localtunnel) 30 | which gives your computer a temporary URL of the format 31 | `https://{uid}.localtunnel.me`. 32 | 33 | The lines you enter are subsequently `eval`d. 34 | A global `window.onerror` hook is also set to capture errors. 35 | Summoning `repl` with the `-n` argument disables this. 36 | 37 | ## Contributors 38 | 39 | - [Nathan Rajlich](https://github.com/tootallnate) 40 | - [Guillermo Rauch](https://github.com/guille) 41 | 42 | ## License 43 | 44 | MIT - Copyright © 2014 Automattic, Inc. 45 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | var io = require('socket.io-client'); 2 | var map = require('array-map'); 3 | var each = require('foreach'); 4 | var toArray = require('to-array'); 5 | var inspect = require('util-inspect'); 6 | var socket = io(); 7 | 8 | // make `console` remote 9 | if (!global.options.k) { 10 | global.console = {}; 11 | each(['log', 'info', 'warn', 'error', 'debug'], function(m){ 12 | global.console[m] = function(){ 13 | var args = toArray(arguments); 14 | socket.emit('console', m, map(args, function(a){ 15 | return inspect(a, { colors: true }); 16 | })); 17 | }; 18 | }); 19 | } 20 | 21 | socket.on('run', function(js, fn){ 22 | try { 23 | // eval in the global scope (http://stackoverflow.com/a/5776496/376773) 24 | var rtn = (function() { return eval.apply(this, arguments); })(js); 25 | 26 | // save the previous value as `_`. matches node's main REPL behavior 27 | global._ = rtn; 28 | 29 | fn(null, inspect(rtn, { colors: true })); 30 | } catch(e) { 31 | // we have to create a "flattened" version of the `e` Error object, 32 | // for JSON serialization purposes 33 | var err = {}; 34 | for (var i in e) err[i] = e[i]; 35 | err.message = e.message; 36 | err.stack = e.stack; 37 | // String() is needed here apparently for IE6-8 which throw an error deep in 38 | // socket.io that is hard to debug through SauceLabs remotely. For some 39 | // reason, toString() here bypasses the bug... 40 | err.name = String(e.name); 41 | fn(err); 42 | } 43 | }); 44 | 45 | window.onerror = function(message, url, linenumber){ 46 | socket.emit('global err', message, url, linenumber); 47 | }; 48 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 2 | 0.4.0 / 2015-05-24 3 | ================== 4 | 5 | * repl: add a 'connected to' log with browser version. 6 | * repl: feature-detect the ansi readline bugfix. 7 | * repl: verbose listing of available browsers. color prompt. 8 | * browsers: update all to latest. explicit versions. fix OSX 9 | * browsers: add support for Android. 10 | * repl: support for android devices. 11 | * package: wd to 0.3 12 | * package: bump ngrok & socket.io 13 | * Makefile: fix `install` rule 14 | * ensure install prior to publish 15 | 16 | 0.3.2 / 2015-02-02 17 | ================== 18 | 19 | * repl: replace localtunnel with ngrok 20 | * package: bump socket.io and add ngrok 21 | 22 | 0.3.1 / 2015-02-02 23 | ================== 24 | 25 | * Makefile: ensure `static` dir is created 26 | * package: add "description" field 27 | * package: add "repository" and "license" fields 28 | * package: remove unused "debug" dependency 29 | * package: update "browserify" to v8.1.3 30 | * package: use npm's versions of "socket.io" and "socket.io-client" 31 | 32 | 0.3.0 / 2014-04-08 33 | ================== 34 | 35 | * repl: add HTML5 doctype tag 36 | * client, repl: use the correct window.onerror args 37 | * client: emit on the `socket`, not `io` 38 | * Readme: better gif 39 | 40 | 0.2.1 / 2014-02-17 41 | ================== 42 | 43 | * bump 44 | 45 | 0.2.0 / 2014-02-17 46 | ================== 47 | 48 | * repl: added sauce heartbeats 49 | * client: save the previous expression result as `_` 50 | * client: properly serialize the `message` and `stack` 51 | * repl: prepend the "name" and "message" to stack 52 | * repl: parse more syntax errors from more browsers 53 | * client, repl: better "error" handling 54 | * client: eval in the global scope 55 | * browsers: fix ie10 56 | * fix ie9, fix prompt, fix not specifying version 57 | * improve browserify build 58 | * client: remove redundant `color` option 59 | * Makefile: add a `build` rule 60 | * repl: don't use ansi escape codes in prompt 61 | 62 | 0.1.0 / 2014-02-13 63 | ================== 64 | 65 | * first release 66 | -------------------------------------------------------------------------------- /browsers.json: -------------------------------------------------------------------------------- 1 | { 2 | "browsers": { 3 | 4 | "ie": { "name": "internet explorer", "platform": "win8.1" }, 5 | "ie6": { "name": "internet explorer", "platform": "winxp" }, 6 | "ie7": { "name": "internet explorer", "platform": "winxp" }, 7 | "ie8": { "name": "internet explorer", "platform": "win7" }, 8 | "ie9": { "name": "internet explorer", "platform": "win7" }, 9 | "ie10": { "name": "internet explorer", "platform": "win8" }, 10 | "ie11": { "name": "internet explorer", "platform": "win8" , "version": "11" }, 11 | 12 | "opera": { "name": "opera", "platform": "win7" }, 13 | 14 | "safari": { "name": "safari", "platform": "mac10.10", "version": "8" }, 15 | "safari5": { "name": "safari", "platform": "mac10.6", "version": "5.1" }, 16 | "safari6": { "name": "safari", "platform": "mac10.8", "version": "6" }, 17 | "safari7": { "name": "safari", "platform": "mac10.9", "version": "7" }, 18 | "safari8": { "name": "safari", "platform": "mac10.9", "version": "8" }, 19 | 20 | "chrome": { "name": "chrome", "platform": "win8.1" }, 21 | "chromedev": { "name": "chrome", "platform": "win8.1", "version": "dev" }, 22 | 23 | "firefox": { "name": "firefox", "platform": "linux" }, 24 | "firefoxdev": { "name": "firefox", "platform": "linux", "version": "dev" }, 25 | 26 | "ipad": { "name": "ipad", "platform": "mac10.10", "version": "8" }, 27 | "ipad4": { "name": "ipad", "platform": "mac10.6" }, 28 | "ipad5": { "name": "ipad", "platform": "mac10.6" }, 29 | "ipad5.1": { "name": "ipad", "platform": "mac10.6" }, 30 | "ipad6": { "name": "ipad", "platform": "mac10.8" }, 31 | "ipad6.1": { "name": "ipad", "platform": "mac10.8" }, 32 | "iphone": { "name": "iphone", "platform": "mac10.10", "version": "8" }, 33 | "iphone4": { "name": "iphone", "platform": "mac10.6" }, 34 | "iphone5": { "name": "iphone", "platform": "mac10.6" }, 35 | "iphone5.1": { "name": "iphone", "platform": "mac10.6" }, 36 | "iphone6": { "name": "iphone", "platform": "mac10.8" }, 37 | "iphone6.1": { "name": "iphone", "platform": "mac10.8" }, 38 | 39 | "android": { "name": "android", "platform": "linux", "version": "5" }, 40 | "android4.4": { "name": "android", "platform": "linux", "version": "4.4" }, 41 | "android4.2": { "name": "android", "platform": "linux", "version": "4.2" }, 42 | "android4.1": { "name": "android", "platform": "linux", "version": "4.1" } 43 | 44 | }, 45 | 46 | "platforms": { 47 | "winxp": "Windows XP", 48 | "win7": "Windows 7", 49 | "win8": "Windows 8", 50 | "win8.1": "Windows 8.1", 51 | "mac10.6": "OS X 10.6", 52 | "mac10.8": "OS X 10.8", 53 | "mac10.9": "OS X 10.9", 54 | "mac10.10": "OS X 10.10", 55 | "linux": "Linux", 56 | "android": "Linux" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /repl.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | if (!process.stdout.isTTY) { 4 | console.error('Must run in a TTY'); 5 | process.exit(1); 6 | } 7 | 8 | if (!process.env.SAUCE_ACCESS_KEY || !process.env.SAUCE_USERNAME) { 9 | console.error('Please configure $SAUCE_ACCESS_KEY and $SAUCE_USERNAME in your shell'); 10 | console.error('Sign up at saucelabs.com'); 11 | process.exit(1); 12 | } 13 | 14 | var wd = require('wd'); 15 | var env = process.env; 16 | var repl = require('repl'); 17 | var args = process.argv.slice(2); 18 | var argv = require('minimist')(args); 19 | var sio = require('socket.io'); 20 | var ngrok = require('ngrok').connect; 21 | var join = require('path').join; 22 | var http = require('http').Server; 23 | var express = require('express'); 24 | 25 | // config 26 | var config = require('./browsers'); 27 | var browsers = config.browsers; 28 | var platforms = config.platforms; 29 | 30 | // parse args 31 | if (2 == argv._.length) platform = argv._.pop(); 32 | var str = argv._.join(''); 33 | var parts = str.match(/([a-z]+) *(\d+(\.\d+)?)?/); 34 | if (!parts) return usage(); 35 | 36 | // locate browser 37 | var browser = browsers[str] || browsers[parts[1]]; 38 | if (!browser) return usage(); 39 | var version = parts[2] || browser.version; 40 | var platform = platforms[platform || browser.platform]; 41 | 42 | 43 | // app 44 | var app = express(); 45 | var srv = http(app); 46 | app.get('/', function(req, res){ 47 | res.send([ 48 | '', 49 | '', 50 | '' 51 | ].join('\n')); 52 | }); 53 | app.use(express.static(join(__dirname, 'static'))); 54 | 55 | var io = sio(srv); 56 | var socket; 57 | 58 | setup(); 59 | 60 | function setup(){ 61 | console.log('… setting up tunnel'); 62 | srv.listen(function(){ 63 | ngrok(srv.address().port, function(err, url){ 64 | if (err) { 65 | console.error('… error setting up reverse tunnel'); 66 | console.error(err.stack); 67 | return; 68 | } 69 | 70 | console.log('… booting up \033[96m' 71 | + browser.name + '\033[39m (' + (version || 'latest') 72 | + ') on ' + platform); 73 | spawn(url); 74 | }); 75 | // let `error` throw 76 | }); 77 | } 78 | 79 | function spawn(url){ 80 | var user = env.SAUCE_USERNAME; 81 | var key = env.SAUCE_ACCESS_KEY; 82 | var vm = wd.remote('ondemand.saucelabs.com', 80, user, key); 83 | 84 | var isAndroid = browser.name == "android"; 85 | var isiPhone = /^ip(hone|ad)$/.test(browser.name); 86 | 87 | var opts = { 88 | browserName: browser.name, 89 | platform : platform, 90 | version : version ? version : undefined, 91 | deviceName : isAndroid ? "Android Emulator" : isiPhone ? "iPhone Simulator" : undefined, 92 | 'device-orientation' : isAndroid || isiPhone ? 'portrait' : undefined, 93 | 'record-video' : false, 94 | 'record-screenshots' : false, 95 | }; 96 | 97 | vm.init(opts, function(err, sessionid, client){ 98 | if (err) throw err; 99 | if (client) console.log('… connected to', client.browserName, client.version); 100 | vm.get(url, function(err){ 101 | if (err) throw err; 102 | 103 | // set up a heartbeat to keep session alive 104 | setInterval(function(){ 105 | vm.eval('', function(err){ 106 | if (err) throw err; 107 | }); 108 | }, 30000); 109 | 110 | // socket io `connection` should fire now 111 | }); 112 | }); 113 | 114 | io.on('connection', function(s){ 115 | socket = s; 116 | socket.on('disconnect', function(){ 117 | console.log('socket disconnected'); 118 | process.exit(1); 119 | }); 120 | start(); 121 | }); 122 | } 123 | 124 | function usage(){ 125 | console.error(''); 126 | console.error('usage: repl [version] [platform]'); 127 | console.error(''); 128 | console.error('options:'); 129 | console.error(' -h: this message'); 130 | console.error(' -k: no remote `console` override'); 131 | console.error(''); 132 | console.error('examples:'); 133 | console.error(' $ repl ie6 # ie 6'); 134 | console.error(' $ repl chrome # chrome latest'); 135 | console.error(''); 136 | console.error('available browsers: '); 137 | 138 | var browsernames = {}; 139 | Object.keys(browsers).map(function(k){ return browsers[k] }).forEach(function(k){ browsernames[k.name] = true; }); 140 | 141 | Object.keys(browsernames).forEach(function(name){ 142 | console.error( 143 | ' ' + name + ': ', 144 | Object.keys(browsers).filter(function(val){ return browsers[val].name == name }).join(' ') 145 | ); 146 | }); 147 | 148 | console.error('\navailable platforms: \n ' + Object.keys(platforms).join(' ')); 149 | console.error(''); 150 | process.exit(1); 151 | } 152 | 153 | function start(){ 154 | console.log('… ready!'); 155 | var isAnsiReadlineOK = 'stripVTControlCharacters' in require('readline'); 156 | 157 | var cmd = repl.start({ 158 | prompt: isAnsiReadlineOK ? '\u001b[96m' + str + ' › \u001b[39m' : str + ' › ', 159 | eval: function(cmd, ctx, file, fn){ 160 | socket.emit('run', cmd, function(err, data){ 161 | if (err) { 162 | // we have to create a synthetic SyntaxError if one occurred in the 163 | // browser because the REPL special-cases that error 164 | // to display the "more" prompt 165 | if ( 166 | // most browsers set the `name` to "SyntaxError" 167 | ('SyntaxError' == err.name && 168 | // firefox 169 | ('syntax error' == err.message || 170 | 'function statement requires a name' == err.message || 171 | // iOS 172 | 'Parse error' == err.message || 173 | // opera 174 | /syntax error$/.test(err.message) || 175 | /expected (.*), got (.*)$/.test(err.message) || 176 | // safari 177 | /^Unexpected token (.*)$/.test(err.message) 178 | ) 179 | ) || 180 | // old IE doens't even have a "name" property :\ 181 | ('Syntax error' == err.message || /^expected /i.test(err.message)) 182 | ) { 183 | err = new SyntaxError('Unexpected end of input'); 184 | } else { 185 | // any other `err` needs to be converted to an `Error` object 186 | // with the given `err`s properties copied over 187 | var e = new Error(); 188 | 189 | // force an empty stack trace on the server-side... in the case where 190 | // the client-side didn't send us a `stack` property (old IE, safari), 191 | // it's confusing to see a server-side stack trace. 192 | e.stack = ''; 193 | 194 | for (var i in err) { 195 | e[i] = err[i]; 196 | } 197 | 198 | // firefox and opera, in particular, doesn't include the "name" 199 | // or "message" in the stack trace 200 | var prefix = e.name; 201 | if (e.message) prefix += ': ' + e.message; 202 | if (e.stack.substring(0, prefix.length) != prefix) { 203 | e.stack = prefix + '\n' + e.stack; 204 | } 205 | 206 | err = e; 207 | } 208 | } 209 | // We're intentionally passing the successful "data" response as the 210 | // `err` argument to the eval function. This is because the `data` is 211 | // actually a properly formatted String output from `util.inspect()` run 212 | // on the client-side, with proper coloring, etc. coincidentally, if we 213 | // pass that as the `err` argument then node's `repl` module will simply 214 | // console.log() the formatted string for us, which is what we want 215 | fn(err || data); 216 | }); 217 | } 218 | }); 219 | 220 | socket.on('global err', function(message, url, linenumber){ 221 | console.log('Global error: ', message, url, linenumber); 222 | }); 223 | 224 | socket.on('console', function(method, args){ 225 | console[method].apply(console, args); 226 | }); 227 | 228 | cmd.on('exit', function(){ 229 | process.exit(0); 230 | }); 231 | } 232 | --------------------------------------------------------------------------------