From 2fcac1336c902850070f44709d2f15af3f2ab784 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Sun, 16 Jun 2013 18:35:55 -0400 Subject: [PATCH 001/247] add debug module to deps --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index ef07564..eade7a9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "request": "2.11.4", "book": "1.2.0", "optimist": "0.3.4", - "http-raw": "1.1.0" + "http-raw": "1.1.0", + "debug": "0.7.2" }, "devDependencies": { "mocha": "1.6.0" -- 2.54.0 From a6ec239a27659e135827f0bc4bb68d9b14442600 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Sun, 16 Jun 2013 18:46:29 -0400 Subject: [PATCH 002/247] remove client components * devDep localtunnel module for client * update readme for server info --- README.md | 53 ++----------------- bin/client | 34 ------------ client.js | 142 -------------------------------------------------- index.js | 2 - package.json | 20 +++---- test/basic.js | 4 +- test/queue.js | 4 +- 7 files changed, 15 insertions(+), 244 deletions(-) delete mode 100755 bin/client delete mode 100644 client.js delete mode 100644 index.js diff --git a/README.md b/README.md index 718a765..713794c 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,10 @@ -# localtunnel [![Build Status](https://secure.travis-ci.org/shtylman/localtunnel.png)](http://travis-ci.org/shtylman/localtunnel) # +# localtunnel-server [![Build Status](https://secure.travis-ci.org/shtylman/localtunnel-server.png)](http://travis-ci.org/shtylman/localtunnel-serer) # localtunnel exposes your localhost to the world for easy testing and sharing! No need to mess with DNS or deploy just to have others test out your changes. -Great for working with browser testing tools like browserling or external api callback services like twilio which require a public url for callbacks. +This repo is the server component. If you are just looking for the CLI localtunnel app, see (https://github.com/shtylman/localtunnel) -## installation ## - -``` -npm install -g localtunnel -``` - -This will install the localtunnel module globally and add the 'lt' client cli tool to your PATH. - -## use ## - -Super Easy! Assuming your local server is running on port 8000, just use the ```lt``` command to start the tunnel. - -``` -lt --port 8000 -``` - -Thats it! It will connect to the tunnel server, setup the tunnel, and tell you what url to use for your testing. This url will remain active for the duration of your session; so feel free to share it with others for happy fun time! - -You can restart your local server all you want, ```lt``` is smart enough to detect this and reconnect once it is back. - -### custom server +## overview ## The default localtunnel client connects to the ```localtunnel.me``` server. You can however easily setup and run your own server. In order to run your own localtunnel server you must ensure that your server can meet the following requirements: @@ -37,7 +17,7 @@ The above are important as the client will ask the server for a subdomain under ```shell // pick a place where the files will live -git clone git://github.com/shtylman/localtunnel.git +git clone git://github.com/shtylman/localtunnel-server.git cd localtunnel npm install @@ -57,28 +37,3 @@ lt --host http://sub.example.tld:1234 --port 9000 You will be assigned a url similar to ```qdci.sub.example.com:1234``` If your server is being a reverse proxy (i.e. nginx) and is able to listen on port 80, then you do not need the ```:1234``` part of the hostname for the ```lt``` client - -## API ## - -The localtunnel client is also usable through an API (test integration, automation, etc) - -```javascript -var lt_client = require('localtunnel').client; - -var client = lt_client.connect({ - // the localtunnel server - host: 'http://localtunnel.me', - // your local application port - port: 12345 -}); - -// when your are assigned a url -client.on('url', function(url) { - // you can now make http requests to the url - // they will be proxied to your local server on port [12345] -}); - -client.on('error', function(err) { - // uh oh! -}); -``` diff --git a/bin/client b/bin/client deleted file mode 100755 index 4b23867..0000000 --- a/bin/client +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env node -var lt_client = require(__dirname + '/../client'); - -var argv = require('optimist') - .usage('Usage: $0 --port [num]') - .demand(['port']) - .options('host', { - default: 'http://localtunnel.me', - describe: 'upstream server providing forwarding' - }) - .options('subdomain', { - describe: 'request this subdomain' - }) - .describe('port', 'internal http server port') - .argv; - -var opt = { - host: argv.host, - port: argv.port, - subdomain: argv.subdomain, -} - -var client = lt_client.connect(opt); - -// only emitted when the url changes -client.on('url', function(url) { - console.log('your url is: %s', url); -}); - -client.on('error', function(err) { - console.error(err); -}); - -// vim: ft=javascript diff --git a/client.js b/client.js deleted file mode 100644 index bb2f25b..0000000 --- a/client.js +++ /dev/null @@ -1,142 +0,0 @@ -// builtin -var net = require('net'); -var url = require('url'); -var request = require('request'); -var EventEmitter = require('events').EventEmitter; - -// request upstream url and connection info -var request_url = function(params, cb) { - request(params, function(err, res, body) { - if (err) { - cb(err); - } - - cb(null, body); - }); -}; - -var connect = function(opt) { - var ev = new EventEmitter(); - - // local port - var local_port = opt.port; - - var base_uri = opt.host + '/'; - - // optionally override the upstream server - var upstream = url.parse(opt.host); - - // no subdomain at first, maybe use requested domain - var assigned_domain = opt.subdomain; - - // connect to upstream given connection parameters - var tunnel = function (remote_host, remote_port) { - - var remote_opt = { - host: remote_host, - port: remote_port - }; - - var local_opt = { - host: 'localhost', - port: local_port - }; - - var remote_attempts = 0; - - (function conn(conn_had_error) { - if (conn_had_error) { - return; - } - - if (++remote_attempts >= 3) { - console.error('localtunnel server offline - try again'); - process.exit(-1); - } - - // connection to localtunnel server - var remote = net.connect(remote_opt); - - remote.once('error', function(err) { - if (err.code !== 'ECONNREFUSED') { - remote.emit('error', err); - } - - // retrying connection to local server - setTimeout(conn, 1000); - }); - - function recon_local() { - remote.pause(); - remote_attempts = 0; - - // connection to local http server - var local = net.connect(local_opt); - - local.once('error', function(err) { - if (err.code !== 'ECONNREFUSED') { - local.emit('error', err); - } - - // retrying connection to local server - setTimeout(recon_local, 1000); - }); - - local.once('connect', function() { - remote.resume(); - remote.pipe(local).pipe(remote, {end: false}); - }); - - local.once('close', function(had_error) { - if (had_error) { - return; - } - recon_local(); - }); - } - - remote.once('close', conn); - remote.once('connect', recon_local); - })(); - }; - - var params = { - path: '/', - json: true - }; - - // where to quest - params.uri = base_uri + ((assigned_domain) ? assigned_domain : '?new'); - - // get an id from lt server and setup forwarding tcp connections - request_url(params, function(err, body) { - - if (err) { - ev.emit('error', new Error('tunnel server not available: %s, retry 1s', err.message)); - - // retry interval for id request - return setTimeout(function() { - connect_proxy(opt); - }, 1000); - } - - // our assigned hostname and tcp port - var port = body.port; - var host = upstream.hostname; - - // store the id so we can try to get the same one - assigned_domain = body.id; - - var max_conn = body.max_conn_count || 1; - for (var count = 0 ; count < max_conn ; ++count) { - tunnel(host, port); - } - - ev.emit('url', body.url); - }); - - return ev; -}; - -module.exports.connect = connect; - diff --git a/index.js b/index.js deleted file mode 100644 index 4d51bbc..0000000 --- a/index.js +++ /dev/null @@ -1,2 +0,0 @@ -module.exports.client = require('./client'); -module.exports.server = require('./server'); diff --git a/package.json b/package.json index ef07564..5eff76b 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,25 @@ { "author": "Roman Shtylman ", - "name": "localtunnel", + "name": "localtunnel-server", "description": "expose localhost to the world", "version": "0.0.4", "repository": { "type": "git", - "url": "git://github.com/shtylman/localtunnel.git" + "url": "git://github.com/shtylman/localtunnel-server.git" }, "dependencies": { "request": "2.11.4", "book": "1.2.0", "optimist": "0.3.4", - "http-raw": "1.1.0" + "http-raw": "1.1.0", + "debug": "0.7.2" }, "devDependencies": { - "mocha": "1.6.0" - }, - "optionalDependencies": {}, - "engines": { - "node": "*" + "mocha": "1.6.0", + "localtunnel": "0.0.4" }, "scripts": { "test": "mocha --ui qunit -- test", "start": "./bin/server" - }, - "bin": { - "lt": "./bin/client" - }, - "main": "./index.js" + } } diff --git a/test/basic.js b/test/basic.js index 34434e3..f4a4073 100644 --- a/test/basic.js +++ b/test/basic.js @@ -2,8 +2,8 @@ var http = require('http'); var url = require('url'); var assert = require('assert'); -var localtunnel_server = require('../').server(); -var localtunnel_client = require('../').client; +var localtunnel_server = require('../server')(); +var localtunnel_client = require('localtunnel').client; test('setup localtunnel server', function(done) { localtunnel_server.listen(3000, function() { diff --git a/test/queue.js b/test/queue.js index c6268b1..844f542 100644 --- a/test/queue.js +++ b/test/queue.js @@ -2,11 +2,11 @@ var http = require('http'); var url = require('url'); var assert = require('assert'); -var localtunnel_server = require('../').server({ +var localtunnel_server = require('../server')({ max_tcp_sockets: 1 }); -var localtunnel_client = require('../').client; +var localtunnel_client = require('localtunnel').client; var server; -- 2.54.0 From e6971e9afe47730ca0c179e5dac4896dcb569077 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Sun, 16 Jun 2013 18:50:04 -0400 Subject: [PATCH 003/247] use bookrc for logging --- bin/server | 3 +-- bookrc.js | 13 +++++++++++++ package.json | 5 ++++- server.js | 2 +- 4 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 bookrc.js diff --git a/bin/server b/bin/server index 90c72b1..547a193 100755 --- a/bin/server +++ b/bin/server @@ -1,7 +1,6 @@ #!/usr/bin/env node -// vendor -var log = require('book'); +var log = require('bookrc'); var optimist = require('optimist'); var argv = optimist diff --git a/bookrc.js b/bookrc.js new file mode 100644 index 0000000..e9aabe2 --- /dev/null +++ b/bookrc.js @@ -0,0 +1,13 @@ +/// bookrc logging setup +var log = require('book').default(); + +log.use(require('book-git')(__dirname)); +log.use(require('book-raven')(process.env.SENTRY_DSN)); + +process.once('uncaughtException', function(err) { + log.panic(err); + setTimeout(process.exit.bind(process, 1), 2000); +}); + +module.exports = log; + diff --git a/package.json b/package.json index 5eff76b..a68b478 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "book": "1.2.0", "optimist": "0.3.4", "http-raw": "1.1.0", - "debug": "0.7.2" + "debug": "0.7.2", + "bookrc": "0.0.1", + "book-git": "0.0.2", + "book-raven": "1.0.0" }, "devDependencies": { "mocha": "1.6.0", diff --git a/server.js b/server.js index 0f01821..2644d64 100644 --- a/server.js +++ b/server.js @@ -2,7 +2,7 @@ var http = require('http'); var net = require('net'); var url = require('url'); -var log = require('book'); +var log = require('bookrc'); var debug = require('debug')('localtunnel-server'); var createRawServer = require('http-raw'); -- 2.54.0 From b153c00cb5176b2d5cbb36c9cc1633f480bf3fd4 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Sun, 16 Jun 2013 19:04:51 -0400 Subject: [PATCH 004/247] add node 0.10 to travis config remove 0.9 --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9395b9d..90ce570 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ language: node_js node_js: - - 0.8 - - 0.9 + - "0.8" + - "0.10" -- 2.54.0 From ed5aa3f16b7b4f877b4315ecf1bf2367e09dc728 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Mon, 17 Jun 2013 02:09:01 -0400 Subject: [PATCH 005/247] remove server components moved to localtunnel-server repo --- README.md | 45 ++----- bin/client | 2 +- bin/server | 35 ----- client.js | 8 +- index.js | 2 - lib/rand_id.js | 12 -- package.json | 16 +-- server.js | 339 ------------------------------------------------- test/basic.js | 99 --------------- test/queue.js | 105 --------------- 10 files changed, 18 insertions(+), 645 deletions(-) delete mode 100755 bin/server delete mode 100644 index.js delete mode 100644 lib/rand_id.js delete mode 100644 server.js delete mode 100644 test/basic.js delete mode 100644 test/queue.js diff --git a/README.md b/README.md index 718a765..91bb469 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# localtunnel [![Build Status](https://secure.travis-ci.org/shtylman/localtunnel.png)](http://travis-ci.org/shtylman/localtunnel) # +# localtunnel # localtunnel exposes your localhost to the world for easy testing and sharing! No need to mess with DNS or deploy just to have others test out your changes. @@ -24,43 +24,9 @@ Thats it! It will connect to the tunnel server, setup the tunnel, and tell you w You can restart your local server all you want, ```lt``` is smart enough to detect this and reconnect once it is back. -### custom server - -The default localtunnel client connects to the ```localtunnel.me``` server. You can however easily setup and run your own server. In order to run your own localtunnel server you must ensure that your server can meet the following requirements: - -* You can setup DNS entries for your domain.tld and for *.domain.tld (or sub.domain.tld and *.sub.domain.tld) -* The server can accept incoming TCP connections for any non-root TCP port (ports over 1000). - -The above are important as the client will ask the server for a subdomain under a particular domain. The server will listen on any OS assigned TCP port for client connections - -#### setup - -```shell -// pick a place where the files will live -git clone git://github.com/shtylman/localtunnel.git -cd localtunnel -npm install - -// server set to run on port 1234 -bin/server --port 1324 -``` - -The localtunnel server is now running and waiting for client requests on port 1234. You will most likely want to setup a reverse proxy to listen on port 80 (or start localtunnel on port 80 directly). - -#### use your server - -You can now use your domain with the ```--host``` flag for the ```lt``` client. -```shell -lt --host http://sub.example.tld:1234 --port 9000 -``` - -You will be assigned a url similar to ```qdci.sub.example.com:1234``` - -If your server is being a reverse proxy (i.e. nginx) and is able to listen on port 80, then you do not need the ```:1234``` part of the hostname for the ```lt``` client - ## API ## -The localtunnel client is also usable through an API (test integration, automation, etc) +The localtunnel client is also usable through an API (for test integration, automation, etc) ```javascript var lt_client = require('localtunnel').client; @@ -82,3 +48,10 @@ client.on('error', function(err) { // uh oh! }); ``` + +## server ## + +See shtylman/localtunnel-server for details on the server that powers localtunnel. + +## License ## +MIT diff --git a/bin/client b/bin/client index 4b23867..427bd02 100755 --- a/bin/client +++ b/bin/client @@ -1,5 +1,5 @@ #!/usr/bin/env node -var lt_client = require(__dirname + '/../client'); +var lt_client = require('../client'); var argv = require('optimist') .usage('Usage: $0 --port [num]') diff --git a/bin/server b/bin/server deleted file mode 100755 index 90c72b1..0000000 --- a/bin/server +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node - -// vendor -var log = require('book'); -var optimist = require('optimist'); - -var argv = optimist - .usage('Usage: $0 --port [num]') - .options('port', { - default: '80', - describe: 'listen on this port for outside requests' - }) - .argv; - -if (argv.help) { - optimist.showHelp(); - process.exit(); -} - -process.once('uncaughtException', function(err) { - log.panic(err); - process.exit(-1); - return; -}); - -var server = require('../server')({ - max_tcp_sockets: 5 -}); - -server.listen(argv.port, function() { - log.info('server listening on port: %d', server.address().port); -}); - -// vim: ft=javascript - diff --git a/client.js b/client.js index bb2f25b..90e98c8 100644 --- a/client.js +++ b/client.js @@ -1,9 +1,9 @@ -// builtin var net = require('net'); var url = require('url'); -var request = require('request'); var EventEmitter = require('events').EventEmitter; +var request = require('request'); + // request upstream url and connection info var request_url = function(params, cb) { request(params, function(err, res, body) { @@ -140,3 +140,7 @@ var connect = function(opt) { module.exports.connect = connect; +// for backwards compatibility +// old localtunnel modules had server and client code in same module +// so to keep .client working we expose it here +module.exports.client = module.exports; diff --git a/index.js b/index.js deleted file mode 100644 index 4d51bbc..0000000 --- a/index.js +++ /dev/null @@ -1,2 +0,0 @@ -module.exports.client = require('./client'); -module.exports.server = require('./server'); diff --git a/lib/rand_id.js b/lib/rand_id.js deleted file mode 100644 index 25f4815..0000000 --- a/lib/rand_id.js +++ /dev/null @@ -1,12 +0,0 @@ - -var chars = 'abcdefghiklmnopqrstuvwxyz'; -module.exports = function rand_id() { - var randomstring = ''; - for (var i=0; i<4; ++i) { - var rnum = Math.floor(Math.random() * chars.length); - randomstring += chars[rnum]; - } - - return randomstring; -} - diff --git a/package.json b/package.json index eade7a9..98c2be1 100644 --- a/package.json +++ b/package.json @@ -9,24 +9,12 @@ }, "dependencies": { "request": "2.11.4", - "book": "1.2.0", - "optimist": "0.3.4", - "http-raw": "1.1.0", - "debug": "0.7.2" + "optimist": "0.3.4" }, "devDependencies": { - "mocha": "1.6.0" - }, - "optionalDependencies": {}, - "engines": { - "node": "*" - }, - "scripts": { - "test": "mocha --ui qunit -- test", - "start": "./bin/server" }, "bin": { "lt": "./bin/client" }, - "main": "./index.js" + "main": "./client.js" } diff --git a/server.js b/server.js deleted file mode 100644 index 0f01821..0000000 --- a/server.js +++ /dev/null @@ -1,339 +0,0 @@ -var http = require('http'); -var net = require('net'); -var url = require('url'); - -var log = require('book'); -var debug = require('debug')('localtunnel-server'); -var createRawServer = require('http-raw'); - -var rand_id = require('./lib/rand_id'); - -// here be dragons, understanding of node http internals will be required -var HTTPParser = process.binding('http_parser').HTTPParser; - -// id -> client http server -var clients = {}; - -// available parsers for requests -// this is borrowed from how node does things by preallocating parsers -var parsers = http.parsers; - -// send this request to the appropriate client -// in -> incoming request stream -function proxy_request(client, req, res, rs, ws) { - - // socket is a tcp connection back to the user hosting the site - var sock = client.sockets.shift(); - - // queue request - if (!sock) { - debug('no more clients, queued: %s', req.url); - rs.pause(); - client.waiting.push([req, res, rs, ws]); - return; - } - - debug('handle req: %s', req.url); - - // pipe incoming request into tcp socket - // incoming request will close the socket when done - // lt client should establish a new socket once request is finished - // we do this instead of keeping socket open to make things easier - rs.pipe(sock); - - sock.ws = ws; - sock.req = req; - - // since tcp connection to upstream are kept open - // invoke parsing so we know when the response is complete - var parser = sock.parser; - parser.reinitialize(HTTPParser.RESPONSE); - parser.socket = sock; - - // we have completed a response - // the tcp socket is free again - parser.onIncoming = function (res) { - parser.onMessageComplete = function() { - debug('ended response: %s', req.url); - - // any request we had going on is now done - ws.end(); - sock.end(); - - // no more forwarding - delete sock.ws; - delete sock.req; - delete parser.onIncoming; - }; - }; - - rs.resume(); -} - -function upstream_response(d, start, end) { - var socket = this; - - var ws = socket.ws; - if (!ws) { - return log.warn('no stream set for req:', socket.req.url); - } - - ws.write(d.slice(start, end)); - - if (socket.upgraded) { - return; - } - - var ret = socket.parser.execute(d, start, end - start); - if (ret instanceof Error) { - log.error(ret); - parsers.free(parser); - socket.destroy(ret); - } -} - -var handle_req = function (req, res) { - - var max_tcp_sockets = req.socket.server.max_tcp_sockets; - - // without a hostname, we won't know who the request is for - var hostname = req.headers.host; - if (!hostname) { - log.trace('no hostname: %j', req.headers); - return res.end(); - } - - var match = hostname.match(/^([a-z]{4})[.].*/); - if (match) { - var client_id = match[1]; - var client = clients[client_id]; - - // no such subdomain - // we use 502 error to the client to signify we can't service the request - if (!client) { - debug('no client found for id: ' + client_id); - res.statusCode = 502; - return res.end('localtunnel error: no active client for \'' + client_id + '\''); - } - - var rs = req.createRawStream(); - var ws = res.createRawStream(); - - return proxy_request(client, req, res, rs, ws); - } - - /// NOTE: everything below is for new client setup (not proxied request) - - // ignore favicon requests - if (req.url === '/favicon.ico') { - res.writeHead(404); - return res.end(); - } - - var parsed = url.parse(req.url, true); - - // redirect main page to github reference for root requests - if (req.url === '/' && !parsed.query.new) { - res.writeHead(301, { Location: 'http://shtylman.github.com/localtunnel/' }); - res.end(); - return; - } - - // at this point, the client is requesting a new tunnel setup - // either generate an id or use the one they requested - - var match = req.url.match(/\/([a-z]{4})?/); - - // user can request a particular set of characters - // will be given if not already taken - // this is useful when the main server is restarted - // users can keep testing with their expected ids - var requested_id; - if (match && match[1]) { - requested_id = match[1]; - } - - var id = requested_id || rand_id(); - - // if the id already exists, this client is assigned a random id - if (clients[id]) { - id = rand_id(); - } - - // sockets is a list of available sockets for the connection - // waiting is a list of requests still needing to be handled - var client = clients[id] = { - sockets: [], - waiting: [] - }; - - // new tcp server to service requests for this client - var client_server = net.createServer(); - client_server.listen(function() { - var port = client_server.address().port; - debug('tcp server listening on port: %d', port); - - var url = 'http://' + id + '.' + req.headers.host; - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - // full url for internet facing requests - url: url, - // "subdomain" part - id: id, - // port for lt client tcp connections - port: port, - // maximum number of tcp connections allowed by lt client - max_conn_count: max_tcp_sockets - })); - }); - - // track initial user connection setup - var conn_timeout; - - // user has 5 seconds to connect before their slot is given up - function maybe_tcp_close() { - clearTimeout(conn_timeout); - conn_timeout = setTimeout(client_server.close.bind(client_server), 5000); - } - - maybe_tcp_close(); - - // no longer accepting connections for this id - client_server.on('close', function() { - log.trace('closed tcp socket for client(%s)', id); - - clearTimeout(conn_timeout); - delete clients[id]; - - // clear waiting by ending responses, (requests?) - client.waiting.forEach(function(waiting) { - waiting[1].end(); - waiting[3].end(); // write stream - }); - }); - - // new tcp connection from lt client - client_server.on('connection', function(socket) { - - // no more socket connections allowed - if (client.sockets.length >= max_tcp_sockets) { - return socket.end(); - } - - debug('new connection for id: %s', id); - - // a single connection is enough to keep client id slot open - clearTimeout(conn_timeout); - - // allocate a response parser for the socket - // it only needs one since it will reuse it - socket.parser = parsers.alloc(); - - socket._orig_ondata = socket.ondata; - socket.ondata = upstream_response; - - socket.once('close', function(had_error) { - debug('client %s closed socket (error: %s)', id, had_error); - - // what if socket was servicing a request at this time? - // then it will be put back in available after right? - - // remove this socket - var idx = client.sockets.indexOf(socket); - if (idx >= 0) { - client.sockets.splice(idx, 1); - } - - // need to track total sockets, not just active available - - debug('remaining client sockets: %s', client.sockets.length); - - // no more sockets for this ident - if (client.sockets.length === 0) { - debug('all client(%s) sockets disconnected', id); - maybe_tcp_close(); - } - }); - - // close will be emitted after this - socket.on('error', function(err) { - log.error(err); - socket.end(); - }); - - client.sockets.push(socket); - - var next = client.waiting.shift(); - if (next) { - debug('handling queued request'); - proxy_request(client, next[0], next[1], next[2], next[3]); - } - }); - - client_server.on('error', function(err) { - log.error(err); - }); -}; - -var handle_upgrade = function(req, ws) { - - if (req.headers.connection !== 'Upgrade') { - return; - } - - var hostname = req.headers.host; - if (!hostname) { - return res.end(); - } - - var match = hostname.match(/^([a-z]{4})[.].*/); - - // not a valid client - if (!match) { - return res.end(); - } - - var client_id = match[1]; - var client = clients[client_id]; - - if (!client) { - // no such subdomain - return res.end(); - } - - var socket = client.sockets.shift(); - if (!socket) { - // no available sockets to upgrade to - return res.end(); - } - - var stream = req.createRawStream(); - - socket.ws = ws; - socket.upgraded = true; - - stream.once('end', function() { - delete socket.ws; - - // when this ends, we just reset the socket to the lt client - // this is easier than trying to figure anything else out - socket.end(); - }); - - stream.pipe(socket); - socket.once('end', ws.end.bind(ws)); -}; - -module.exports = function(opt) { - opt = opt || {}; - - var server = createRawServer(); - - server.max_tcp_sockets = opt.max_tcp_sockets || 5; - server.on('request', handle_req); - server.on('upgrade', handle_upgrade); - - return server; -}; - diff --git a/test/basic.js b/test/basic.js deleted file mode 100644 index 34434e3..0000000 --- a/test/basic.js +++ /dev/null @@ -1,99 +0,0 @@ -var http = require('http'); -var url = require('url'); -var assert = require('assert'); - -var localtunnel_server = require('../').server(); -var localtunnel_client = require('../').client; - -test('setup localtunnel server', function(done) { - localtunnel_server.listen(3000, function() { - console.log('lt server on:', 3000); - done(); - }); -}); - -test('setup local http server', function(done) { - var server = http.createServer(); - server.on('request', function(req, res) { - res.write('foo'); - res.end(); - }); - server.listen(function() { - var port = server.address().port; - - test._fake_port = port; - console.log('local http on:', port); - done(); - }); -}); - -test('setup localtunnel client', function(done) { - var client = localtunnel_client.connect({ - host: 'http://localhost:' + 3000, - port: test._fake_port - }); - - client.on('url', function(url) { - assert.ok(/^http:\/\/.*localhost:3000$/.test(url)); - test._fake_url = url; - done(); - }); - - client.on('error', function(err) { - console.error(err); - }); -}); - -test('query localtunnel server w/ ident', function(done) { - var uri = test._fake_url; - var hostname = url.parse(uri).hostname; - - var opt = { - host: 'localhost', - port: 3000, - headers: { - host: hostname - }, - path: '/' - } - - var req = http.request(opt, function(res) { - res.setEncoding('utf8'); - var body = ''; - - res.on('data', function(chunk) { - body += chunk; - }); - - res.on('end', function() { - assert.equal('foo', body); - - // TODO(shtylman) shutdown client - done(); - }); - }); - - req.end(); -}); - -test('request specific domain', function(done) { - var client = localtunnel_client.connect({ - host: 'http://localhost:' + 3000, - port: test._fake_port, - subdomain: 'abcd' - }); - - client.on('url', function(url) { - assert.ok(/^http:\/\/abcd.localhost:3000$/.test(url)); - done(); - }); - - client.on('error', function(err) { - console.error(err); - }); -}); - -test('shutdown', function() { - localtunnel_server.close(); -}); - diff --git a/test/queue.js b/test/queue.js deleted file mode 100644 index c6268b1..0000000 --- a/test/queue.js +++ /dev/null @@ -1,105 +0,0 @@ -var http = require('http'); -var url = require('url'); -var assert = require('assert'); - -var localtunnel_server = require('../').server({ - max_tcp_sockets: 1 -}); - -var localtunnel_client = require('../').client; - -var server; - -test('setup localtunnel server', function(done) { - localtunnel_server.listen(3000, function() { - console.log('lt server on:', 3000); - done(); - }); -}); - -test('setup local http server', function(done) { - server = http.createServer(); - server.on('request', function(req, res) { - // respond sometime later - setTimeout(function() { - res.setHeader('x-count', req.headers['x-count']); - res.end('foo'); - }, 100); - }); - - server.listen(function() { - var port = server.address().port; - - test._fake_port = port; - console.log('local http on:', port); - done(); - }); -}); - -test('setup localtunnel client', function(done) { - var client = localtunnel_client.connect({ - host: 'http://localhost:' + 3000, - port: test._fake_port - }); - - client.on('url', function(url) { - assert.ok(/^http:\/\/.*localhost:3000$/.test(url)); - test._fake_url = url; - done(); - }); - - client.on('error', function(err) { - console.error(err); - }); -}); - -test('query localtunnel server w/ ident', function(done) { - var uri = test._fake_url; - var hostname = url.parse(uri).hostname; - - var count = 0; - var opt = { - host: 'localhost', - port: 3000, - agent: false, - headers: { - host: hostname - }, - path: '/' - } - - var num_requests = 2; - var responses = 0; - - function maybe_done() { - if (++responses >= num_requests) { - done(); - } - } - - function make_req() { - opt.headers['x-count'] = count++; - http.get(opt, function(res) { - res.setEncoding('utf8'); - var body = ''; - - res.on('data', function(chunk) { - body += chunk; - }); - - res.on('end', function() { - assert.equal('foo', body); - maybe_done(); - }); - }); - } - - for (var i=0 ; i Date: Mon, 17 Jun 2013 02:12:54 -0400 Subject: [PATCH 006/247] fix README api example --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 91bb469..dfef4a8 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ You can restart your local server all you want, ```lt``` is smart enough to dete The localtunnel client is also usable through an API (for test integration, automation, etc) ```javascript -var lt_client = require('localtunnel').client; +var localtunnel = require('localtunnel'); -var client = lt_client.connect({ +var client = localtunnel.connect({ // the localtunnel server host: 'http://localtunnel.me', // your local application port -- 2.54.0 From 0334ace20ba7d395a69d6c052e0bf0e3deae5882 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Mon, 17 Jun 2013 02:13:17 -0400 Subject: [PATCH 007/247] 0.1.0 --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 98c2be1..c09446b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Roman Shtylman ", "name": "localtunnel", "description": "expose localhost to the world", - "version": "0.0.4", + "version": "0.1.0", "repository": { "type": "git", "url": "git://github.com/shtylman/localtunnel.git" @@ -11,8 +11,7 @@ "request": "2.11.4", "optimist": "0.3.4" }, - "devDependencies": { - }, + "devDependencies": {}, "bin": { "lt": "./bin/client" }, -- 2.54.0 From d15e568cea623f24954619c6af3f69b42f3027fb Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Tue, 18 Jun 2013 23:00:45 -0400 Subject: [PATCH 008/247] refactor server * shows some basic statistics on main page * move tcp proxy setup into separate file * migrate github page theme to be hosted locally --- package.json | 11 +- proxy.js | 235 ++++++++++++++++ server.js | 482 +++++++++++++-------------------- static/css/grid.css | 8 + static/css/pygment_trac.css | 70 +++++ static/css/style.css | 437 ++++++++++++++++++++++++++++++ static/css/widgets.css | 1 + static/img/bg_hr.png | Bin 0 -> 943 bytes static/img/blacktocat.png | Bin 0 -> 1428 bytes static/img/digits.png | 1 + static/img/icon_download.png | Bin 0 -> 1162 bytes static/img/sprite_download.png | Bin 0 -> 16799 bytes static/js/index.js | 37 +++ test/basic.js | 2 +- test/queue.js | 2 +- views/index.html | 98 +++++++ 16 files changed, 1085 insertions(+), 299 deletions(-) create mode 100644 proxy.js create mode 100644 static/css/grid.css create mode 100644 static/css/pygment_trac.css create mode 100644 static/css/style.css create mode 100644 static/css/widgets.css create mode 100644 static/img/bg_hr.png create mode 100644 static/img/blacktocat.png create mode 120000 static/img/digits.png create mode 100644 static/img/icon_download.png create mode 100644 static/img/sprite_download.png create mode 100644 static/js/index.js create mode 100644 views/index.html diff --git a/package.json b/package.json index a68b478..86d10ab 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,16 @@ "debug": "0.7.2", "bookrc": "0.0.1", "book-git": "0.0.2", - "book-raven": "1.0.0" + "book-raven": "1.0.0", + "engine.io": "0.6.2", + "engine.io-client": "shtylman/engine.io-client#v0.5.0-dz0", + "flip-counter": "0.5.3", + "browserkthx": "0.0.2", + "hbs": "2.3.0", + "taters": "0.0.5", + "express": "3.2.6", + "makeup": "0.0.1", + "enchilada": "0.3.0" }, "devDependencies": { "mocha": "1.6.0", diff --git a/proxy.js b/proxy.js new file mode 100644 index 0000000..84f4093 --- /dev/null +++ b/proxy.js @@ -0,0 +1,235 @@ +var http = require('http'); +var net = require('net'); +var EventEmitter = require('events').EventEmitter; + +var log = require('bookrc'); +var debug = require('debug')('localtunnel-server'); + +// here be dragons, understanding of node http internals will be required +var HTTPParser = process.binding('http_parser').HTTPParser; + +// available parsers for requests +// this is borrowed from how node does things by preallocating parsers +var parsers = http.parsers; + +var Proxy = function(opt, cb) { + if (!(this instanceof Proxy)) { + return new Proxy(opt, cb); + } + + var self = this; + + self.sockets = []; + self.waiting = []; + + var id = opt.id; + + // default max is 5 + var max_tcp_sockets = opt.max_tcp_sockets || 5; + + // new tcp server to service requests for this client + var client_server = net.createServer(); + client_server.listen(function() { + var port = client_server.address().port; + debug('tcp server listening on port: %d', port); + + cb(null, { + // port for lt client tcp connections + port: port, + // maximum number of tcp connections allowed by lt client + max_conn_count: max_tcp_sockets + }); + }); + + // track initial user connection setup + var conn_timeout; + + // user has 5 seconds to connect before their slot is given up + function maybe_tcp_close() { + clearTimeout(conn_timeout); + conn_timeout = setTimeout(client_server.close.bind(client_server), 5000); + } + + maybe_tcp_close(); + + // no longer accepting connections for this id + client_server.on('close', function() { + debug('closed tcp socket for client(%s)', id); + + clearTimeout(conn_timeout); + + // clear waiting by ending responses, (requests?) + self.waiting.forEach(function(waiting) { + waiting[1].end(); + waiting[3].end(); // write stream + }); + + self.emit('end'); + }); + + // new tcp connection from lt client + client_server.on('connection', function(socket) { + + // no more socket connections allowed + if (self.sockets.length >= max_tcp_sockets) { + return socket.end(); + } + + debug('new connection on port: %s', id); + + // a single connection is enough to keep client id slot open + clearTimeout(conn_timeout); + + // allocate a response parser for the socket + // it only needs one since it will reuse it + socket.parser = parsers.alloc(); + + socket._orig_ondata = socket.ondata; + socket.ondata = upstream_response; + + socket.once('close', function(had_error) { + debug('client %s closed socket (error: %s)', id, had_error); + + // what if socket was servicing a request at this time? + // then it will be put back in available after right? + + // remove this socket + var idx = self.sockets.indexOf(socket); + if (idx >= 0) { + self.sockets.splice(idx, 1); + } + + // need to track total sockets, not just active available + + debug('remaining client sockets: %s', self.sockets.length); + + // no more sockets for this ident + if (self.sockets.length === 0) { + debug('all client(%s) sockets disconnected', id); + maybe_tcp_close(); + } + }); + + // close will be emitted after this + socket.on('error', function(err) { + log.error(err); + socket.end(); + }); + + self.sockets.push(socket); + + var next = self.waiting.shift(); + if (next) { + debug('handling queued request'); + self.proxy_request(next[0], next[1], next[2], next[3]); + } + }); + + client_server.on('error', function(err) { + log.error(err); + }); +}; + +Proxy.prototype.__proto__ = EventEmitter.prototype; + +Proxy.prototype.proxy_request = function(req, res, rs, ws) { + var self = this; + + // socket is a tcp connection back to the user hosting the site + var sock = self.sockets.shift(); + + // queue request + if (!sock) { + debug('no more clients, queued: %s', req.url); + rs.pause(); + self.waiting.push([req, res, rs, ws]); + return; + } + + debug('handle req: %s', req.url); + + // pipe incoming request into tcp socket + // incoming request will close the socket when done + // lt client should establish a new socket once request is finished + // we do this instead of keeping socket open to make things easier + rs.pipe(sock); + + sock.ws = ws; + sock.req = req; + + // since tcp connection to upstream are kept open + // invoke parsing so we know when the response is complete + var parser = sock.parser; + parser.reinitialize(HTTPParser.RESPONSE); + parser.socket = sock; + + // we have completed a response + // the tcp socket is free again + parser.onIncoming = function (res) { + parser.onMessageComplete = function() { + debug('ended response: %s', req.url); + + // any request we had going on is now done + ws.end(); + sock.end(); + + // no more forwarding + delete sock.ws; + delete sock.req; + delete parser.onIncoming; + }; + }; + + rs.resume(); +}; + +Proxy.prototype.proxy_upgrade = function(req, socket, head) { + + var sock = self.sockets.shift(); + if (!sock) { + // no available sockets to upgrade to + // TODO queue? + return socket.end(); + } + + var stream = req.createRawStream(); + + sock.ws = ws; + sock.upgraded = true; + + stream.once('end', function() { + delete sock.ws; + + // when this ends, we just reset the socket to the lt client + // this is easier than trying to figure anything else out + sock.end(); + }); + + stream.pipe(sock); + sock.once('end', socket.end.bind(ws)); +}; + +function upstream_response(d, start, end) { + var socket = this; + + var ws = socket.ws; + if (!ws) { + return log.warn('no stream set for req:', socket.req.url); + } + + ws.write(d.slice(start, end)); + + if (socket.upgraded) { + return; + } + + var ret = socket.parser.execute(d, start, end - start); + if (ret instanceof Error) { + log.error(ret); + parsers.free(parser); + socket.destroy(ret); + } +} + +module.exports = Proxy; + diff --git a/server.js b/server.js index 2644d64..be00e0b 100644 --- a/server.js +++ b/server.js @@ -1,339 +1,229 @@ -var http = require('http'); -var net = require('net'); -var url = require('url'); - var log = require('bookrc'); +var express = require('express'); +var taters = require('taters'); +var enchilada = require('enchilada'); +var makeup = require('makeup'); +var engine = require('engine.io'); +var browserkthx = require('browserkthx'); var debug = require('debug')('localtunnel-server'); var createRawServer = require('http-raw'); +var Proxy = require('./proxy'); var rand_id = require('./lib/rand_id'); -// here be dragons, understanding of node http internals will be required -var HTTPParser = process.binding('http_parser').HTTPParser; +var kProduction = process.env.NODE_ENV === 'production'; // id -> client http server var clients = {}; -// available parsers for requests -// this is borrowed from how node does things by preallocating parsers -var parsers = http.parsers; +// proxy statistics +var stats = { + requests: 0, + waiting: 0, + tunnels: 0, +}; -// send this request to the appropriate client -// in -> incoming request stream -function proxy_request(client, req, res, rs, ws) { - - // socket is a tcp connection back to the user hosting the site - var sock = client.sockets.shift(); - - // queue request - if (!sock) { - debug('no more clients, queued: %s', req.url); - rs.pause(); - client.waiting.push([req, res, rs, ws]); - return; - } - - debug('handle req: %s', req.url); - - // pipe incoming request into tcp socket - // incoming request will close the socket when done - // lt client should establish a new socket once request is finished - // we do this instead of keeping socket open to make things easier - rs.pipe(sock); - - sock.ws = ws; - sock.req = req; - - // since tcp connection to upstream are kept open - // invoke parsing so we know when the response is complete - var parser = sock.parser; - parser.reinitialize(HTTPParser.RESPONSE); - parser.socket = sock; - - // we have completed a response - // the tcp socket is free again - parser.onIncoming = function (res) { - parser.onMessageComplete = function() { - debug('ended response: %s', req.url); - - // any request we had going on is now done - ws.end(); - sock.end(); - - // no more forwarding - delete sock.ws; - delete sock.req; - delete parser.onIncoming; - }; - }; - - rs.resume(); -} - -function upstream_response(d, start, end) { - var socket = this; - - var ws = socket.ws; - if (!ws) { - return log.warn('no stream set for req:', socket.req.url); - } - - ws.write(d.slice(start, end)); - - if (socket.upgraded) { - return; - } - - var ret = socket.parser.execute(d, start, end - start); - if (ret instanceof Error) { - log.error(ret); - parsers.free(parser); - socket.destroy(ret); - } -} - -var handle_req = function (req, res) { - - var max_tcp_sockets = req.socket.server.max_tcp_sockets; +// return true if request will be handled, false otherwise +function middleware(req, res) { // without a hostname, we won't know who the request is for var hostname = req.headers.host; if (!hostname) { - log.trace('no hostname: %j', req.headers); - return res.end(); - } - - var match = hostname.match(/^([a-z]{4})[.].*/); - if (match) { - var client_id = match[1]; - var client = clients[client_id]; - - // no such subdomain - // we use 502 error to the client to signify we can't service the request - if (!client) { - debug('no client found for id: ' + client_id); - res.statusCode = 502; - return res.end('localtunnel error: no active client for \'' + client_id + '\''); - } - - var rs = req.createRawStream(); - var ws = res.createRawStream(); - - return proxy_request(client, req, res, rs, ws); - } - - /// NOTE: everything below is for new client setup (not proxied request) - - // ignore favicon requests - if (req.url === '/favicon.ico') { - res.writeHead(404); - return res.end(); - } - - var parsed = url.parse(req.url, true); - - // redirect main page to github reference for root requests - if (req.url === '/' && !parsed.query.new) { - res.writeHead(301, { Location: 'http://shtylman.github.com/localtunnel/' }); - res.end(); - return; - } - - // at this point, the client is requesting a new tunnel setup - // either generate an id or use the one they requested - - var match = req.url.match(/\/([a-z]{4})?/); - - // user can request a particular set of characters - // will be given if not already taken - // this is useful when the main server is restarted - // users can keep testing with their expected ids - var requested_id; - if (match && match[1]) { - requested_id = match[1]; - } - - var id = requested_id || rand_id(); - - // if the id already exists, this client is assigned a random id - if (clients[id]) { - id = rand_id(); - } - - // sockets is a list of available sockets for the connection - // waiting is a list of requests still needing to be handled - var client = clients[id] = { - sockets: [], - waiting: [] - }; - - // new tcp server to service requests for this client - var client_server = net.createServer(); - client_server.listen(function() { - var port = client_server.address().port; - debug('tcp server listening on port: %d', port); - - var url = 'http://' + id + '.' + req.headers.host; - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - // full url for internet facing requests - url: url, - // "subdomain" part - id: id, - // port for lt client tcp connections - port: port, - // maximum number of tcp connections allowed by lt client - max_conn_count: max_tcp_sockets - })); - }); - - // track initial user connection setup - var conn_timeout; - - // user has 5 seconds to connect before their slot is given up - function maybe_tcp_close() { - clearTimeout(conn_timeout); - conn_timeout = setTimeout(client_server.close.bind(client_server), 5000); - } - - maybe_tcp_close(); - - // no longer accepting connections for this id - client_server.on('close', function() { - log.trace('closed tcp socket for client(%s)', id); - - clearTimeout(conn_timeout); - delete clients[id]; - - // clear waiting by ending responses, (requests?) - client.waiting.forEach(function(waiting) { - waiting[1].end(); - waiting[3].end(); // write stream - }); - }); - - // new tcp connection from lt client - client_server.on('connection', function(socket) { - - // no more socket connections allowed - if (client.sockets.length >= max_tcp_sockets) { - return socket.end(); - } - - debug('new connection for id: %s', id); - - // a single connection is enough to keep client id slot open - clearTimeout(conn_timeout); - - // allocate a response parser for the socket - // it only needs one since it will reuse it - socket.parser = parsers.alloc(); - - socket._orig_ondata = socket.ondata; - socket.ondata = upstream_response; - - socket.once('close', function(had_error) { - debug('client %s closed socket (error: %s)', id, had_error); - - // what if socket was servicing a request at this time? - // then it will be put back in available after right? - - // remove this socket - var idx = client.sockets.indexOf(socket); - if (idx >= 0) { - client.sockets.splice(idx, 1); - } - - // need to track total sockets, not just active available - - debug('remaining client sockets: %s', client.sockets.length); - - // no more sockets for this ident - if (client.sockets.length === 0) { - debug('all client(%s) sockets disconnected', id); - maybe_tcp_close(); - } - }); - - // close will be emitted after this - socket.on('error', function(err) { - log.error(err); - socket.end(); - }); - - client.sockets.push(socket); - - var next = client.waiting.shift(); - if (next) { - debug('handling queued request'); - proxy_request(client, next[0], next[1], next[2], next[3]); - } - }); - - client_server.on('error', function(err) { - log.error(err); - }); -}; - -var handle_upgrade = function(req, ws) { - - if (req.headers.connection !== 'Upgrade') { - return; - } - - var hostname = req.headers.host; - if (!hostname) { - return res.end(); + return false; } var match = hostname.match(/^([a-z]{4})[.].*/); - // not a valid client + // not for a specific client if (!match) { - return res.end(); + var match = req.url.match(/\/([a-z]{4})$/); + + var req_id; + + if (req.url === '/?new') { + req_id = rand_id(); + } + else if (match && match[1]) { + req_id = match[1]; + } + + // will not handle + if (!req_id) { + return false; + } + + new_client(req_id, {}, function(err, info) { + if (err) { + res.statusCode = 500; + return res.end(err.message); + } + + var url = 'http://' + req_id + '.' + req.headers.host; + info.url = url; + res.end(JSON.stringify(info)); + }); + + return true; } var client_id = match[1]; var client = clients[client_id]; + // no such subdomain + // we use 502 error to the client to signify we can't service the request if (!client) { - // no such subdomain - return res.end(); + res.statusCode = 502; + res.end('localtunnel error: no active client for \'' + client_id + '\''); + return true; } - var socket = client.sockets.shift(); - if (!socket) { - // no available sockets to upgrade to - return res.end(); - } + ++stats.requests; - var stream = req.createRawStream(); - - socket.ws = ws; - socket.upgraded = true; - - stream.once('end', function() { - delete socket.ws; - - // when this ends, we just reset the socket to the lt client - // this is easier than trying to figure anything else out - socket.end(); + res.on('close', function() { + --stats.requests; }); - stream.pipe(socket); - socket.once('end', ws.end.bind(ws)); + var rs = req.createRawStream(); + var ws = res.createRawStream(); + + client.proxy_request(req, res, rs, ws); + return true; +} + +var handle_upgrade = function(req, socket, head) { + var hostname = req.headers.host; + if (!hostname) { + return socket.end(); + } + + var match = hostname.match(/^([a-z]{4})[.].*/); + + // not handled by us + if (!match) { + return false; + } + + var client_id = match[1]; + var client = clients[client_id]; + + // no such subdomain + if (!client) { + return socket.end(); + } + + client.handle_upgrade(req, socket, head); + return true; }; +function new_client(id, opt, cb) { + + // can't ask for id already is use + // TODO check this new id again + if (clients[id]) { + id = rand_id(); + } + + var popt = { + id: id, + max_tcp_sockets: opt.max_tcp_sockets + }; + + var client = Proxy(popt, function(err, info) { + if (err) { + return cb(err); + } + + ++stats.tunnels; + clients[id] = client; + + info.id = id; + + cb(err, info); + }); + + client.on('end', function() { + --stats.tunnels; + delete clients[id]; + }); +} + module.exports = function(opt) { opt = opt || {}; var server = createRawServer(); - server.max_tcp_sockets = opt.max_tcp_sockets || 5; - server.on('request', handle_req); + var app = express(); + + app.set('view engine', 'html'); + app.engine('html', require('hbs').__express); + + app.use(function(req, res, next) { + if (middleware(req, res)) { + return; + } + + next(); + }); + + app.use(express.favicon()); + + app.use(browserkthx({ ie: '< 9' })); + app.use(taters({ cache: kProduction })); + + app.use(enchilada({ + src: __dirname + '/static/', + compress: kProduction, + cache: kProduction + })); + + app.use('/css/widgets.css', makeup(__dirname + '/static/css/widgets.css')); + app.use(express.static(__dirname + '/static')); + app.use(app.router); + + app.get('/', function(req, res, next) { + return res.render('index'); + }); + + // connected engine.io sockets for stats updates + var eio_sockets = []; + + setInterval(function() { + eio_sockets.forEach(function(socket) { + socket.send(JSON.stringify(stats)); + }); + }, 1000); + + var eio_server = new engine.Server(); + eio_server.on('connection', function (socket) { + + eio_sockets.push(socket); + socket.send(JSON.stringify(stats)); + + socket.on('close', function() { + + // remove from socket pool so no more updates are sent + var idx = eio_sockets.indexOf(socket); + if (idx >= 0) { + eio_sockets.splice(idx, 1); + } + }); + }); + + app.use('/engine.io', function(req, res, next) { + eio_server.handleRequest(req, res); + }); + + server.on('request', app); server.on('upgrade', handle_upgrade); + server.on('upgrade', function(req, socket, head) { + if (handle_upgrade(req, socket, head)) { + return; + } + + eio_server.handleUpgrade(req, socket, head); + }); + return server; }; diff --git a/static/css/grid.css b/static/css/grid.css new file mode 100644 index 0000000..3a02af8 --- /dev/null +++ b/static/css/grid.css @@ -0,0 +1,8 @@ +.row { + position: relative; +} + +.row .half { + width: 49%; + display: inline-block; +} diff --git a/static/css/pygment_trac.css b/static/css/pygment_trac.css new file mode 100644 index 0000000..e65cedf --- /dev/null +++ b/static/css/pygment_trac.css @@ -0,0 +1,70 @@ +.highlight .hll { background-color: #ffffcc } +.highlight { background: #f0f3f3; } +.highlight .c { color: #0099FF; font-style: italic } /* Comment */ +.highlight .err { color: #AA0000; background-color: #FFAAAA } /* Error */ +.highlight .k { color: #006699; font-weight: bold } /* Keyword */ +.highlight .o { color: #555555 } /* Operator */ +.highlight .cm { color: #0099FF; font-style: italic } /* Comment.Multiline */ +.highlight .cp { color: #009999 } /* Comment.Preproc */ +.highlight .c1 { color: #0099FF; font-style: italic } /* Comment.Single */ +.highlight .cs { color: #0099FF; font-weight: bold; font-style: italic } /* Comment.Special */ +.highlight .gd { background-color: #FFCCCC; border: 1px solid #CC0000 } /* Generic.Deleted */ +.highlight .ge { font-style: italic } /* Generic.Emph */ +.highlight .gr { color: #FF0000 } /* Generic.Error */ +.highlight .gh { color: #003300; font-weight: bold } /* Generic.Heading */ +.highlight .gi { background-color: #CCFFCC; border: 1px solid #00CC00 } /* Generic.Inserted */ +.highlight .go { color: #AAAAAA } /* Generic.Output */ +.highlight .gp { color: #000099; font-weight: bold } /* Generic.Prompt */ +.highlight .gs { font-weight: bold } /* Generic.Strong */ +.highlight .gu { color: #003300; font-weight: bold } /* Generic.Subheading */ +.highlight .gt { color: #99CC66 } /* Generic.Traceback */ +.highlight .kc { color: #006699; font-weight: bold } /* Keyword.Constant */ +.highlight .kd { color: #006699; font-weight: bold } /* Keyword.Declaration */ +.highlight .kn { color: #006699; font-weight: bold } /* Keyword.Namespace */ +.highlight .kp { color: #006699 } /* Keyword.Pseudo */ +.highlight .kr { color: #006699; font-weight: bold } /* Keyword.Reserved */ +.highlight .kt { color: #007788; font-weight: bold } /* Keyword.Type */ +.highlight .m { color: #FF6600 } /* Literal.Number */ +.highlight .s { color: #CC3300 } /* Literal.String */ +.highlight .na { color: #330099 } /* Name.Attribute */ +.highlight .nb { color: #336666 } /* Name.Builtin */ +.highlight .nc { color: #00AA88; font-weight: bold } /* Name.Class */ +.highlight .no { color: #336600 } /* Name.Constant */ +.highlight .nd { color: #9999FF } /* Name.Decorator */ +.highlight .ni { color: #999999; font-weight: bold } /* Name.Entity */ +.highlight .ne { color: #CC0000; font-weight: bold } /* Name.Exception */ +.highlight .nf { color: #CC00FF } /* Name.Function */ +.highlight .nl { color: #9999FF } /* Name.Label */ +.highlight .nn { color: #00CCFF; font-weight: bold } /* Name.Namespace */ +.highlight .nt { color: #330099; font-weight: bold } /* Name.Tag */ +.highlight .nv { color: #003333 } /* Name.Variable */ +.highlight .ow { color: #000000; font-weight: bold } /* Operator.Word */ +.highlight .w { color: #bbbbbb } /* Text.Whitespace */ +.highlight .mf { color: #FF6600 } /* Literal.Number.Float */ +.highlight .mh { color: #FF6600 } /* Literal.Number.Hex */ +.highlight .mi { color: #FF6600 } /* Literal.Number.Integer */ +.highlight .mo { color: #FF6600 } /* Literal.Number.Oct */ +.highlight .sb { color: #CC3300 } /* Literal.String.Backtick */ +.highlight .sc { color: #CC3300 } /* Literal.String.Char */ +.highlight .sd { color: #CC3300; font-style: italic } /* Literal.String.Doc */ +.highlight .s2 { color: #CC3300 } /* Literal.String.Double */ +.highlight .se { color: #CC3300; font-weight: bold } /* Literal.String.Escape */ +.highlight .sh { color: #CC3300 } /* Literal.String.Heredoc */ +.highlight .si { color: #AA0000 } /* Literal.String.Interpol */ +.highlight .sx { color: #CC3300 } /* Literal.String.Other */ +.highlight .sr { color: #33AAAA } /* Literal.String.Regex */ +.highlight .s1 { color: #CC3300 } /* Literal.String.Single */ +.highlight .ss { color: #FFCC33 } /* Literal.String.Symbol */ +.highlight .bp { color: #336666 } /* Name.Builtin.Pseudo */ +.highlight .vc { color: #003333 } /* Name.Variable.Class */ +.highlight .vg { color: #003333 } /* Name.Variable.Global */ +.highlight .vi { color: #003333 } /* Name.Variable.Instance */ +.highlight .il { color: #FF6600 } /* Literal.Number.Integer.Long */ + +.type-csharp .highlight .k { color: #0000FF } +.type-csharp .highlight .kt { color: #0000FF } +.type-csharp .highlight .nf { color: #000000; font-weight: normal } +.type-csharp .highlight .nc { color: #2B91AF } +.type-csharp .highlight .nn { color: #000000 } +.type-csharp .highlight .s { color: #A31515 } +.type-csharp .highlight .sc { color: #A31515 } diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..58f37a3 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,437 @@ +/******************************************************************************* +Slate Theme for Github Pages +by Jason Costello, @jsncostello +*******************************************************************************/ + +* { + -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ + -moz-box-sizing: border-box; /* Firefox, other Gecko */ + box-sizing: border-box; +} + +@import url(pygment_trac.css); + +/******************************************************************************* +MeyerWeb Reset +*******************************************************************************/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font: inherit; + vertical-align: baseline; +} + +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} + +ol, ul { + list-style: none; +} + +blockquote, q { +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +a:focus { + outline: none; +} + +/******************************************************************************* +Theme Styles +*******************************************************************************/ + +body { + box-sizing: border-box; + color:#373737; + background: #212121; + font-size: 16px; + font-family: 'Myriad Pro', Calibri, Helvetica, Arial, sans-serif; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, h4, h5, h6 { + margin: 10px 0; + font-weight: 700; + color:#222222; + font-family: 'Lucida Grande', 'Calibri', Helvetica, Arial, sans-serif; + letter-spacing: -1px; +} + +h1 { + font-size: 36px; + font-weight: 700; +} + +h2 { + padding-bottom: 10px; + font-size: 32px; + background: url('../img/bg_hr.png') repeat-x bottom; +} + +h3 { + font-size: 24px; +} + +h4 { + font-size: 21px; +} + +h5 { + font-size: 18px; +} + +h6 { + font-size: 16px; +} + +p { + margin: 10px 0 15px 0; +} + +footer p { + color: #f2f2f2; +} + +a { + text-decoration: none; + color: #007edf; + text-shadow: none; + + transition: color 0.5s ease; + transition: text-shadow 0.5s ease; + -webkit-transition: color 0.5s ease; + -webkit-transition: text-shadow 0.5s ease; + -moz-transition: color 0.5s ease; + -moz-transition: text-shadow 0.5s ease; + -o-transition: color 0.5s ease; + -o-transition: text-shadow 0.5s ease; + -ms-transition: color 0.5s ease; + -ms-transition: text-shadow 0.5s ease; +} + +#main_content a:hover { + color: #0069ba; + text-shadow: #0090ff 0px 0px 2px; +} + +footer a:hover { + color: #43adff; + text-shadow: #0090ff 0px 0px 2px; +} + +em { + font-style: italic; +} + +strong { + font-weight: bold; +} + +img { + position: relative; + margin: 0 auto; + max-width: 739px; + padding: 5px; + margin: 10px 0 10px 0; + border: 1px solid #ebebeb; + + box-shadow: 0 0 5px #ebebeb; + -webkit-box-shadow: 0 0 5px #ebebeb; + -moz-box-shadow: 0 0 5px #ebebeb; + -o-box-shadow: 0 0 5px #ebebeb; + -ms-box-shadow: 0 0 5px #ebebeb; +} + +pre, code { + width: 100%; + color: #222; + background-color: #fff; + + font-family: Monaco, "Bitstream Vera Sans Mono", "Lucida Console", Terminal, monospace; + font-size: 14px; + + border-radius: 2px; + -moz-border-radius: 2px; + -webkit-border-radius: 2px; + + + +} + +pre { + width: 100%; + padding: 10px; + box-shadow: 0 0 10px rgba(0,0,0,.1); + overflow: auto; +} + +code { + padding: 3px; + margin: 0 3px; + box-shadow: 0 0 10px rgba(0,0,0,.1); +} + +pre code { + display: block; + box-shadow: none; +} + +blockquote { + color: #666; + margin-bottom: 20px; + padding: 0 0 0 20px; + border-left: 3px solid #bbb; +} + +ul, ol, dl { + margin-bottom: 15px +} + +ul li { + list-style: inside; + padding-left: 20px; +} + +ol li { + list-style: decimal inside; + padding-left: 20px; +} + +dl dt { + font-weight: bold; +} + +dl dd { + padding-left: 20px; + font-style: italic; +} + +dl p { + padding-left: 20px; + font-style: italic; +} + +hr { + height: 1px; + margin-bottom: 5px; + border: none; + background: url('../img/bg_hr.png') repeat-x center; +} + +table { + border: 1px solid #373737; + margin-bottom: 20px; + text-align: left; + } + +th { + font-family: 'Lucida Grande', 'Helvetica Neue', Helvetica, Arial, sans-serif; + padding: 10px; + background: #373737; + color: #fff; + } + +td { + padding: 10px; + border: 1px solid #373737; + } + +form { + background: #f2f2f2; + padding: 20px; +} + +img { + width: 100%; + max-width: 100%; +} + +/******************************************************************************* +Full-Width Styles +*******************************************************************************/ + +.outer { + width: 100%; +} + +.inner { + position: relative; + max-width: 640px; + padding: 20px 10px; + margin: 0 auto; +} + +#forkme_banner { + display: block; + position: absolute; + top:0; + right: 10px; + z-index: 10; + padding: 10px 50px 10px 10px; + color: #fff; + background: url('../img/blacktocat.png') #0090ff no-repeat 95% 50%; + font-weight: 700; + box-shadow: 0 0 10px rgba(0,0,0,.5); + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; +} + +#header_wrap { + background: #212121; + background: -moz-linear-gradient(top, #373737, #212121); + background: -webkit-linear-gradient(top, #373737, #212121); + background: -ms-linear-gradient(top, #373737, #212121); + background: -o-linear-gradient(top, #373737, #212121); + background: linear-gradient(top, #373737, #212121); +} + +#header_wrap .inner { + padding: 50px 10px 30px 10px; +} + +#project_title { + margin: 0; + color: #fff; + font-size: 42px; + font-weight: 700; + text-shadow: #111 0px 0px 10px; +} + +#project_tagline { + color: #fff; + font-size: 24px; + font-weight: 300; + background: none; + text-shadow: #111 0px 0px 10px; +} + +#downloads { + position: absolute; + width: 210px; + z-index: 10; + bottom: -40px; + right: 0; + height: 70px; + background: url('../img/icon_download.png') no-repeat 0% 90%; +} + +.zip_download_link { + display: block; + float: right; + width: 90px; + height:70px; + text-indent: -5000px; + overflow: hidden; + background: url(../img/sprite_download.png) no-repeat bottom left; +} + +.tar_download_link { + display: block; + float: right; + width: 90px; + height:70px; + text-indent: -5000px; + overflow: hidden; + background: url(../img/sprite_download.png) no-repeat bottom right; + margin-left: 10px; +} + +.zip_download_link:hover { + background: url(../img/sprite_download.png) no-repeat top left; +} + +.tar_download_link:hover { + background: url(../img/sprite_download.png) no-repeat top right; +} + +#main_content_wrap { + background: #f2f2f2; + border-top: 1px solid #111; + border-bottom: 1px solid #111; +} + +#main_content { + padding-top: 40px; +} + +#footer_wrap { + background: #212121; +} + + + +/******************************************************************************* +Small Device Styles +*******************************************************************************/ + +@media screen and (max-width: 480px) { + body { + font-size:14px; + } + + #downloads { + display: none; + } + + .inner { + min-width: 320px; + max-width: 480px; + } + + #project_title { + font-size: 32px; + } + + h1 { + font-size: 28px; + } + + h2 { + font-size: 24px; + } + + h3 { + font-size: 21px; + } + + h4 { + font-size: 18px; + } + + h5 { + font-size: 14px; + } + + h6 { + font-size: 12px; + } + + code, pre { + min-width: 320px; + max-width: 480px; + font-size: 11px; + } + +} diff --git a/static/css/widgets.css b/static/css/widgets.css new file mode 100644 index 0000000..ba3ed2e --- /dev/null +++ b/static/css/widgets.css @@ -0,0 +1 @@ +@import 'flip-counter'; diff --git a/static/img/bg_hr.png b/static/img/bg_hr.png new file mode 100644 index 0000000000000000000000000000000000000000..7973bd69888c7e10ccad1111d555ceabb7cd99b6 GIT binary patch literal 943 zcmaJ=O^ee&7!FiK7FWCot{@Ck@nrMW&tx0B-6VAbrk1u~FTzffX&bu9#AIsIdef8t z!QZfdz=K}>3m(LO;6X3qN}Y6@>cJYA%)G<%Jn!ec>9im1@7>wsIBwrMF}iHO!q%;8 zSJ@xEd~(FL18NRvkBsOXMVM>4WQc*~qcQGc17IjxRnj!O_^B1gan0x#EWT48PK->5B2>mI;LIx zC*FSw$Nfc!g)WZCEOJ=mM)}lLsOk|$ltg_(&ax_YCWMlBLPDVT%D_gB7o_$YZ`-OB z#1sV%whRq21>W;qwN$N?OUGtQQe;JvOsQrna;+v+j8dth=*?orHHb6waX>S!yXCgT zo!oR3{E&GzaOAzfZYv@_Sf{LdyJInS>TS60&R9%yCs$y>2x(*gYIJtRrYAja$Ceq} z!N&oc_K1!3-Ft`U>`CM;quEbB4KG%!MovB*9_3!QzFhqHwrbwK|Doo-y>auDJNSP6 T=d)j*_4El@X4^PFK7I8YBT*xD literal 0 HcmV?d00001 diff --git a/static/img/blacktocat.png b/static/img/blacktocat.png new file mode 100644 index 0000000000000000000000000000000000000000..6e264fe57a2e35a2855405ac7d4102c3f6ddcdae GIT binary patch literal 1428 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 literal 0 HcmV?d00001 diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..30ddb0d --- /dev/null +++ b/static/js/index.js @@ -0,0 +1,37 @@ +var eio = require('engine.io-client'); +var flipCounter = require('flip-counter'); + +var request_counter = new flipCounter('request-count', { + value: 0, + pace: 10, + fW: 30, + tFH: 20, + bFH: 40, + bOffset: 200, + auto: false +}); + +var user_counter = new flipCounter('user-count', { + value: 0, + pace: 10, + fW: 30, + tFH: 20, + bFH: 40, + bOffset: 200, + auto: false +}); + +var socket = eio('/'); +socket.on('open', function () { +}); + +socket.on('message', function (data) { + var msg = JSON.parse(data); + request_counter.incrementTo(msg.requests); + user_counter.incrementTo(msg.tunnels); +}); + +socket.on('close', function () { + request_counter.incrementTo(0); + user_counter.incrementTo(0); +}); diff --git a/test/basic.js b/test/basic.js index f4a4073..6c0809d 100644 --- a/test/basic.js +++ b/test/basic.js @@ -3,7 +3,7 @@ var url = require('url'); var assert = require('assert'); var localtunnel_server = require('../server')(); -var localtunnel_client = require('localtunnel').client; +var localtunnel_client = require('localtunnel'); test('setup localtunnel server', function(done) { localtunnel_server.listen(3000, function() { diff --git a/test/queue.js b/test/queue.js index 844f542..d7e292c 100644 --- a/test/queue.js +++ b/test/queue.js @@ -6,7 +6,7 @@ var localtunnel_server = require('../server')({ max_tcp_sockets: 1 }); -var localtunnel_client = require('localtunnel').client; +var localtunnel_client = require('localtunnel'); var server; diff --git a/views/index.html b/views/index.html new file mode 100644 index 0000000..e59e5fa --- /dev/null +++ b/views/index.html @@ -0,0 +1,98 @@ + + + + + + + + + + + + + localtunnel + + + + + +
+
+ View on GitHub + +

localtunnel

+

expose yourself to the world

+ +
+
+ + +
+ +
+ +
+
+

Requests

+
+
+ +
+

Tunnels

+
+
+
+ +

Ever wish you could get feedback from friends or colleagues about a web project without deploying it? Localtunnel helps you do just that! Run the simple proxy and get a web facing url which you can share with anyone.

+ +

install

+ +
npm install -g localtunnel
+
+ +

Lets say I have a local webserver running on port 8000. I can expose it to the world just by running

+ +
$ lt --port 8000
+your url is: http://gqgh.localtunnel.me
+
+ +

You can now share http://gqgh.localtunnel.me with anyone. As long as your local instance of lt is running, this url will remain active. Any requests to that url will be routed to your service on port 8000.

+ +

uses

+ +

Beyond sharing with friends, localtunnel makes a great tool for testing with any service which needs to hit internet visible URLs.

+ +
    +
  • browserling.com
  • +
  • twillio testing
  • +
  • sendgrid web api
  • +
+
+
+ + + + + + + + + + -- 2.54.0 From b0e94c9784436065d562a936217636b553be0bea Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Tue, 18 Jun 2013 23:15:48 -0400 Subject: [PATCH 009/247] explicitly set view dir --- server.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server.js b/server.js index be00e0b..d8373b5 100644 --- a/server.js +++ b/server.js @@ -155,6 +155,7 @@ module.exports = function(opt) { var app = express(); app.set('view engine', 'html'); + app.set('views', __dirname + '/views'); app.engine('html', require('hbs').__express); app.use(function(req, res, next) { -- 2.54.0 From 092d050fa01d22114ca446bfa09e1fbd32f6c645 Mon Sep 17 00:00:00 2001 From: Andrii Shumada Date: Wed, 16 Oct 2013 12:05:37 +0300 Subject: [PATCH 010/247] Do not call success callback right after error in request_url --- client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client.js b/client.js index 90e98c8..413a60b 100644 --- a/client.js +++ b/client.js @@ -8,7 +8,7 @@ var request = require('request'); var request_url = function(params, cb) { request(params, function(err, res, body) { if (err) { - cb(err); + return cb(err); } cb(null, body); -- 2.54.0 From 9334b18117ba0f43ea69288bea5aad6eccfae25f Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Wed, 16 Oct 2013 10:20:08 -0400 Subject: [PATCH 011/247] update README for github username change --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 713794c..92067e9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# localtunnel-server [![Build Status](https://secure.travis-ci.org/shtylman/localtunnel-server.png)](http://travis-ci.org/shtylman/localtunnel-serer) # +# localtunnel-server [![Build Status](https://secure.travis-ci.org/defunctzombie/localtunnel-server.png)](http://travis-ci.org/defunctzombie/localtunnel-serer) # localtunnel exposes your localhost to the world for easy testing and sharing! No need to mess with DNS or deploy just to have others test out your changes. -This repo is the server component. If you are just looking for the CLI localtunnel app, see (https://github.com/shtylman/localtunnel) +This repo is the server component. If you are just looking for the CLI localtunnel app, see (https://github.com/defunctzombie/localtunnel) ## overview ## @@ -17,7 +17,7 @@ The above are important as the client will ask the server for a subdomain under ```shell // pick a place where the files will live -git clone git://github.com/shtylman/localtunnel-server.git +git clone git://github.com/defunctzombie/localtunnel-server.git cd localtunnel npm install -- 2.54.0 From e7e112963c7200e9c89b5d2f5ee3e6ab291af826 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Wed, 16 Oct 2013 10:34:48 -0400 Subject: [PATCH 012/247] update localtunnel dev dep --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 86d10ab..83bff31 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ }, "devDependencies": { "mocha": "1.6.0", - "localtunnel": "0.0.4" + "localtunnel": "0.1.0" }, "scripts": { "test": "mocha --ui qunit -- test", -- 2.54.0 From aa488f6e76435940ab58f9ff388bd07824ef2ba2 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Tue, 22 Oct 2013 15:57:36 -0400 Subject: [PATCH 013/247] 0.1.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c09446b..a978902 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Roman Shtylman ", "name": "localtunnel", "description": "expose localhost to the world", - "version": "0.1.0", + "version": "0.1.1", "repository": { "type": "git", "url": "git://github.com/shtylman/localtunnel.git" -- 2.54.0 From 2c38aefb9dadd3838ac1ee0fb14d4957e755a29b Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Wed, 23 Oct 2013 11:31:28 -0400 Subject: [PATCH 014/247] add go client to readme --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index dfef4a8..c7d7f4b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ client.on('error', function(err) { }); ``` +## other clients ## + +Clients in other languages + +*go* [gotunnelme](https://github.com/NoahShen/gotunnelme) + ## server ## See shtylman/localtunnel-server for details on the server that powers localtunnel. -- 2.54.0 From 6afb75704adfe980243799001eed7851b3e21ebb Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Sun, 3 Nov 2013 16:56:41 -0500 Subject: [PATCH 015/247] use bouncy to proxy requests - simplifies our codebase a lot! - queued requests are currently broken --- package.json | 4 +- proxy.js | 128 +++++++++-------------------------------------- server.js | 135 ++++++++++++++++++++++---------------------------- test/queue.js | 2 +- 4 files changed, 84 insertions(+), 185 deletions(-) diff --git a/package.json b/package.json index 83bff31..489978f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "request": "2.11.4", "book": "1.2.0", "optimist": "0.3.4", - "http-raw": "1.1.0", "debug": "0.7.2", "bookrc": "0.0.1", "book-git": "0.0.2", @@ -24,7 +23,8 @@ "taters": "0.0.5", "express": "3.2.6", "makeup": "0.0.1", - "enchilada": "0.3.0" + "enchilada": "0.3.0", + "bouncy": "3.2.1" }, "devDependencies": { "mocha": "1.6.0", diff --git a/proxy.js b/proxy.js index 84f4093..25aed6f 100644 --- a/proxy.js +++ b/proxy.js @@ -1,17 +1,9 @@ -var http = require('http'); var net = require('net'); var EventEmitter = require('events').EventEmitter; var log = require('bookrc'); var debug = require('debug')('localtunnel-server'); -// here be dragons, understanding of node http internals will be required -var HTTPParser = process.binding('http_parser').HTTPParser; - -// available parsers for requests -// this is borrowed from how node does things by preallocating parsers -var parsers = http.parsers; - var Proxy = function(opt, cb) { if (!(this instanceof Proxy)) { return new Proxy(opt, cb); @@ -60,8 +52,7 @@ var Proxy = function(opt, cb) { // clear waiting by ending responses, (requests?) self.waiting.forEach(function(waiting) { - waiting[1].end(); - waiting[3].end(); // write stream + waiting(null); }); self.emit('end'); @@ -80,13 +71,6 @@ var Proxy = function(opt, cb) { // a single connection is enough to keep client id slot open clearTimeout(conn_timeout); - // allocate a response parser for the socket - // it only needs one since it will reuse it - socket.parser = parsers.alloc(); - - socket._orig_ondata = socket.ondata; - socket.ondata = upstream_response; - socket.once('close', function(had_error) { debug('client %s closed socket (error: %s)', id, had_error); @@ -100,7 +84,6 @@ var Proxy = function(opt, cb) { } // need to track total sockets, not just active available - debug('remaining client sockets: %s', self.sockets.length); // no more sockets for this ident @@ -118,10 +101,10 @@ var Proxy = function(opt, cb) { self.sockets.push(socket); - var next = self.waiting.shift(); - if (next) { + var wait_cb = self.waiting.shift(); + if (wait_cb) { debug('handling queued request'); - self.proxy_request(next[0], next[1], next[2], next[3]); + self.next_socket(wait_cb); } }); @@ -132,104 +115,39 @@ var Proxy = function(opt, cb) { Proxy.prototype.__proto__ = EventEmitter.prototype; -Proxy.prototype.proxy_request = function(req, res, rs, ws) { +Proxy.prototype.next_socket = function(cb) { var self = this; // socket is a tcp connection back to the user hosting the site var sock = self.sockets.shift(); + // TODO how to handle queue? // queue request if (!sock) { - debug('no more clients, queued: %s', req.url); - rs.pause(); - self.waiting.push([req, res, rs, ws]); - return; + debug('no more client, queue callback'); + return self.waiting.push(cb); } - debug('handle req: %s', req.url); + // put the socket back + function done() { + if (!sock.destroyed) { + debug('retuning socket'); + self.sockets.push(sock); + } - // pipe incoming request into tcp socket - // incoming request will close the socket when done - // lt client should establish a new socket once request is finished - // we do this instead of keeping socket open to make things easier - rs.pipe(sock); - - sock.ws = ws; - sock.req = req; - - // since tcp connection to upstream are kept open - // invoke parsing so we know when the response is complete - var parser = sock.parser; - parser.reinitialize(HTTPParser.RESPONSE); - parser.socket = sock; - - // we have completed a response - // the tcp socket is free again - parser.onIncoming = function (res) { - parser.onMessageComplete = function() { - debug('ended response: %s', req.url); - - // any request we had going on is now done - ws.end(); - sock.end(); - - // no more forwarding - delete sock.ws; - delete sock.req; - delete parser.onIncoming; - }; + var wait = self.waiting.shift(); + debug('processing queued cb'); + if (wait) { + return self.next_socket(cb); + } }; - rs.resume(); + debug('processing request'); + cb(sock, done); }; -Proxy.prototype.proxy_upgrade = function(req, socket, head) { - - var sock = self.sockets.shift(); - if (!sock) { - // no available sockets to upgrade to - // TODO queue? - return socket.end(); - } - - var stream = req.createRawStream(); - - sock.ws = ws; - sock.upgraded = true; - - stream.once('end', function() { - delete sock.ws; - - // when this ends, we just reset the socket to the lt client - // this is easier than trying to figure anything else out - sock.end(); - }); - - stream.pipe(sock); - sock.once('end', socket.end.bind(ws)); +Proxy.prototype._done = function() { + var self = this; }; -function upstream_response(d, start, end) { - var socket = this; - - var ws = socket.ws; - if (!ws) { - return log.warn('no stream set for req:', socket.req.url); - } - - ws.write(d.slice(start, end)); - - if (socket.upgraded) { - return; - } - - var ret = socket.parser.execute(d, start, end - start); - if (ret instanceof Error) { - log.error(ret); - parsers.free(parser); - socket.destroy(ret); - } -} - module.exports = Proxy; - diff --git a/server.js b/server.js index d8373b5..33df9c7 100644 --- a/server.js +++ b/server.js @@ -1,12 +1,12 @@ var log = require('bookrc'); var express = require('express'); +var bouncy = require('bouncy'); var taters = require('taters'); var enchilada = require('enchilada'); var makeup = require('makeup'); var engine = require('engine.io'); var browserkthx = require('browserkthx'); var debug = require('debug')('localtunnel-server'); -var createRawServer = require('http-raw'); var Proxy = require('./proxy'); var rand_id = require('./lib/rand_id'); @@ -23,9 +23,7 @@ var stats = { tunnels: 0, }; -// return true if request will be handled, false otherwise -function middleware(req, res) { - +function maybe_bounce(req, res, bounce) { // without a hostname, we won't know who the request is for var hostname = req.headers.host; if (!hostname) { @@ -35,35 +33,9 @@ function middleware(req, res) { var match = hostname.match(/^([a-z]{4})[.].*/); // not for a specific client + // pass on to regular server if (!match) { - var match = req.url.match(/\/([a-z]{4})$/); - - var req_id; - - if (req.url === '/?new') { - req_id = rand_id(); - } - else if (match && match[1]) { - req_id = match[1]; - } - - // will not handle - if (!req_id) { - return false; - } - - new_client(req_id, {}, function(err, info) { - if (err) { - res.statusCode = 500; - return res.end(err.message); - } - - var url = 'http://' + req_id + '.' + req.headers.host; - info.url = url; - res.end(JSON.stringify(info)); - }); - - return true; + return false; } var client_id = match[1]; @@ -83,38 +55,19 @@ function middleware(req, res) { --stats.requests; }); - var rs = req.createRawStream(); - var ws = res.createRawStream(); + // get client port + client.next_socket(function(socket, done) { + var stream = bounce(socket); //, { headers: { connection: 'close' } }); + + // return the socket to the client pool + stream.once('end', function() { + done(); + }); + }); - client.proxy_request(req, res, rs, ws); return true; } -var handle_upgrade = function(req, socket, head) { - var hostname = req.headers.host; - if (!hostname) { - return socket.end(); - } - - var match = hostname.match(/^([a-z]{4})[.].*/); - - // not handled by us - if (!match) { - return false; - } - - var client_id = match[1]; - var client = clients[client_id]; - - // no such subdomain - if (!client) { - return socket.end(); - } - - client.handle_upgrade(req, socket, head); - return true; -}; - function new_client(id, opt, cb) { // can't ask for id already is use @@ -150,22 +103,12 @@ function new_client(id, opt, cb) { module.exports = function(opt) { opt = opt || {}; - var server = createRawServer(); - var app = express(); app.set('view engine', 'html'); app.set('views', __dirname + '/views'); app.engine('html', require('hbs').__express); - app.use(function(req, res, next) { - if (middleware(req, res)) { - return; - } - - next(); - }); - app.use(express.favicon()); app.use(browserkthx({ ie: '< 9' })); @@ -181,6 +124,42 @@ module.exports = function(opt) { app.use(express.static(__dirname + '/static')); app.use(app.router); + app.get('/', function(req, res, next) { + if (!req.query.hasOwnProperty('new')) { + return next(); + } + + var req_id = rand_id(); + debug('making new client with id %s', req_id); + new_client(req_id, opt, function(err, info) { + if (err) { + res.statusCode = 500; + return res.end(err.message); + } + + var url = 'http://' + req_id + '.' + req.headers.host; + info.url = url; + res.end(JSON.stringify(info)); + }); + }); + + app.get('/:req_id', function(req, res, next) { + var req_id = req.param('req_id'); + + debug('making new client with id %s', req_id); + new_client(req_id, opt, function(err, info) { + if (err) { + res.statusCode = 500; + return res.end(err.message); + } + + var url = 'http://' + req_id + '.' + req.headers.host; + info.url = url; + res.end(JSON.stringify(info)); + }); + + }); + app.get('/', function(req, res, next) { return res.render('index'); }); @@ -214,17 +193,19 @@ module.exports = function(opt) { eio_server.handleRequest(req, res); }); - server.on('request', app); - server.on('upgrade', handle_upgrade); + var app_port = 0; + var app_server = app.listen(app_port, function() { + app_port = app_server.address().port; + }); - server.on('upgrade', function(req, socket, head) { - if (handle_upgrade(req, socket, head)) { + var server = bouncy(function(req, res, bounce) { + // if we should bounce this request, then don't send to our server + if (maybe_bounce(req, res, bounce)) { return; - } + }; - eio_server.handleUpgrade(req, socket, head); + bounce(app_port); }); return server; }; - diff --git a/test/queue.js b/test/queue.js index d7e292c..d75d920 100644 --- a/test/queue.js +++ b/test/queue.js @@ -24,7 +24,7 @@ test('setup local http server', function(done) { setTimeout(function() { res.setHeader('x-count', req.headers['x-count']); res.end('foo'); - }, 100); + }, 500); }); server.listen(function() { -- 2.54.0 From 0bba03ccd2d54ae6fc166b7f436e4e918d8d2878 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Mon, 4 Nov 2013 10:30:54 -0500 Subject: [PATCH 016/247] fix processing queued requests --- proxy.js | 5 +++++ server.js | 2 ++ 2 files changed, 7 insertions(+) diff --git a/proxy.js b/proxy.js index 25aed6f..1ee66c6 100644 --- a/proxy.js +++ b/proxy.js @@ -135,6 +135,11 @@ Proxy.prototype.next_socket = function(cb) { self.sockets.push(sock); } + // no sockets left to process waiting requests + if (self.sockets.length === 0) { + return; + } + var wait = self.waiting.shift(); debug('processing queued cb'); if (wait) { diff --git a/server.js b/server.js index 33df9c7..c59cf37 100644 --- a/server.js +++ b/server.js @@ -199,6 +199,8 @@ module.exports = function(opt) { }); var server = bouncy(function(req, res, bounce) { + debug('request %s', req.url); + // if we should bounce this request, then don't send to our server if (maybe_bounce(req, res, bounce)) { return; -- 2.54.0 From af2c727b416c941bf64228048ce83da0bea540d1 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Wed, 6 Nov 2013 23:06:59 -0500 Subject: [PATCH 017/247] fix upstream client disconnect handling When upstream client disconnects, kill any waiting connections. This is needed to inform the browser that the upstream connection is no longer available and to close the socket so a new one will be created for new requests. --- server.js | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/server.js b/server.js index c59cf37..8ae0d9e 100644 --- a/server.js +++ b/server.js @@ -51,13 +51,29 @@ function maybe_bounce(req, res, bounce) { ++stats.requests; - res.on('close', function() { + res.once('close', function() { --stats.requests; }); // get client port client.next_socket(function(socket, done) { - var stream = bounce(socket); //, { headers: { connection: 'close' } }); + // happens when client upstream is disconnected + // we gracefully inform the user and kill their conn + // without this, the browser will leave some connections open + // and try to use them again for new requests + // we cannot have this as we need bouncy to assign the requests again + if (!socket) { + res.statusCode = 504; + res.end(); + req.connection.destroy(); + return; + } + + var stream = bounce(socket, { headers: { connection: 'close' } }); + + stream.on('error', function(err) { + socket.destroy(); + }); // return the socket to the client pool stream.once('end', function() { @@ -122,6 +138,7 @@ module.exports = function(opt) { app.use('/css/widgets.css', makeup(__dirname + '/static/css/widgets.css')); app.use(express.static(__dirname + '/static')); + app.use(app.router); app.get('/', function(req, res, next) { @@ -143,9 +160,17 @@ module.exports = function(opt) { }); }); + app.get('/', function(req, res, next) { + return res.render('index'); + }); + app.get('/:req_id', function(req, res, next) { var req_id = req.param('req_id'); + if (! /[a-z]{4}/.test(req_id)) { + return next(); + } + debug('making new client with id %s', req_id); new_client(req_id, opt, function(err, info) { if (err) { @@ -160,8 +185,9 @@ module.exports = function(opt) { }); - app.get('/', function(req, res, next) { - return res.render('index'); + var app_port = 0; + var app_server = app.listen(app_port, function() { + app_port = app_server.address().port; }); // connected engine.io sockets for stats updates @@ -173,7 +199,7 @@ module.exports = function(opt) { }); }, 1000); - var eio_server = new engine.Server(); + var eio_server = engine.attach(app_server); eio_server.on('connection', function (socket) { eio_sockets.push(socket); @@ -189,14 +215,6 @@ module.exports = function(opt) { }); }); - app.use('/engine.io', function(req, res, next) { - eio_server.handleRequest(req, res); - }); - - var app_port = 0; - var app_server = app.listen(app_port, function() { - app_port = app_server.address().port; - }); var server = bouncy(function(req, res, bounce) { debug('request %s', req.url); -- 2.54.0 From 34afd6537db10716e3655720d88b27006064543c Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Wed, 6 Nov 2013 23:25:05 -0500 Subject: [PATCH 018/247] more resilient to upstream server failure and restart --- client.js | 57 +++++++++++++++++++++++++++++++--------------------- package.json | 3 ++- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/client.js b/client.js index 413a60b..285f7d0 100644 --- a/client.js +++ b/client.js @@ -2,6 +2,7 @@ var net = require('net'); var url = require('url'); var EventEmitter = require('events').EventEmitter; +var after = require('after'); var request = require('request'); // request upstream url and connection info @@ -30,7 +31,7 @@ var connect = function(opt) { var assigned_domain = opt.subdomain; // connect to upstream given connection parameters - var tunnel = function (remote_host, remote_port) { + var tunnel = function (remote_host, remote_port, dead) { var remote_opt = { host: remote_host, @@ -49,9 +50,9 @@ var connect = function(opt) { return; } + // we need a new tunnel if (++remote_attempts >= 3) { - console.error('localtunnel server offline - try again'); - process.exit(-1); + return dead(); } // connection to localtunnel server @@ -108,32 +109,42 @@ var connect = function(opt) { // where to quest params.uri = base_uri + ((assigned_domain) ? assigned_domain : '?new'); - // get an id from lt server and setup forwarding tcp connections - request_url(params, function(err, body) { + function init_tunnel() { + // get an id from lt server and setup forwarding tcp connections + request_url(params, function(err, body) { + if (err) { + ev.emit('error', new Error('tunnel server not available: ' + err.message + ', retry 1s')); - if (err) { - ev.emit('error', new Error('tunnel server not available: %s, retry 1s', err.message)); + // retry interval for id request + return setTimeout(function() { + init_tunnel(); + }, 1000); + } - // retry interval for id request - return setTimeout(function() { - connect_proxy(opt); - }, 1000); - } + // our assigned hostname and tcp port + var port = body.port; + var host = upstream.hostname; - // our assigned hostname and tcp port - var port = body.port; - var host = upstream.hostname; + // store the id so we can try to get the same one + assigned_domain = body.id; - // store the id so we can try to get the same one - assigned_domain = body.id; + var max_conn = body.max_conn_count || 1; - var max_conn = body.max_conn_count || 1; - for (var count = 0 ; count < max_conn ; ++count) { - tunnel(host, port); - } + // after all our tunnels die, we ask for new ones + // this might happen if the upstream server dies + var dead = after(max_conn, function() { + init_tunnel(); + }); - ev.emit('url', body.url); - }); + for (var count = 0 ; count < max_conn ; ++count) { + tunnel(host, port, dead); + } + + ev.emit('url', body.url); + }); + } + + init_tunnel(); return ev; }; diff --git a/package.json b/package.json index a978902..ea73725 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ }, "dependencies": { "request": "2.11.4", - "optimist": "0.3.4" + "optimist": "0.3.4", + "after": "0.8.1" }, "devDependencies": {}, "bin": { -- 2.54.0 From 18ada0854a3ea0c5aa22e002b69b24cda1dd917b Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Wed, 6 Nov 2013 23:25:28 -0500 Subject: [PATCH 019/247] 0.1.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ea73725..27b1b9f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Roman Shtylman ", "name": "localtunnel", "description": "expose localhost to the world", - "version": "0.1.1", + "version": "0.1.2", "repository": { "type": "git", "url": "git://github.com/shtylman/localtunnel.git" -- 2.54.0 From 2bbf98c24c58d7961592f6e7b3277c381349514f Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Sun, 10 Nov 2013 00:13:42 -0500 Subject: [PATCH 020/247] add superstack for better stacktraces --- bookrc.js | 1 + package.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bookrc.js b/bookrc.js index e9aabe2..71d7c7b 100644 --- a/bookrc.js +++ b/bookrc.js @@ -1,5 +1,6 @@ /// bookrc logging setup var log = require('book').default(); +require('superstack'); log.use(require('book-git')(__dirname)); log.use(require('book-raven')(process.env.SENTRY_DSN)); diff --git a/package.json b/package.json index 489978f..d212b22 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "express": "3.2.6", "makeup": "0.0.1", "enchilada": "0.3.0", - "bouncy": "3.2.1" + "bouncy": "3.2.1", + "superstack": "0.0.3" }, "devDependencies": { "mocha": "1.6.0", -- 2.54.0 From a260585d56ceec1343d1e5aa2175cc24df622b77 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Sun, 10 Nov 2013 00:18:25 -0500 Subject: [PATCH 021/247] remove uncaughtException from bin/server this is handled in bookrc --- bin/server | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/bin/server b/bin/server index 547a193..6e8fc81 100755 --- a/bin/server +++ b/bin/server @@ -2,6 +2,7 @@ var log = require('bookrc'); var optimist = require('optimist'); +var debug = require('debug')('localtunnel'); var argv = optimist .usage('Usage: $0 --port [num]') @@ -16,18 +17,12 @@ if (argv.help) { process.exit(); } -process.once('uncaughtException', function(err) { - log.panic(err); - process.exit(-1); - return; -}); - var server = require('../server')({ max_tcp_sockets: 5 }); server.listen(argv.port, function() { - log.info('server listening on port: %d', server.address().port); + debug('server listening on port: %d', server.address().port); }); // vim: ft=javascript -- 2.54.0 From ac1fbc740ede0d4a25270e0e182ffb6899d3441b Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Sun, 10 Nov 2013 00:19:00 -0500 Subject: [PATCH 022/247] continue to handle uncaught exception until exit --- bookrc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bookrc.js b/bookrc.js index 71d7c7b..b2f760c 100644 --- a/bookrc.js +++ b/bookrc.js @@ -5,7 +5,7 @@ require('superstack'); log.use(require('book-git')(__dirname)); log.use(require('book-raven')(process.env.SENTRY_DSN)); -process.once('uncaughtException', function(err) { +process.on('uncaughtException', function(err) { log.panic(err); setTimeout(process.exit.bind(process, 1), 2000); }); -- 2.54.0 From 1fe63831d831c6dc7690f78e822f06e6e15bb6d7 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Sun, 10 Nov 2013 00:19:55 -0500 Subject: [PATCH 023/247] remove error log from socket errors We don't care, just close the socket --- proxy.js | 1 - 1 file changed, 1 deletion(-) diff --git a/proxy.js b/proxy.js index 1ee66c6..d332b44 100644 --- a/proxy.js +++ b/proxy.js @@ -95,7 +95,6 @@ var Proxy = function(opt, cb) { // close will be emitted after this socket.on('error', function(err) { - log.error(err); socket.end(); }); -- 2.54.0 From 5e777a1eca48b13e51f3d8408fc9096f6272fbd4 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Sun, 10 Nov 2013 00:21:04 -0500 Subject: [PATCH 024/247] update deps to latest versions --- package.json | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index d212b22..fafaee2 100644 --- a/package.json +++ b/package.json @@ -8,28 +8,27 @@ "url": "git://github.com/shtylman/localtunnel-server.git" }, "dependencies": { - "request": "2.11.4", - "book": "1.2.0", - "optimist": "0.3.4", - "debug": "0.7.2", + "book": "1.3.0", + "optimist": "0.6.0", + "debug": "0.7.3", "bookrc": "0.0.1", "book-git": "0.0.2", - "book-raven": "1.0.0", - "engine.io": "0.6.2", - "engine.io-client": "shtylman/engine.io-client#v0.5.0-dz0", + "book-raven": "1.0.1", + "engine.io": "0.7.11", + "engine.io-client": "0.7.11", "flip-counter": "0.5.3", "browserkthx": "0.0.2", - "hbs": "2.3.0", + "hbs": "2.4.0", "taters": "0.0.5", - "express": "3.2.6", + "express": "3.4.4", "makeup": "0.0.1", - "enchilada": "0.3.0", + "enchilada": "0.5.0", "bouncy": "3.2.1", "superstack": "0.0.3" }, "devDependencies": { - "mocha": "1.6.0", - "localtunnel": "0.1.0" + "mocha": "1.14.0", + "localtunnel": "0.1.2" }, "scripts": { "test": "mocha --ui qunit -- test", -- 2.54.0 From 7fadd36106c2779b3a54a211aee950c54bd46590 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Sun, 10 Nov 2013 00:25:15 -0500 Subject: [PATCH 025/247] don't care about node 0.8 anymore --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 90ce570..982fee7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,3 @@ language: node_js node_js: - - "0.8" - "0.10" -- 2.54.0 From 21df257d167185eb5b63969e8285a19e10f4e87a Mon Sep 17 00:00:00 2001 From: Omri Bahumi Date: Thu, 14 Nov 2013 15:44:50 +0200 Subject: [PATCH 026/247] Added the --local-host parameter to tunnel the traffic to other hosts --- bin/client | 5 +++++ client.js | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/bin/client b/bin/client index 427bd02..4b4a9bd 100755 --- a/bin/client +++ b/bin/client @@ -11,12 +11,17 @@ var argv = require('optimist') .options('subdomain', { describe: 'request this subdomain' }) + .options('local-host', { + describe: 'tunnel traffic to this host instead of localhost' + }) + .default('local-host', 'localhost') .describe('port', 'internal http server port') .argv; var opt = { host: argv.host, port: argv.port, + local_host: argv['local-host'], subdomain: argv.subdomain, } diff --git a/client.js b/client.js index 285f7d0..748efd0 100644 --- a/client.js +++ b/client.js @@ -19,6 +19,9 @@ var request_url = function(params, cb) { var connect = function(opt) { var ev = new EventEmitter(); + // local host + var local_host = opt.local_host; + // local port var local_port = opt.port; @@ -39,7 +42,7 @@ var connect = function(opt) { }; var local_opt = { - host: 'localhost', + host: local_host, port: local_port }; -- 2.54.0 From a9b0274ff4debbeb8a575b4cc45f1438580ef4b3 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Thu, 14 Nov 2013 12:10:02 -0500 Subject: [PATCH 027/247] 0.1.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 27b1b9f..32f1c26 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Roman Shtylman ", "name": "localtunnel", "description": "expose localhost to the world", - "version": "0.1.2", + "version": "0.1.3", "repository": { "type": "git", "url": "git://github.com/shtylman/localtunnel.git" -- 2.54.0 From 9db2bc5202ff412a0011e242af6753efe4c8217c Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Mon, 18 Nov 2013 13:40:38 -0500 Subject: [PATCH 028/247] don't wait for server close to clear timeout --- proxy.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/proxy.js b/proxy.js index d332b44..79f7baa 100644 --- a/proxy.js +++ b/proxy.js @@ -39,7 +39,10 @@ var Proxy = function(opt, cb) { // user has 5 seconds to connect before their slot is given up function maybe_tcp_close() { clearTimeout(conn_timeout); - conn_timeout = setTimeout(client_server.close.bind(client_server), 5000); + conn_timeout = setTimeout(function() { + client_server.close(); + clearTimeout(conn_timeout); + }, 5000); } maybe_tcp_close(); -- 2.54.0 From 790e55e8811fd671247fead6053c261c33438c46 Mon Sep 17 00:00:00 2001 From: Adam Mckaig Date: Wed, 20 Nov 2013 11:47:52 -0500 Subject: [PATCH 029/247] Add more verbose error for ECONNREFUSED If the tunnel server can be reached (at e.g. http://localtunnel.me/?new) but the tunnel (to e.g. grpi.localtunnel.me:44827) can't actually be established, the client currently gets stuck in a loop retrying forever with no indication as to what's wrong. This doesn't fix the loop, since it does seem desirable to retry forever, but logs: [Error: connection refused: localtunnel.me:44827] --- client.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client.js b/client.js index 748efd0..404b5a8 100644 --- a/client.js +++ b/client.js @@ -62,7 +62,13 @@ var connect = function(opt) { var remote = net.connect(remote_opt); remote.once('error', function(err) { - if (err.code !== 'ECONNREFUSED') { + + // emit connection refused errors immediately, because they + // indicate that the tunnel can't be established. + if (err.code === 'ECONNREFUSED') { + ev.emit('error', new Error('connection refused: ' + remote_host + ':' + remote_port + ' (check your firewall settings)')); + } + else { remote.emit('error', err); } -- 2.54.0 From 91ec28a2eeddf3a075397c3eebfd81dcd3cb5424 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Thu, 21 Nov 2013 21:06:47 -0500 Subject: [PATCH 030/247] don't pass uri to engine.io for connection It thinks that is for something different. --- static/js/index.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 30ddb0d..64a81ed 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -21,9 +21,8 @@ var user_counter = new flipCounter('user-count', { auto: false }); -var socket = eio('/'); -socket.on('open', function () { -}); +var socket = eio(); +socket.on('open', function () {}); socket.on('message', function (data) { var msg = JSON.parse(data); -- 2.54.0 From 91b708c134685673a26a805b7be8bbd0f42fd63a Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Thu, 21 Nov 2013 21:07:53 -0500 Subject: [PATCH 031/247] update deps --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index fafaee2..a89c736 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,12 @@ "dependencies": { "book": "1.3.0", "optimist": "0.6.0", - "debug": "0.7.3", + "debug": "0.7.4", "bookrc": "0.0.1", "book-git": "0.0.2", "book-raven": "1.0.1", - "engine.io": "0.7.11", - "engine.io-client": "0.7.11", + "engine.io": "0.7.12", + "engine.io-client": "0.7.12", "flip-counter": "0.5.3", "browserkthx": "0.0.2", "hbs": "2.4.0", @@ -28,7 +28,7 @@ }, "devDependencies": { "mocha": "1.14.0", - "localtunnel": "0.1.2" + "localtunnel": "0.1.3" }, "scripts": { "test": "mocha --ui qunit -- test", -- 2.54.0 From a4f5ceb4bcc1db37e025581da194ffbcff1d4f5a Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Thu, 21 Nov 2013 21:10:33 -0500 Subject: [PATCH 032/247] https by default for urls --- server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server.js b/server.js index 8ae0d9e..f9ab678 100644 --- a/server.js +++ b/server.js @@ -154,7 +154,7 @@ module.exports = function(opt) { return res.end(err.message); } - var url = 'http://' + req_id + '.' + req.headers.host; + var url = 'https://' + req_id + '.' + req.headers.host; info.url = url; res.end(JSON.stringify(info)); }); @@ -178,7 +178,7 @@ module.exports = function(opt) { return res.end(err.message); } - var url = 'http://' + req_id + '.' + req.headers.host; + var url = 'https://' + req_id + '.' + req.headers.host; info.url = url; res.end(JSON.stringify(info)); }); -- 2.54.0 From ae73e16ac268bafb6ec1933d9df807c12d8fb803 Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Thu, 21 Nov 2013 21:16:59 -0500 Subject: [PATCH 033/247] add --secure flag to indicate proxy can support https requests When --secure is set, all urls will be sent back with https so use will use secure proxy. --- bin/server | 7 ++++++- server.js | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/bin/server b/bin/server index 6e8fc81..e53ee57 100755 --- a/bin/server +++ b/bin/server @@ -6,6 +6,10 @@ var debug = require('debug')('localtunnel'); var argv = optimist .usage('Usage: $0 --port [num]') + .options('secure', { + default: false, + describe: 'use this flag to indicate proxy over https' + }) .options('port', { default: '80', describe: 'listen on this port for outside requests' @@ -18,7 +22,8 @@ if (argv.help) { } var server = require('../server')({ - max_tcp_sockets: 5 + max_tcp_sockets: 5, + secure: argv.secure }); server.listen(argv.port, function() { diff --git a/server.js b/server.js index f9ab678..09583eb 100644 --- a/server.js +++ b/server.js @@ -119,6 +119,8 @@ function new_client(id, opt, cb) { module.exports = function(opt) { opt = opt || {}; + var schema = opt.secure ? 'https' : 'http'; + var app = express(); app.set('view engine', 'html'); @@ -154,7 +156,7 @@ module.exports = function(opt) { return res.end(err.message); } - var url = 'https://' + req_id + '.' + req.headers.host; + var url = schema + '://' + req_id + '.' + req.headers.host; info.url = url; res.end(JSON.stringify(info)); }); @@ -178,7 +180,7 @@ module.exports = function(opt) { return res.end(err.message); } - var url = 'https://' + req_id + '.' + req.headers.host; + var url = schema + '://' + req_id + '.' + req.headers.host; info.url = url; res.end(JSON.stringify(info)); }); -- 2.54.0 From 9ce57979cf963914df4ebfe4db3dfa165236f9ab Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Thu, 21 Nov 2013 21:22:49 -0500 Subject: [PATCH 034/247] fix tests to be port agnostic --- test/basic.js | 17 ++++++++++------- test/queue.js | 12 +++++++----- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/test/basic.js b/test/basic.js index 6c0809d..fef2da5 100644 --- a/test/basic.js +++ b/test/basic.js @@ -5,9 +5,12 @@ var assert = require('assert'); var localtunnel_server = require('../server')(); var localtunnel_client = require('localtunnel'); +var lt_server_port + test('setup localtunnel server', function(done) { - localtunnel_server.listen(3000, function() { - console.log('lt server on:', 3000); + var server = localtunnel_server.listen(function() { + lt_server_port = server.address().port; + console.log('lt server on:', lt_server_port); done(); }); }); @@ -29,12 +32,12 @@ test('setup local http server', function(done) { test('setup localtunnel client', function(done) { var client = localtunnel_client.connect({ - host: 'http://localhost:' + 3000, + host: 'http://localhost:' + lt_server_port, port: test._fake_port }); client.on('url', function(url) { - assert.ok(/^http:\/\/.*localhost:3000$/.test(url)); + assert.ok(new RegExp('^http:\/\/.*localhost:' + lt_server_port + '$').test(url)); test._fake_url = url; done(); }); @@ -50,7 +53,7 @@ test('query localtunnel server w/ ident', function(done) { var opt = { host: 'localhost', - port: 3000, + port: lt_server_port, headers: { host: hostname }, @@ -78,13 +81,13 @@ test('query localtunnel server w/ ident', function(done) { test('request specific domain', function(done) { var client = localtunnel_client.connect({ - host: 'http://localhost:' + 3000, + host: 'http://localhost:' + lt_server_port, port: test._fake_port, subdomain: 'abcd' }); client.on('url', function(url) { - assert.ok(/^http:\/\/abcd.localhost:3000$/.test(url)); + assert.ok(new RegExp('^http:\/\/abcd.localhost:' + lt_server_port + '$').test(url)); done(); }); diff --git a/test/queue.js b/test/queue.js index d75d920..32a06a3 100644 --- a/test/queue.js +++ b/test/queue.js @@ -9,10 +9,12 @@ var localtunnel_server = require('../server')({ var localtunnel_client = require('localtunnel'); var server; +var lt_server_port; test('setup localtunnel server', function(done) { - localtunnel_server.listen(3000, function() { - console.log('lt server on:', 3000); + var lt_server = localtunnel_server.listen(function() { + lt_server_port = lt_server.address().port; + console.log('lt server on:', lt_server_port); done(); }); }); @@ -38,12 +40,12 @@ test('setup local http server', function(done) { test('setup localtunnel client', function(done) { var client = localtunnel_client.connect({ - host: 'http://localhost:' + 3000, + host: 'http://localhost:' + lt_server_port, port: test._fake_port }); client.on('url', function(url) { - assert.ok(/^http:\/\/.*localhost:3000$/.test(url)); + assert.ok(new RegExp('^http:\/\/.*localhost:' + lt_server_port + '$').test(url)); test._fake_url = url; done(); }); @@ -60,7 +62,7 @@ test('query localtunnel server w/ ident', function(done) { var count = 0; var opt = { host: 'localhost', - port: 3000, + port: lt_server_port, agent: false, headers: { host: hostname -- 2.54.0 From b6abd1638330ceae5d8c6c930f90d29ba5cc76ba Mon Sep 17 00:00:00 2001 From: Roman Shtylman Date: Fri, 22 Nov 2013 17:57:26 -0500 Subject: [PATCH 035/247] update landing page - restyle - add sponsors --- package.json | 4 +- server.js | 11 + static/css/font-awesome.css | 1338 +++++++++++++++++ static/css/normalize.styl | 406 +++++ .../{pygment_trac.css => pygment_trac.styl} | 0 static/css/style.css | 437 ------ static/css/style.styl | 96 ++ static/fonts/FontAwesome.otf | Bin 0 -> 62856 bytes static/fonts/fontawesome-webfont.eot | Bin 0 -> 38205 bytes static/fonts/fontawesome-webfont.svg | 414 +++++ static/fonts/fontawesome-webfont.ttf | Bin 0 -> 80652 bytes static/fonts/fontawesome-webfont.woff | Bin 0 -> 44432 bytes views/index.html | 142 +- 13 files changed, 2342 insertions(+), 506 deletions(-) create mode 100644 static/css/font-awesome.css create mode 100644 static/css/normalize.styl rename static/css/{pygment_trac.css => pygment_trac.styl} (100%) delete mode 100644 static/css/style.css create mode 100644 static/css/style.styl create mode 100644 static/fonts/FontAwesome.otf create mode 100755 static/fonts/fontawesome-webfont.eot create mode 100755 static/fonts/fontawesome-webfont.svg create mode 100755 static/fonts/fontawesome-webfont.ttf create mode 100755 static/fonts/fontawesome-webfont.woff diff --git a/package.json b/package.json index a89c736..f16b93c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "makeup": "0.0.1", "enchilada": "0.5.0", "bouncy": "3.2.1", - "superstack": "0.0.3" + "superstack": "0.0.3", + "stylish": "0.3.0", + "makeover": "0.0.1" }, "devDependencies": { "mocha": "1.14.0", diff --git a/server.js b/server.js index 09583eb..de5948e 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,8 @@ var express = require('express'); var bouncy = require('bouncy'); var taters = require('taters'); var enchilada = require('enchilada'); +var stylish = require('stylish'); +var makeover = require('makeover'); var makeup = require('makeup'); var engine = require('engine.io'); var browserkthx = require('browserkthx'); @@ -132,6 +134,15 @@ module.exports = function(opt) { app.use(browserkthx({ ie: '< 9' })); app.use(taters({ cache: kProduction })); + app.use(stylish({ + src: __dirname + '/static/', + compress: kProduction, + cache: kProduction, + setup: function(stylus) { + return stylus.use(makeover()); + } + })); + app.use(enchilada({ src: __dirname + '/static/', compress: kProduction, diff --git a/static/css/font-awesome.css b/static/css/font-awesome.css new file mode 100644 index 0000000..048cff9 --- /dev/null +++ b/static/css/font-awesome.css @@ -0,0 +1,1338 @@ +/*! + * Font Awesome 4.0.3 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */ +/* FONT PATH + * -------------------------- */ +@font-face { + font-family: 'FontAwesome'; + src: url('../fonts/fontawesome-webfont.eot?v=4.0.3'); + src: url('../fonts/fontawesome-webfont.eot?#iefix&v=4.0.3') format('embedded-opentype'), url('../fonts/fontawesome-webfont.woff?v=4.0.3') format('woff'), url('../fonts/fontawesome-webfont.ttf?v=4.0.3') format('truetype'), url('../fonts/fontawesome-webfont.svg?v=4.0.3#fontawesomeregular') format('svg'); + font-weight: normal; + font-style: normal; +} +.fa { + display: inline-block; + font-family: FontAwesome; + font-style: normal; + font-weight: normal; + line-height: 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +/* makes the font 33% larger relative to the icon container */ +.fa-lg { + font-size: 1.3333333333333333em; + line-height: 0.75em; + vertical-align: -15%; +} +.fa-2x { + font-size: 2em; +} +.fa-3x { + font-size: 3em; +} +.fa-4x { + font-size: 4em; +} +.fa-5x { + font-size: 5em; +} +.fa-fw { + width: 1.2857142857142858em; + text-align: center; +} +.fa-ul { + padding-left: 0; + margin-left: 2.142857142857143em; + list-style-type: none; +} +.fa-ul > li { + position: relative; +} +.fa-li { + position: absolute; + left: -2.142857142857143em; + width: 2.142857142857143em; + top: 0.14285714285714285em; + text-align: center; +} +.fa-li.fa-lg { + left: -1.8571428571428572em; +} +.fa-border { + padding: .2em .25em .15em; + border: solid 0.08em #eeeeee; + border-radius: .1em; +} +.pull-right { + float: right; +} +.pull-left { + float: left; +} +.fa.pull-left { + margin-right: .3em; +} +.fa.pull-right { + margin-left: .3em; +} +.fa-spin { + -webkit-animation: spin 2s infinite linear; + -moz-animation: spin 2s infinite linear; + -o-animation: spin 2s infinite linear; + animation: spin 2s infinite linear; +} +@-moz-keyframes spin { + 0% { + -moz-transform: rotate(0deg); + } + 100% { + -moz-transform: rotate(359deg); + } +} +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(359deg); + } +} +@-o-keyframes spin { + 0% { + -o-transform: rotate(0deg); + } + 100% { + -o-transform: rotate(359deg); + } +} +@-ms-keyframes spin { + 0% { + -ms-transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(359deg); + } +} +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(359deg); + } +} +.fa-rotate-90 { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=1); + -webkit-transform: rotate(90deg); + -moz-transform: rotate(90deg); + -ms-transform: rotate(90deg); + -o-transform: rotate(90deg); + transform: rotate(90deg); +} +.fa-rotate-180 { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2); + -webkit-transform: rotate(180deg); + -moz-transform: rotate(180deg); + -ms-transform: rotate(180deg); + -o-transform: rotate(180deg); + transform: rotate(180deg); +} +.fa-rotate-270 { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=3); + -webkit-transform: rotate(270deg); + -moz-transform: rotate(270deg); + -ms-transform: rotate(270deg); + -o-transform: rotate(270deg); + transform: rotate(270deg); +} +.fa-flip-horizontal { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1); + -webkit-transform: scale(-1, 1); + -moz-transform: scale(-1, 1); + -ms-transform: scale(-1, 1); + -o-transform: scale(-1, 1); + transform: scale(-1, 1); +} +.fa-flip-vertical { + filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1); + -webkit-transform: scale(1, -1); + -moz-transform: scale(1, -1); + -ms-transform: scale(1, -1); + -o-transform: scale(1, -1); + transform: scale(1, -1); +} +.fa-stack { + position: relative; + display: inline-block; + width: 2em; + height: 2em; + line-height: 2em; + vertical-align: middle; +} +.fa-stack-1x, +.fa-stack-2x { + position: absolute; + left: 0; + width: 100%; + text-align: center; +} +.fa-stack-1x { + line-height: inherit; +} +.fa-stack-2x { + font-size: 2em; +} +.fa-inverse { + color: #ffffff; +} +/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen + readers do not read off random characters that represent icons */ +.fa-glass:before { + content: "\f000"; +} +.fa-music:before { + content: "\f001"; +} +.fa-search:before { + content: "\f002"; +} +.fa-envelope-o:before { + content: "\f003"; +} +.fa-heart:before { + content: "\f004"; +} +.fa-star:before { + content: "\f005"; +} +.fa-star-o:before { + content: "\f006"; +} +.fa-user:before { + content: "\f007"; +} +.fa-film:before { + content: "\f008"; +} +.fa-th-large:before { + content: "\f009"; +} +.fa-th:before { + content: "\f00a"; +} +.fa-th-list:before { + content: "\f00b"; +} +.fa-check:before { + content: "\f00c"; +} +.fa-times:before { + content: "\f00d"; +} +.fa-search-plus:before { + content: "\f00e"; +} +.fa-search-minus:before { + content: "\f010"; +} +.fa-power-off:before { + content: "\f011"; +} +.fa-signal:before { + content: "\f012"; +} +.fa-gear:before, +.fa-cog:before { + content: "\f013"; +} +.fa-trash-o:before { + content: "\f014"; +} +.fa-home:before { + content: "\f015"; +} +.fa-file-o:before { + content: "\f016"; +} +.fa-clock-o:before { + content: "\f017"; +} +.fa-road:before { + content: "\f018"; +} +.fa-download:before { + content: "\f019"; +} +.fa-arrow-circle-o-down:before { + content: "\f01a"; +} +.fa-arrow-circle-o-up:before { + content: "\f01b"; +} +.fa-inbox:before { + content: "\f01c"; +} +.fa-play-circle-o:before { + content: "\f01d"; +} +.fa-rotate-right:before, +.fa-repeat:before { + content: "\f01e"; +} +.fa-refresh:before { + content: "\f021"; +} +.fa-list-alt:before { + content: "\f022"; +} +.fa-lock:before { + content: "\f023"; +} +.fa-flag:before { + content: "\f024"; +} +.fa-headphones:before { + content: "\f025"; +} +.fa-volume-off:before { + content: "\f026"; +} +.fa-volume-down:before { + content: "\f027"; +} +.fa-volume-up:before { + content: "\f028"; +} +.fa-qrcode:before { + content: "\f029"; +} +.fa-barcode:before { + content: "\f02a"; +} +.fa-tag:before { + content: "\f02b"; +} +.fa-tags:before { + content: "\f02c"; +} +.fa-book:before { + content: "\f02d"; +} +.fa-bookmark:before { + content: "\f02e"; +} +.fa-print:before { + content: "\f02f"; +} +.fa-camera:before { + content: "\f030"; +} +.fa-font:before { + content: "\f031"; +} +.fa-bold:before { + content: "\f032"; +} +.fa-italic:before { + content: "\f033"; +} +.fa-text-height:before { + content: "\f034"; +} +.fa-text-width:before { + content: "\f035"; +} +.fa-align-left:before { + content: "\f036"; +} +.fa-align-center:before { + content: "\f037"; +} +.fa-align-right:before { + content: "\f038"; +} +.fa-align-justify:before { + content: "\f039"; +} +.fa-list:before { + content: "\f03a"; +} +.fa-dedent:before, +.fa-outdent:before { + content: "\f03b"; +} +.fa-indent:before { + content: "\f03c"; +} +.fa-video-camera:before { + content: "\f03d"; +} +.fa-picture-o:before { + content: "\f03e"; +} +.fa-pencil:before { + content: "\f040"; +} +.fa-map-marker:before { + content: "\f041"; +} +.fa-adjust:before { + content: "\f042"; +} +.fa-tint:before { + content: "\f043"; +} +.fa-edit:before, +.fa-pencil-square-o:before { + content: "\f044"; +} +.fa-share-square-o:before { + content: "\f045"; +} +.fa-check-square-o:before { + content: "\f046"; +} +.fa-arrows:before { + content: "\f047"; +} +.fa-step-backward:before { + content: "\f048"; +} +.fa-fast-backward:before { + content: "\f049"; +} +.fa-backward:before { + content: "\f04a"; +} +.fa-play:before { + content: "\f04b"; +} +.fa-pause:before { + content: "\f04c"; +} +.fa-stop:before { + content: "\f04d"; +} +.fa-forward:before { + content: "\f04e"; +} +.fa-fast-forward:before { + content: "\f050"; +} +.fa-step-forward:before { + content: "\f051"; +} +.fa-eject:before { + content: "\f052"; +} +.fa-chevron-left:before { + content: "\f053"; +} +.fa-chevron-right:before { + content: "\f054"; +} +.fa-plus-circle:before { + content: "\f055"; +} +.fa-minus-circle:before { + content: "\f056"; +} +.fa-times-circle:before { + content: "\f057"; +} +.fa-check-circle:before { + content: "\f058"; +} +.fa-question-circle:before { + content: "\f059"; +} +.fa-info-circle:before { + content: "\f05a"; +} +.fa-crosshairs:before { + content: "\f05b"; +} +.fa-times-circle-o:before { + content: "\f05c"; +} +.fa-check-circle-o:before { + content: "\f05d"; +} +.fa-ban:before { + content: "\f05e"; +} +.fa-arrow-left:before { + content: "\f060"; +} +.fa-arrow-right:before { + content: "\f061"; +} +.fa-arrow-up:before { + content: "\f062"; +} +.fa-arrow-down:before { + content: "\f063"; +} +.fa-mail-forward:before, +.fa-share:before { + content: "\f064"; +} +.fa-expand:before { + content: "\f065"; +} +.fa-compress:before { + content: "\f066"; +} +.fa-plus:before { + content: "\f067"; +} +.fa-minus:before { + content: "\f068"; +} +.fa-asterisk:before { + content: "\f069"; +} +.fa-exclamation-circle:before { + content: "\f06a"; +} +.fa-gift:before { + content: "\f06b"; +} +.fa-leaf:before { + content: "\f06c"; +} +.fa-fire:before { + content: "\f06d"; +} +.fa-eye:before { + content: "\f06e"; +} +.fa-eye-slash:before { + content: "\f070"; +} +.fa-warning:before, +.fa-exclamation-triangle:before { + content: "\f071"; +} +.fa-plane:before { + content: "\f072"; +} +.fa-calendar:before { + content: "\f073"; +} +.fa-random:before { + content: "\f074"; +} +.fa-comment:before { + content: "\f075"; +} +.fa-magnet:before { + content: "\f076"; +} +.fa-chevron-up:before { + content: "\f077"; +} +.fa-chevron-down:before { + content: "\f078"; +} +.fa-retweet:before { + content: "\f079"; +} +.fa-shopping-cart:before { + content: "\f07a"; +} +.fa-folder:before { + content: "\f07b"; +} +.fa-folder-open:before { + content: "\f07c"; +} +.fa-arrows-v:before { + content: "\f07d"; +} +.fa-arrows-h:before { + content: "\f07e"; +} +.fa-bar-chart-o:before { + content: "\f080"; +} +.fa-twitter-square:before { + content: "\f081"; +} +.fa-facebook-square:before { + content: "\f082"; +} +.fa-camera-retro:before { + content: "\f083"; +} +.fa-key:before { + content: "\f084"; +} +.fa-gears:before, +.fa-cogs:before { + content: "\f085"; +} +.fa-comments:before { + content: "\f086"; +} +.fa-thumbs-o-up:before { + content: "\f087"; +} +.fa-thumbs-o-down:before { + content: "\f088"; +} +.fa-star-half:before { + content: "\f089"; +} +.fa-heart-o:before { + content: "\f08a"; +} +.fa-sign-out:before { + content: "\f08b"; +} +.fa-linkedin-square:before { + content: "\f08c"; +} +.fa-thumb-tack:before { + content: "\f08d"; +} +.fa-external-link:before { + content: "\f08e"; +} +.fa-sign-in:before { + content: "\f090"; +} +.fa-trophy:before { + content: "\f091"; +} +.fa-github-square:before { + content: "\f092"; +} +.fa-upload:before { + content: "\f093"; +} +.fa-lemon-o:before { + content: "\f094"; +} +.fa-phone:before { + content: "\f095"; +} +.fa-square-o:before { + content: "\f096"; +} +.fa-bookmark-o:before { + content: "\f097"; +} +.fa-phone-square:before { + content: "\f098"; +} +.fa-twitter:before { + content: "\f099"; +} +.fa-facebook:before { + content: "\f09a"; +} +.fa-github:before { + content: "\f09b"; +} +.fa-unlock:before { + content: "\f09c"; +} +.fa-credit-card:before { + content: "\f09d"; +} +.fa-rss:before { + content: "\f09e"; +} +.fa-hdd-o:before { + content: "\f0a0"; +} +.fa-bullhorn:before { + content: "\f0a1"; +} +.fa-bell:before { + content: "\f0f3"; +} +.fa-certificate:before { + content: "\f0a3"; +} +.fa-hand-o-right:before { + content: "\f0a4"; +} +.fa-hand-o-left:before { + content: "\f0a5"; +} +.fa-hand-o-up:before { + content: "\f0a6"; +} +.fa-hand-o-down:before { + content: "\f0a7"; +} +.fa-arrow-circle-left:before { + content: "\f0a8"; +} +.fa-arrow-circle-right:before { + content: "\f0a9"; +} +.fa-arrow-circle-up:before { + content: "\f0aa"; +} +.fa-arrow-circle-down:before { + content: "\f0ab"; +} +.fa-globe:before { + content: "\f0ac"; +} +.fa-wrench:before { + content: "\f0ad"; +} +.fa-tasks:before { + content: "\f0ae"; +} +.fa-filter:before { + content: "\f0b0"; +} +.fa-briefcase:before { + content: "\f0b1"; +} +.fa-arrows-alt:before { + content: "\f0b2"; +} +.fa-group:before, +.fa-users:before { + content: "\f0c0"; +} +.fa-chain:before, +.fa-link:before { + content: "\f0c1"; +} +.fa-cloud:before { + content: "\f0c2"; +} +.fa-flask:before { + content: "\f0c3"; +} +.fa-cut:before, +.fa-scissors:before { + content: "\f0c4"; +} +.fa-copy:before, +.fa-files-o:before { + content: "\f0c5"; +} +.fa-paperclip:before { + content: "\f0c6"; +} +.fa-save:before, +.fa-floppy-o:before { + content: "\f0c7"; +} +.fa-square:before { + content: "\f0c8"; +} +.fa-bars:before { + content: "\f0c9"; +} +.fa-list-ul:before { + content: "\f0ca"; +} +.fa-list-ol:before { + content: "\f0cb"; +} +.fa-strikethrough:before { + content: "\f0cc"; +} +.fa-underline:before { + content: "\f0cd"; +} +.fa-table:before { + content: "\f0ce"; +} +.fa-magic:before { + content: "\f0d0"; +} +.fa-truck:before { + content: "\f0d1"; +} +.fa-pinterest:before { + content: "\f0d2"; +} +.fa-pinterest-square:before { + content: "\f0d3"; +} +.fa-google-plus-square:before { + content: "\f0d4"; +} +.fa-google-plus:before { + content: "\f0d5"; +} +.fa-money:before { + content: "\f0d6"; +} +.fa-caret-down:before { + content: "\f0d7"; +} +.fa-caret-up:before { + content: "\f0d8"; +} +.fa-caret-left:before { + content: "\f0d9"; +} +.fa-caret-right:before { + content: "\f0da"; +} +.fa-columns:before { + content: "\f0db"; +} +.fa-unsorted:before, +.fa-sort:before { + content: "\f0dc"; +} +.fa-sort-down:before, +.fa-sort-asc:before { + content: "\f0dd"; +} +.fa-sort-up:before, +.fa-sort-desc:before { + content: "\f0de"; +} +.fa-envelope:before { + content: "\f0e0"; +} +.fa-linkedin:before { + content: "\f0e1"; +} +.fa-rotate-left:before, +.fa-undo:before { + content: "\f0e2"; +} +.fa-legal:before, +.fa-gavel:before { + content: "\f0e3"; +} +.fa-dashboard:before, +.fa-tachometer:before { + content: "\f0e4"; +} +.fa-comment-o:before { + content: "\f0e5"; +} +.fa-comments-o:before { + content: "\f0e6"; +} +.fa-flash:before, +.fa-bolt:before { + content: "\f0e7"; +} +.fa-sitemap:before { + content: "\f0e8"; +} +.fa-umbrella:before { + content: "\f0e9"; +} +.fa-paste:before, +.fa-clipboard:before { + content: "\f0ea"; +} +.fa-lightbulb-o:before { + content: "\f0eb"; +} +.fa-exchange:before { + content: "\f0ec"; +} +.fa-cloud-download:before { + content: "\f0ed"; +} +.fa-cloud-upload:before { + content: "\f0ee"; +} +.fa-user-md:before { + content: "\f0f0"; +} +.fa-stethoscope:before { + content: "\f0f1"; +} +.fa-suitcase:before { + content: "\f0f2"; +} +.fa-bell-o:before { + content: "\f0a2"; +} +.fa-coffee:before { + content: "\f0f4"; +} +.fa-cutlery:before { + content: "\f0f5"; +} +.fa-file-text-o:before { + content: "\f0f6"; +} +.fa-building-o:before { + content: "\f0f7"; +} +.fa-hospital-o:before { + content: "\f0f8"; +} +.fa-ambulance:before { + content: "\f0f9"; +} +.fa-medkit:before { + content: "\f0fa"; +} +.fa-fighter-jet:before { + content: "\f0fb"; +} +.fa-beer:before { + content: "\f0fc"; +} +.fa-h-square:before { + content: "\f0fd"; +} +.fa-plus-square:before { + content: "\f0fe"; +} +.fa-angle-double-left:before { + content: "\f100"; +} +.fa-angle-double-right:before { + content: "\f101"; +} +.fa-angle-double-up:before { + content: "\f102"; +} +.fa-angle-double-down:before { + content: "\f103"; +} +.fa-angle-left:before { + content: "\f104"; +} +.fa-angle-right:before { + content: "\f105"; +} +.fa-angle-up:before { + content: "\f106"; +} +.fa-angle-down:before { + content: "\f107"; +} +.fa-desktop:before { + content: "\f108"; +} +.fa-laptop:before { + content: "\f109"; +} +.fa-tablet:before { + content: "\f10a"; +} +.fa-mobile-phone:before, +.fa-mobile:before { + content: "\f10b"; +} +.fa-circle-o:before { + content: "\f10c"; +} +.fa-quote-left:before { + content: "\f10d"; +} +.fa-quote-right:before { + content: "\f10e"; +} +.fa-spinner:before { + content: "\f110"; +} +.fa-circle:before { + content: "\f111"; +} +.fa-mail-reply:before, +.fa-reply:before { + content: "\f112"; +} +.fa-github-alt:before { + content: "\f113"; +} +.fa-folder-o:before { + content: "\f114"; +} +.fa-folder-open-o:before { + content: "\f115"; +} +.fa-smile-o:before { + content: "\f118"; +} +.fa-frown-o:before { + content: "\f119"; +} +.fa-meh-o:before { + content: "\f11a"; +} +.fa-gamepad:before { + content: "\f11b"; +} +.fa-keyboard-o:before { + content: "\f11c"; +} +.fa-flag-o:before { + content: "\f11d"; +} +.fa-flag-checkered:before { + content: "\f11e"; +} +.fa-terminal:before { + content: "\f120"; +} +.fa-code:before { + content: "\f121"; +} +.fa-reply-all:before { + content: "\f122"; +} +.fa-mail-reply-all:before { + content: "\f122"; +} +.fa-star-half-empty:before, +.fa-star-half-full:before, +.fa-star-half-o:before { + content: "\f123"; +} +.fa-location-arrow:before { + content: "\f124"; +} +.fa-crop:before { + content: "\f125"; +} +.fa-code-fork:before { + content: "\f126"; +} +.fa-unlink:before, +.fa-chain-broken:before { + content: "\f127"; +} +.fa-question:before { + content: "\f128"; +} +.fa-info:before { + content: "\f129"; +} +.fa-exclamation:before { + content: "\f12a"; +} +.fa-superscript:before { + content: "\f12b"; +} +.fa-subscript:before { + content: "\f12c"; +} +.fa-eraser:before { + content: "\f12d"; +} +.fa-puzzle-piece:before { + content: "\f12e"; +} +.fa-microphone:before { + content: "\f130"; +} +.fa-microphone-slash:before { + content: "\f131"; +} +.fa-shield:before { + content: "\f132"; +} +.fa-calendar-o:before { + content: "\f133"; +} +.fa-fire-extinguisher:before { + content: "\f134"; +} +.fa-rocket:before { + content: "\f135"; +} +.fa-maxcdn:before { + content: "\f136"; +} +.fa-chevron-circle-left:before { + content: "\f137"; +} +.fa-chevron-circle-right:before { + content: "\f138"; +} +.fa-chevron-circle-up:before { + content: "\f139"; +} +.fa-chevron-circle-down:before { + content: "\f13a"; +} +.fa-html5:before { + content: "\f13b"; +} +.fa-css3:before { + content: "\f13c"; +} +.fa-anchor:before { + content: "\f13d"; +} +.fa-unlock-alt:before { + content: "\f13e"; +} +.fa-bullseye:before { + content: "\f140"; +} +.fa-ellipsis-h:before { + content: "\f141"; +} +.fa-ellipsis-v:before { + content: "\f142"; +} +.fa-rss-square:before { + content: "\f143"; +} +.fa-play-circle:before { + content: "\f144"; +} +.fa-ticket:before { + content: "\f145"; +} +.fa-minus-square:before { + content: "\f146"; +} +.fa-minus-square-o:before { + content: "\f147"; +} +.fa-level-up:before { + content: "\f148"; +} +.fa-level-down:before { + content: "\f149"; +} +.fa-check-square:before { + content: "\f14a"; +} +.fa-pencil-square:before { + content: "\f14b"; +} +.fa-external-link-square:before { + content: "\f14c"; +} +.fa-share-square:before { + content: "\f14d"; +} +.fa-compass:before { + content: "\f14e"; +} +.fa-toggle-down:before, +.fa-caret-square-o-down:before { + content: "\f150"; +} +.fa-toggle-up:before, +.fa-caret-square-o-up:before { + content: "\f151"; +} +.fa-toggle-right:before, +.fa-caret-square-o-right:before { + content: "\f152"; +} +.fa-euro:before, +.fa-eur:before { + content: "\f153"; +} +.fa-gbp:before { + content: "\f154"; +} +.fa-dollar:before, +.fa-usd:before { + content: "\f155"; +} +.fa-rupee:before, +.fa-inr:before { + content: "\f156"; +} +.fa-cny:before, +.fa-rmb:before, +.fa-yen:before, +.fa-jpy:before { + content: "\f157"; +} +.fa-ruble:before, +.fa-rouble:before, +.fa-rub:before { + content: "\f158"; +} +.fa-won:before, +.fa-krw:before { + content: "\f159"; +} +.fa-bitcoin:before, +.fa-btc:before { + content: "\f15a"; +} +.fa-file:before { + content: "\f15b"; +} +.fa-file-text:before { + content: "\f15c"; +} +.fa-sort-alpha-asc:before { + content: "\f15d"; +} +.fa-sort-alpha-desc:before { + content: "\f15e"; +} +.fa-sort-amount-asc:before { + content: "\f160"; +} +.fa-sort-amount-desc:before { + content: "\f161"; +} +.fa-sort-numeric-asc:before { + content: "\f162"; +} +.fa-sort-numeric-desc:before { + content: "\f163"; +} +.fa-thumbs-up:before { + content: "\f164"; +} +.fa-thumbs-down:before { + content: "\f165"; +} +.fa-youtube-square:before { + content: "\f166"; +} +.fa-youtube:before { + content: "\f167"; +} +.fa-xing:before { + content: "\f168"; +} +.fa-xing-square:before { + content: "\f169"; +} +.fa-youtube-play:before { + content: "\f16a"; +} +.fa-dropbox:before { + content: "\f16b"; +} +.fa-stack-overflow:before { + content: "\f16c"; +} +.fa-instagram:before { + content: "\f16d"; +} +.fa-flickr:before { + content: "\f16e"; +} +.fa-adn:before { + content: "\f170"; +} +.fa-bitbucket:before { + content: "\f171"; +} +.fa-bitbucket-square:before { + content: "\f172"; +} +.fa-tumblr:before { + content: "\f173"; +} +.fa-tumblr-square:before { + content: "\f174"; +} +.fa-long-arrow-down:before { + content: "\f175"; +} +.fa-long-arrow-up:before { + content: "\f176"; +} +.fa-long-arrow-left:before { + content: "\f177"; +} +.fa-long-arrow-right:before { + content: "\f178"; +} +.fa-apple:before { + content: "\f179"; +} +.fa-windows:before { + content: "\f17a"; +} +.fa-android:before { + content: "\f17b"; +} +.fa-linux:before { + content: "\f17c"; +} +.fa-dribbble:before { + content: "\f17d"; +} +.fa-skype:before { + content: "\f17e"; +} +.fa-foursquare:before { + content: "\f180"; +} +.fa-trello:before { + content: "\f181"; +} +.fa-female:before { + content: "\f182"; +} +.fa-male:before { + content: "\f183"; +} +.fa-gittip:before { + content: "\f184"; +} +.fa-sun-o:before { + content: "\f185"; +} +.fa-moon-o:before { + content: "\f186"; +} +.fa-archive:before { + content: "\f187"; +} +.fa-bug:before { + content: "\f188"; +} +.fa-vk:before { + content: "\f189"; +} +.fa-weibo:before { + content: "\f18a"; +} +.fa-renren:before { + content: "\f18b"; +} +.fa-pagelines:before { + content: "\f18c"; +} +.fa-stack-exchange:before { + content: "\f18d"; +} +.fa-arrow-circle-o-right:before { + content: "\f18e"; +} +.fa-arrow-circle-o-left:before { + content: "\f190"; +} +.fa-toggle-left:before, +.fa-caret-square-o-left:before { + content: "\f191"; +} +.fa-dot-circle-o:before { + content: "\f192"; +} +.fa-wheelchair:before { + content: "\f193"; +} +.fa-vimeo-square:before { + content: "\f194"; +} +.fa-turkish-lira:before, +.fa-try:before { + content: "\f195"; +} +.fa-plus-square-o:before { + content: "\f196"; +} diff --git a/static/css/normalize.styl b/static/css/normalize.styl new file mode 100644 index 0000000..c2de8df --- /dev/null +++ b/static/css/normalize.styl @@ -0,0 +1,406 @@ +/*! normalize.css v2.1.3 | MIT License | git.io/normalize */ + +/* ========================================================================== + HTML5 display definitions + ========================================================================== */ + +/** + * Correct `block` display not defined in IE 8/9. + */ + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +nav, +section, +summary { + display: block; +} + +/** + * Correct `inline-block` display not defined in IE 8/9. + */ + +audio, +canvas, +video { + display: inline-block; +} + +/** + * Prevent modern browsers from displaying `audio` without controls. + * Remove excess height in iOS 5 devices. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Address `[hidden]` styling not present in IE 8/9. + * Hide the `template` element in IE, Safari, and Firefox < 22. + */ + +[hidden], +template { + display: none; +} + +/* ========================================================================== + Base + ========================================================================== */ + +/** + * 1. Set default font family to sans-serif. + * 2. Prevent iOS text size adjust after orientation change, without disabling + * user zoom. + */ + +html { + font-family: sans-serif; /* 1 */ + -ms-text-size-adjust: 100%; /* 2 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/** + * Remove default margin. + */ + +body { + margin: 0; +} + +/* ========================================================================== + Links + ========================================================================== */ + +/** + * Remove the gray background color from active links in IE 10. + */ + +a { + background: transparent; +} + +/** + * Address `outline` inconsistency between Chrome and other browsers. + */ + +a:focus { + outline: thin dotted; +} + +/** + * Improve readability when focused and also mouse hovered in all browsers. + */ + +a:active, +a:hover { + outline: 0; +} + +/* ========================================================================== + Typography + ========================================================================== */ + +/** + * Address variable `h1` font-size and margin within `section` and `article` + * contexts in Firefox 4+, Safari 5, and Chrome. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/** + * Address styling not present in IE 8/9, Safari 5, and Chrome. + */ + +abbr[title] { + border-bottom: 1px dotted; +} + +/** + * Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome. + */ + +b, +strong { + font-weight: bold; +} + +/** + * Address styling not present in Safari 5 and Chrome. + */ + +dfn { + font-style: italic; +} + +/** + * Address differences between Firefox and other browsers. + */ + +hr { + -moz-box-sizing: content-box; + box-sizing: content-box; + height: 0; +} + +/** + * Address styling not present in IE 8/9. + */ + +mark { + background: #ff0; + color: #000; +} + +/** + * Correct font family set oddly in Safari 5 and Chrome. + */ + +code, +kbd, +pre, +samp { + font-family: monospace, serif; + font-size: 1em; +} + +/** + * Improve readability of pre-formatted text in all browsers. + */ + +pre { + white-space: pre-wrap; +} + +/** + * Set consistent quote types. + */ + +q { + quotes: "\201C" "\201D" "\2018" "\2019"; +} + +/** + * Address inconsistent and variable font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` affecting `line-height` in all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +/* ========================================================================== + Embedded content + ========================================================================== */ + +/** + * Remove border when inside `a` element in IE 8/9. + */ + +img { + border: 0; +} + +/** + * Correct overflow displayed oddly in IE 9. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* ========================================================================== + Figures + ========================================================================== */ + +/** + * Address margin not present in IE 8/9 and Safari 5. + */ + +figure { + margin: 0; +} + +/* ========================================================================== + Forms + ========================================================================== */ + +/** + * Define consistent border, margin, and padding. + */ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct `color` not being inherited in IE 8/9. + * 2. Remove padding so people aren't caught out if they zero out fieldsets. + */ + +legend { + border: 0; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * 1. Correct font family not being inherited in all browsers. + * 2. Correct font size not being inherited in all browsers. + * 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome. + */ + +button, +input, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 2 */ + margin: 0; /* 3 */ +} + +/** + * Address Firefox 4+ setting `line-height` on `input` using `!important` in + * the UA stylesheet. + */ + +button, +input { + line-height: normal; +} + +/** + * Address inconsistent `text-transform` inheritance for `button` and `select`. + * All other form control elements do not inherit `text-transform` values. + * Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+. + * Correct `select` style inheritance in Firefox 4+ and Opera. + */ + +button, +select { + text-transform: none; +} + +/** + * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` + * and `video` controls. + * 2. Correct inability to style clickable `input` types in iOS. + * 3. Improve usability and consistency of cursor style between image-type + * `input` and others. + */ + +button, +html input[type="button"], /* 1 */ +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; /* 2 */ + cursor: pointer; /* 3 */ +} + +/** + * Re-set default cursor for disabled elements. + */ + +button[disabled], +html input[disabled] { + cursor: default; +} + +/** + * 1. Address box sizing set to `content-box` in IE 8/9/10. + * 2. Remove excess padding in IE 8/9/10. + */ + +input[type="checkbox"], +input[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. + * 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome + * (include `-moz` to future-proof). + */ + +input[type="search"] { + -webkit-appearance: textfield; /* 1 */ + -moz-box-sizing: content-box; + -webkit-box-sizing: content-box; /* 2 */ + box-sizing: content-box; +} + +/** + * Remove inner padding and search cancel button in Safari 5 and Chrome + * on OS X. + */ + +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * Remove inner padding and border in Firefox 4+. + */ + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +/** + * 1. Remove default vertical scrollbar in IE 8/9. + * 2. Improve readability and alignment in all browsers. + */ + +textarea { + overflow: auto; /* 1 */ + vertical-align: top; /* 2 */ +} + +/* ========================================================================== + Tables + ========================================================================== */ + +/** + * Remove most spacing between table cells. + */ + +table { + border-collapse: collapse; + border-spacing: 0; +} diff --git a/static/css/pygment_trac.css b/static/css/pygment_trac.styl similarity index 100% rename from static/css/pygment_trac.css rename to static/css/pygment_trac.styl diff --git a/static/css/style.css b/static/css/style.css deleted file mode 100644 index 58f37a3..0000000 --- a/static/css/style.css +++ /dev/null @@ -1,437 +0,0 @@ -/******************************************************************************* -Slate Theme for Github Pages -by Jason Costello, @jsncostello -*******************************************************************************/ - -* { - -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ - -moz-box-sizing: border-box; /* Firefox, other Gecko */ - box-sizing: border-box; -} - -@import url(pygment_trac.css); - -/******************************************************************************* -MeyerWeb Reset -*******************************************************************************/ - -html, body, div, span, applet, object, iframe, -h1, h2, h3, h4, h5, h6, p, blockquote, pre, -a, abbr, acronym, address, big, cite, code, -del, dfn, em, img, ins, kbd, q, s, samp, -small, strike, strong, sub, sup, tt, var, -b, u, i, center, -dl, dt, dd, ol, ul, li, -fieldset, form, label, legend, -table, caption, tbody, tfoot, thead, tr, th, td, -article, aside, canvas, details, embed, -figure, figcaption, footer, header, hgroup, -menu, nav, output, ruby, section, summary, -time, mark, audio, video { - margin: 0; - padding: 0; - border: 0; - font: inherit; - vertical-align: baseline; -} - -/* HTML5 display-role reset for older browsers */ -article, aside, details, figcaption, figure, -footer, header, hgroup, menu, nav, section { - display: block; -} - -ol, ul { - list-style: none; -} - -blockquote, q { -} - -table { - border-collapse: collapse; - border-spacing: 0; -} - -a:focus { - outline: none; -} - -/******************************************************************************* -Theme Styles -*******************************************************************************/ - -body { - box-sizing: border-box; - color:#373737; - background: #212121; - font-size: 16px; - font-family: 'Myriad Pro', Calibri, Helvetica, Arial, sans-serif; - line-height: 1.5; - -webkit-font-smoothing: antialiased; -} - -h1, h2, h3, h4, h5, h6 { - margin: 10px 0; - font-weight: 700; - color:#222222; - font-family: 'Lucida Grande', 'Calibri', Helvetica, Arial, sans-serif; - letter-spacing: -1px; -} - -h1 { - font-size: 36px; - font-weight: 700; -} - -h2 { - padding-bottom: 10px; - font-size: 32px; - background: url('../img/bg_hr.png') repeat-x bottom; -} - -h3 { - font-size: 24px; -} - -h4 { - font-size: 21px; -} - -h5 { - font-size: 18px; -} - -h6 { - font-size: 16px; -} - -p { - margin: 10px 0 15px 0; -} - -footer p { - color: #f2f2f2; -} - -a { - text-decoration: none; - color: #007edf; - text-shadow: none; - - transition: color 0.5s ease; - transition: text-shadow 0.5s ease; - -webkit-transition: color 0.5s ease; - -webkit-transition: text-shadow 0.5s ease; - -moz-transition: color 0.5s ease; - -moz-transition: text-shadow 0.5s ease; - -o-transition: color 0.5s ease; - -o-transition: text-shadow 0.5s ease; - -ms-transition: color 0.5s ease; - -ms-transition: text-shadow 0.5s ease; -} - -#main_content a:hover { - color: #0069ba; - text-shadow: #0090ff 0px 0px 2px; -} - -footer a:hover { - color: #43adff; - text-shadow: #0090ff 0px 0px 2px; -} - -em { - font-style: italic; -} - -strong { - font-weight: bold; -} - -img { - position: relative; - margin: 0 auto; - max-width: 739px; - padding: 5px; - margin: 10px 0 10px 0; - border: 1px solid #ebebeb; - - box-shadow: 0 0 5px #ebebeb; - -webkit-box-shadow: 0 0 5px #ebebeb; - -moz-box-shadow: 0 0 5px #ebebeb; - -o-box-shadow: 0 0 5px #ebebeb; - -ms-box-shadow: 0 0 5px #ebebeb; -} - -pre, code { - width: 100%; - color: #222; - background-color: #fff; - - font-family: Monaco, "Bitstream Vera Sans Mono", "Lucida Console", Terminal, monospace; - font-size: 14px; - - border-radius: 2px; - -moz-border-radius: 2px; - -webkit-border-radius: 2px; - - - -} - -pre { - width: 100%; - padding: 10px; - box-shadow: 0 0 10px rgba(0,0,0,.1); - overflow: auto; -} - -code { - padding: 3px; - margin: 0 3px; - box-shadow: 0 0 10px rgba(0,0,0,.1); -} - -pre code { - display: block; - box-shadow: none; -} - -blockquote { - color: #666; - margin-bottom: 20px; - padding: 0 0 0 20px; - border-left: 3px solid #bbb; -} - -ul, ol, dl { - margin-bottom: 15px -} - -ul li { - list-style: inside; - padding-left: 20px; -} - -ol li { - list-style: decimal inside; - padding-left: 20px; -} - -dl dt { - font-weight: bold; -} - -dl dd { - padding-left: 20px; - font-style: italic; -} - -dl p { - padding-left: 20px; - font-style: italic; -} - -hr { - height: 1px; - margin-bottom: 5px; - border: none; - background: url('../img/bg_hr.png') repeat-x center; -} - -table { - border: 1px solid #373737; - margin-bottom: 20px; - text-align: left; - } - -th { - font-family: 'Lucida Grande', 'Helvetica Neue', Helvetica, Arial, sans-serif; - padding: 10px; - background: #373737; - color: #fff; - } - -td { - padding: 10px; - border: 1px solid #373737; - } - -form { - background: #f2f2f2; - padding: 20px; -} - -img { - width: 100%; - max-width: 100%; -} - -/******************************************************************************* -Full-Width Styles -*******************************************************************************/ - -.outer { - width: 100%; -} - -.inner { - position: relative; - max-width: 640px; - padding: 20px 10px; - margin: 0 auto; -} - -#forkme_banner { - display: block; - position: absolute; - top:0; - right: 10px; - z-index: 10; - padding: 10px 50px 10px 10px; - color: #fff; - background: url('../img/blacktocat.png') #0090ff no-repeat 95% 50%; - font-weight: 700; - box-shadow: 0 0 10px rgba(0,0,0,.5); - border-bottom-left-radius: 2px; - border-bottom-right-radius: 2px; -} - -#header_wrap { - background: #212121; - background: -moz-linear-gradient(top, #373737, #212121); - background: -webkit-linear-gradient(top, #373737, #212121); - background: -ms-linear-gradient(top, #373737, #212121); - background: -o-linear-gradient(top, #373737, #212121); - background: linear-gradient(top, #373737, #212121); -} - -#header_wrap .inner { - padding: 50px 10px 30px 10px; -} - -#project_title { - margin: 0; - color: #fff; - font-size: 42px; - font-weight: 700; - text-shadow: #111 0px 0px 10px; -} - -#project_tagline { - color: #fff; - font-size: 24px; - font-weight: 300; - background: none; - text-shadow: #111 0px 0px 10px; -} - -#downloads { - position: absolute; - width: 210px; - z-index: 10; - bottom: -40px; - right: 0; - height: 70px; - background: url('../img/icon_download.png') no-repeat 0% 90%; -} - -.zip_download_link { - display: block; - float: right; - width: 90px; - height:70px; - text-indent: -5000px; - overflow: hidden; - background: url(../img/sprite_download.png) no-repeat bottom left; -} - -.tar_download_link { - display: block; - float: right; - width: 90px; - height:70px; - text-indent: -5000px; - overflow: hidden; - background: url(../img/sprite_download.png) no-repeat bottom right; - margin-left: 10px; -} - -.zip_download_link:hover { - background: url(../img/sprite_download.png) no-repeat top left; -} - -.tar_download_link:hover { - background: url(../img/sprite_download.png) no-repeat top right; -} - -#main_content_wrap { - background: #f2f2f2; - border-top: 1px solid #111; - border-bottom: 1px solid #111; -} - -#main_content { - padding-top: 40px; -} - -#footer_wrap { - background: #212121; -} - - - -/******************************************************************************* -Small Device Styles -*******************************************************************************/ - -@media screen and (max-width: 480px) { - body { - font-size:14px; - } - - #downloads { - display: none; - } - - .inner { - min-width: 320px; - max-width: 480px; - } - - #project_title { - font-size: 32px; - } - - h1 { - font-size: 28px; - } - - h2 { - font-size: 24px; - } - - h3 { - font-size: 21px; - } - - h4 { - font-size: 18px; - } - - h5 { - font-size: 14px; - } - - h6 { - font-size: 12px; - } - - code, pre { - min-width: 320px; - max-width: 480px; - font-size: 11px; - } - -} diff --git a/static/css/style.styl b/static/css/style.styl new file mode 100644 index 0000000..7d89d1e --- /dev/null +++ b/static/css/style.styl @@ -0,0 +1,96 @@ +@import 'normalize'; +@import 'makeover/vendor' +@import 'pygment_trac' + +// main banner +$color-heading = #9FC8DF + +// light background color +$color-bg = #FBF4F4 + +// main font color +$color-font = #0B1622 + +// sanity +* { + box-sizing: border-box; +} + +// weights +// 300 - light +// 400 - normal +// 600 - semi-bold + +html, body { + margin: 0; + padding: 0; +} + +body { + font-family: 'Open Sans', sans-serif; + font-weight: 400; + color: $color-font; + background-color: $color-bg; +} + +.container { + max-width: 675px; + margin: 0 auto; +} + +h3 { + font-weight: 300; +} + +.header { + padding: 10px; + position: relative; + background-color: $color-heading; + color: #ffffff; + font-size: 20px; + + h1 { + font-weight: bold; + } + + h2, h3 { + font-weight: 300; + } +} + +.footer { + padding: 10px; + position: relative; + background-color: $color-heading; + color: #ffffff; +} + +.highlight { + padding: 10px; + background-color: white; + border-radius: 2px; + + pre { + padding: 0; + margin: 0; + } +} + +a, a:visited, a:active { + color: $color-font; + text-decoration: none; +} + +.sponsors { + float: right; + + h3 { + padding: 3px; + margin: 3px; + } +} + +.fa { + min-width: 1em; + text-align: center; +} diff --git a/static/fonts/FontAwesome.otf b/static/fonts/FontAwesome.otf new file mode 100644 index 0000000000000000000000000000000000000000..8b0f54e47e1d356dcf1496942a50e228e0f1ee14 GIT binary patch literal 62856 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