├── LICENSE.txt ├── README.md ├── app ├── server.js └── views │ └── cache.ejs ├── circle.yml ├── data └── .keep ├── index.js ├── package.json ├── tests ├── helpers.js └── unit │ ├── config.test.js │ └── server.test.js └── util ├── db.js └── getConfig.js /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Jeremia Kimelman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :airplane: airplane-mode 2 | [![CircleCI](https://circleci.com/gh/jeremiak/airplane-mode.svg?style=svg)](https://circleci.com/gh/jeremiak/airplane-mode) [![npm version](https://badge.fury.io/js/airplane-mode.svg)](https://badge.fury.io/js/airplane-mode) 3 | 4 | An easy-to-use http cache inspired by [Runscope](http://www.runscope.com) and in-flight WiFi 5 | 6 | ## Purpose 7 | 8 | Provide an easy to set up http cache for offline development. 9 | 10 | With `airplane-mode` you can cache any url (headers and response) easily so that when your internet connection isn't reliable you can still develop features. 11 | 12 | ## Wait, but why? 13 | 14 | Over the past few weeks I've spent quite a bit of time on airplanes. On my last trip, I wanted to write a bit of client-side d3 but because the page required an API response for the data, I was stuck with either paying for expensive (yet shitty) airplane internet or just not doing development. Those seemed like a bad set of options. 15 | 16 | `airplane-mode` is a quick solution to that problem. Now I/you can easily cache a few responses you know you'll need for offline development and then not worry about connectivity. 17 | 18 | ## Installation & usage 19 | 20 | Run `npm install -g airplane-mode` 21 | 22 | Once installed, just use the command `airplane-mode` to run. `airplane-mode` accepts a few flags 23 | 24 | * `--clear-cache` will clear the entire cache when the server starts 25 | * `--cors` will force every response to have `Access-Control-Allow-Origin` set to `'*'` 26 | * `--port 3000` will set up the server at port `3000` if it is available 27 | 28 | Now you can easily populate your cache, by prefixing all of the requests you want cached with `http://0.0.0.0:3000`. 29 | 30 | For example, if I wanted the JSON from [http://www.theschmearcampaign.com/api/bakers.json](`http://www.theschmearcampaign.com/api/bakers.json`) to be available through the cache, I simply need to make a request to `http://0.0.0.0:3000/http://www.theschmearcampaign.com/api/bakers.json` 31 | 32 | The cache is persistent using LevelDB. You can reset the cache in two ways: 33 | 34 | 1. `DELETE http://0.0.0.0:3000/cache/:url` will remove the matching entry 35 | 2. `DELETE http://0.0.0.0:3000/cache` will remove all entries 36 | 3. `airplane-mode --clear-cache` will remove all entries and start the server 37 | 38 | If you want to take a look at the current contents of the cache at anytime, just visit `http://0.0.0.0:3000/cache` 39 | -------------------------------------------------------------------------------- /app/server.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | 3 | var express = require('express') 4 | var request = require('request') 5 | var db = require('../util/db.js') 6 | 7 | var app = express(), server 8 | 9 | app.set('view engine', 'ejs'); 10 | app.set('views', path.join(__dirname, '/views')); 11 | 12 | function addCorsHeader(res) { 13 | res.set({ 'Access-Control-Allow-Origin': '*'}) 14 | return res 15 | } 16 | 17 | function fetchUrlAndCache(url, cb) { 18 | console.log(`making request for ${url}`) 19 | request.get(url, function (err, res, body) { 20 | if (err) return cb(err) 21 | var data = { 22 | headers: res.headers, 23 | body 24 | } 25 | 26 | db.putJSON(url, data, (err) => { 27 | if (err) return cb(err) 28 | 29 | cb(null, data) 30 | }) 31 | }) 32 | } 33 | 34 | function makeUrl(req) { 35 | var headers = req.headers 36 | var isAsset = (headers.referer) ? true : false 37 | var path = `${req.originalUrl.slice(1)}` 38 | 39 | if (!isAsset) return path 40 | 41 | var referrer = removeProxyUrl(headers.referer) 42 | return `${referrer}/${path}` 43 | } 44 | 45 | function removeProxyUrl(url) { 46 | return url.replace(/^http:\/\/\w+:\d+\//, '') 47 | } 48 | 49 | function respondWithCachedUrl(res, data, cors) { 50 | if (cors) addCorsHeader(res) 51 | res.set(data.headers) 52 | return res.send(data.body) 53 | } 54 | 55 | app.get('/', function(req, res) { 56 | res.redirect('/cache') 57 | }) 58 | 59 | app.get('/favicon.ico', function(req, res) { 60 | res.sendStatus(404) 61 | }) 62 | 63 | app.get('/cache', function (req, res) { 64 | db.dumpKeys(function (keys) { 65 | res.render('cache', { keys }) 66 | }) 67 | }) 68 | 69 | app.get('/cache/keys.json', function(req, res) { 70 | db.dumpKeys(function (data) { 71 | res.send(data) 72 | }) 73 | }) 74 | 75 | app.delete('/cache', function (req, res) { 76 | console.log('DELETE all') 77 | db.clear((err) => { 78 | if (err) return res.send(err) 79 | return res.sendStatus(200) 80 | }) 81 | }) 82 | 83 | app.delete('/*', function (req, res) { 84 | var url = makeUrl(req) 85 | console.log(`DELETE ${url}`) 86 | db.del(url, (err) => { 87 | if (err) return res.send(err) 88 | return res.sendStatus(200) 89 | }) 90 | }) 91 | 92 | app.get('/*', function(req, res) { 93 | var cors = app.get('FORCE_CORS') 94 | var url = makeUrl(req) 95 | console.log(`GET ${url}`) 96 | console.log(`req.originalUrl ${req.originalUrl}`) 97 | db.getJSON(url, function (err, data) { 98 | if (!err) { 99 | console.log('\turl is cached and it was successfully retrieved') 100 | return respondWithCachedUrl(res, data, cors) 101 | } else if (err && err.type === 'NotFoundError') { 102 | console.log('\turl is NOT cached and it being retrieved') 103 | fetchUrlAndCache(url, (err, data) => { 104 | if (err) return res.send(err) 105 | if (cors) addCorsHeader(res) 106 | console.log('\turl is has been cached') 107 | res.set(data.headers) 108 | return res.send(data.body) 109 | }) 110 | } else { 111 | return res.send(err) 112 | } 113 | }) 114 | }) 115 | 116 | module.exports = app 117 | 118 | if (process.env.NODE_TEST) { 119 | module.exports = Object.assign({}, module.exports, { 120 | addCorsHeader, 121 | fetchUrlAndCache, 122 | makeUrl, 123 | removeProxyUrl, 124 | respondWithCachedUrl 125 | }) 126 | } 127 | -------------------------------------------------------------------------------- /app/views/cache.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Cached Urls 5 | 21 | 22 | 23 |
24 |

airplane-mode

25 |

Currently cached:

26 | 31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.2.2 4 | environment: 5 | NPM_USERNAME: jeremiak 6 | deployment: 7 | npm: 8 | tag: /v[0-9]+(\.[0-9]+)*/ 9 | commands: 10 | - echo -e "$NPM_USERNAME\n$NPM_PASSWORD\n$NPM_EMAIL" | npm login 11 | - npm run publish 12 | -------------------------------------------------------------------------------- /data/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremiak/airplane-mode/c9d591bd7c5d2bf78b7edd1c219859b77475a3e2/data/.keep -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var portfinder = require('portfinder') 4 | 5 | var app = require('./app/server.js') 6 | var db = require('./util/db.js') 7 | var getConfig = require('./util/getConfig.js') 8 | var package = require('./package.json') 9 | 10 | var config = getConfig(process.argv.slice(2)) 11 | app.set('FORCE_CORS', config.forceCors) 12 | 13 | function helpText() { 14 | var demo = 'http://www.theschmearcampaign.com' 15 | var text = [ 16 | `airplane-mode version ${package['version']}`, 17 | `${package.description}`, 18 | `\n`, 19 | `cache a URL by appending it to the airplane-mode server url.`, 20 | `for example, http://0.0.0.0:${config.port}/${demo} would cache ${demo}`, 21 | `\n`, 22 | `you can also set some options:`, 23 | ` --clear-cache will clear the cache when the server starts`, 24 | ` --cors will force every response to allow CORS requests`, 25 | ` --port ${config.port} will use ${config.port} if it is available` 26 | ].join('\n') 27 | 28 | console.log(`${text}\n`) 29 | } 30 | 31 | function runningText(host, port){ 32 | var text = [ 33 | `airplane-mode version ${package['version']}`, 34 | `listening at http://${host}:${port}`, 35 | ' run with --help for more instructions' 36 | ].join('\n') 37 | 38 | console.log(`${text}\n`) 39 | } 40 | 41 | function start() { 42 | portfinder.basePort = config.port 43 | portfinder.getPort(function (err, port) { 44 | server = app.listen(port, '0.0.0.0', function() { 45 | var host = server.address().address; 46 | runningText(host, port); 47 | }) 48 | }) 49 | } 50 | 51 | if (config.showHelp) return helpText() 52 | if (config.clearCache) return db.clear(() => start()) 53 | 54 | start() 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airplane-mode", 3 | "version": "0.0.5", 4 | "description": "quick http cache for offline development", 5 | "keywords": [ 6 | "http", 7 | "offline", 8 | "airplane", 9 | "cache" 10 | ], 11 | "main": "index.js", 12 | "scripts": { 13 | "start": "node index.js", 14 | "test": "export NODE_TEST=1 && tape tests/**/*.test.js && unset NODE_TEST" 15 | }, 16 | "bin": { 17 | "airplane-mode": "./index.js" 18 | }, 19 | "author": "jeremiak", 20 | "license": "MIT", 21 | "dependencies": { 22 | "ejs": "^2.5.2", 23 | "express": "^4.10.2", 24 | "leveldown": "^1.5.0", 25 | "levelup": "^1.3.3", 26 | "portfinder": "^0.2.1", 27 | "request": "^2.48.0" 28 | }, 29 | "bugs": { 30 | "url": "https://github.com/jeremiak/airplane-mode/issues" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/jeremiak/airplane-mode.git" 35 | }, 36 | "devDependencies": { 37 | "sinon": "^1.17.6", 38 | "tape": "^4.6.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/helpers.js: -------------------------------------------------------------------------------- 1 | function createResponse () { 2 | return { 3 | get: function () {}, 4 | send: function () {}, 5 | set: function () {} 6 | } 7 | } 8 | 9 | module.exports.createResponse = createResponse 10 | -------------------------------------------------------------------------------- /tests/unit/config.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | 3 | var getConfig = require('../../util/getConfig') 4 | 5 | test('config() with no args should return defaults', function (t) { 6 | var config = getConfig() 7 | t.plan(4) 8 | t.equal(config.clearCache, false) 9 | t.equal(config.forceCors, false) 10 | t.equal(config.port, 3000) 11 | t.equal(config.showHelp, false) 12 | }) 13 | 14 | test('config() with --clear-cache should set clearCache to true', function (t) { 15 | var args = ['--clear-cache'] 16 | var config = getConfig(args) 17 | t.plan(1) 18 | t.equal(config.clearCache, true) 19 | }) 20 | 21 | test('config() with --cors should set forceCors to true', function (t) { 22 | var args = ['--cors'] 23 | var config = getConfig(args) 24 | t.plan(1) 25 | t.equal(config.forceCors, true) 26 | }) 27 | 28 | test('config() with --port 5000 should set port to 5000', function (t) { 29 | var args = ['--port', '5000'] 30 | var config = getConfig(args) 31 | t.plan(1) 32 | t.equal(config.port, 5000) 33 | }) 34 | 35 | test('config() with --help should set showHelp to true', function (t) { 36 | var args = ['--help'] 37 | var config = getConfig(args) 38 | t.plan(1) 39 | t.equal(config.showHelp, true) 40 | }) 41 | -------------------------------------------------------------------------------- /tests/unit/server.test.js: -------------------------------------------------------------------------------- 1 | var sinon = require('sinon') 2 | var test = require('tape') 3 | 4 | var helpers = require('../helpers.js') 5 | var server = require('../../app/server.js') 6 | 7 | test('addCorsHeader() should add CORS header', function (t) { 8 | var res = helpers.createResponse() 9 | var spy = sinon.spy(res, 'set') 10 | server.addCorsHeader(res) 11 | 12 | t.plan(1) 13 | t.deepEqual({ 'Access-Control-Allow-Origin': '*' }, spy.args[0][0]) 14 | }) 15 | 16 | test('removeProxyUrl()', function (t) { 17 | var host = 'http://localhost:3000' 18 | var url = 'http://www.example.com' 19 | 20 | t.plan(2) 21 | t.equal(server.removeProxyUrl(`${host}/${url}`), url) 22 | t.equal(server.removeProxyUrl(`${url}`), url) 23 | }) 24 | 25 | test('respondWithCachedUrl without cors should set headers and body', function (t) { 26 | var data = { 27 | headers: { 28 | 'test-header': 'foo' 29 | }, 30 | body: 'fake data here' 31 | } 32 | var res = helpers.createResponse() 33 | var sendSpy = sinon.spy(res, 'send') 34 | var setSpy = sinon.spy(res, 'set') 35 | server.respondWithCachedUrl(res, data) 36 | 37 | t.plan(2) 38 | t.deepEqual(data.body, sendSpy.args[0][0]) 39 | t.deepEqual(data.headers, setSpy.args[0][0]) 40 | }) 41 | 42 | test('respondWithCachedUrl with cors should set headers and body and cors', function (t) { 43 | var data = { 44 | headers: { 45 | 'test-header': 'foo' 46 | }, 47 | body: 'fake data here' 48 | } 49 | var res = helpers.createResponse() 50 | var spy = sinon.spy(res, 'set') 51 | server.respondWithCachedUrl(res, data, true) 52 | 53 | t.plan(1) 54 | t.deepEqual(2, spy.callCount) 55 | }) 56 | -------------------------------------------------------------------------------- /util/db.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var levelup = require('levelup') 3 | 4 | var DB_PATH = path.join(__dirname, '../data/url-cache.db') 5 | var db = levelup(DB_PATH, { 6 | valueEncoding: 'json' 7 | }) 8 | 9 | function DB() { 10 | 11 | } 12 | 13 | DB.prototype = db 14 | 15 | DB.prototype.clear = function clear(cb) { 16 | this.dumpKeys((keys) => { 17 | if (!keys) return cb() 18 | var ops = keys.map((key) => { 19 | return {type: 'del', key } 20 | }) 21 | 22 | db.batch(ops, (err) => { 23 | if (err) return cb(err) 24 | cb() 25 | }) 26 | }) 27 | } 28 | 29 | DB.prototype.dump = function dump(cb) { 30 | var dumped = {} 31 | db.createReadStream({ keys: true, values: true }) 32 | .on('data', function (data) { 33 | dumped[data.key] = data.value 34 | }) 35 | .on('close', function () { 36 | if (cb) return cb(dumped) 37 | console.log('database dump', dumped) 38 | }) 39 | } 40 | 41 | DB.prototype.dumpKeys = function dump(cb) { 42 | var keys = [] 43 | db.createReadStream({ keys: true, values: false }) 44 | .on('data', function (data) { 45 | keys.push(data) 46 | }) 47 | .on('close', function () { 48 | if (cb) return cb(keys) 49 | console.log('keys dump', keys) 50 | }) 51 | } 52 | 53 | DB.prototype.getJSON = function getJSON(key, cb) { 54 | db.get(key, (err, data) => { 55 | if (err) return cb(err) 56 | 57 | cb(null, JSON.parse(data)) 58 | }) 59 | } 60 | 61 | DB.prototype.putJSON = function putJSON(key, value, cb) { 62 | db.put(key, JSON.stringify(value), (err, data) => { 63 | if (err) return cb(err) 64 | 65 | cb(null, value) 66 | }) 67 | } 68 | 69 | module.exports = new DB() 70 | -------------------------------------------------------------------------------- /util/getConfig.js: -------------------------------------------------------------------------------- 1 | function configFromArgs(args = []) { 2 | var clearFlag = (args.indexOf('--clear-cache') > -1) 3 | var corsFlag = (args.indexOf('--cors') > -1) 4 | var helpFlag = (args.indexOf('--help') > -1) 5 | var portFlag = (args.indexOf('--port') > -1) 6 | 7 | return { 8 | clearCache: (clearFlag) ? true: false, 9 | forceCors: (corsFlag) ? true : false, 10 | port: (portFlag) ? parseInt(args[args.indexOf('--port')+1]) : 3000, 11 | showHelp: (helpFlag) ? true : false 12 | } 13 | } 14 | 15 | module.exports = configFromArgs 16 | --------------------------------------------------------------------------------