├── .gitignore ├── README.md ├── boot.js ├── config └── firebase.js ├── package.json ├── server.js ├── store.js ├── stores ├── firebase-collection.js └── top-story.js └── util ├── bind.js └── not-found-error.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore docs files 2 | _gh_pages 3 | _site 4 | .ruby-version 5 | 6 | # Numerous always-ignore extensions 7 | *.diff 8 | *.err 9 | *.orig 10 | *.log 11 | *.rej 12 | *.swo 13 | *.swp 14 | *.zip 15 | *.vi 16 | *~ 17 | 18 | # OS or Editor folders 19 | .DS_Store 20 | ._* 21 | Thumbs.db 22 | .cache 23 | .project 24 | .settings 25 | .tmproj 26 | *.esproj 27 | nbproject 28 | *.sublime-project 29 | *.sublime-workspace 30 | .idea 31 | 32 | # Komodo 33 | *.komodoproject 34 | .komodotools 35 | 36 | # Folders to ignore 37 | node_modules 38 | bower_components 39 | 40 | store/ 41 | db/ 42 | config.json 43 | processes.json 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hacker-news-mobile-api 2 | 3 | Turns official Hacker News Firebase API into a REST JSON API. Used by [hacker-news-mobile](https://github.com/jsdf/hacker-news-mobile) -------------------------------------------------------------------------------- /boot.js: -------------------------------------------------------------------------------- 1 | require('babel/register') 2 | require('./server') -------------------------------------------------------------------------------- /config/firebase.js: -------------------------------------------------------------------------------- 1 | var Firebase = require('firebase') 2 | var urlJoin = require('url-join') 3 | 4 | var BASE_URL = 'https://hacker-news.firebaseio.com/v0' 5 | 6 | function getFirebase(...itemPath) { 7 | return new Firebase(urlJoin(BASE_URL, ...itemPath)) 8 | } 9 | 10 | module.exports = getFirebase 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hacker-news-mobile-api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node boot.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "James Friend (http://jsdf.co/)", 11 | "license": "ISC", 12 | "dependencies": { 13 | "babel": "^4.6.3", 14 | "cors": "^2.5.3", 15 | "express": "^4.12.1", 16 | "firebase": "^2.2.1", 17 | "http2": "^3.2.0", 18 | "leveldown": "^1.4.1", 19 | "levelup": "^1.2.1", 20 | "morgan": "^1.6.1", 21 | "standard-error": "^1.1.0", 22 | "underscore": "^1.8.2", 23 | "url-join": "0.0.1" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var http2 = require('http2') 3 | var express = require('express') 4 | var _ = require('underscore') 5 | var cors = require('cors') 6 | var morgan = require('morgan') 7 | 8 | var Store = require('./store') 9 | 10 | var config = require('./config.json') 11 | 12 | var app = express() 13 | app.set('trust proxy', 'loopback') 14 | app.use(cors()) 15 | app.use(morgan('combined')) 16 | 17 | var store = new Store() 18 | 19 | const MAX_ITEMS = 50 20 | 21 | app.get('/', function (req, res) { 22 | res.json(_.first(store.topStories.ordered(), MAX_ITEMS)) 23 | }) 24 | 25 | app.get('/item/:id', function (req, res) { 26 | store.getNested(req.params.id, (err, item) => { 27 | if (err) return res.sendStatus(err.notFound ? 404 : 500) 28 | else return res.json(item) 29 | }) 30 | }) 31 | 32 | const LISTEN_PORT = process.env.PORT || config.port || 3030 33 | var server = app.listen(LISTEN_PORT, () => { 34 | var host = server.address().address 35 | var port = server.address().port 36 | console.log('listening at http://%s:%s', host, port) 37 | }) 38 | 39 | // var http2Opts = { 40 | // key: fs.readFileSync('./localhost.key'), 41 | // cert: fs.readFileSync('./localhost.crt') 42 | // } 43 | 44 | // var server = http2.createServer(http2Opts, app) 45 | // server.listen(3030, function () { 46 | // console.log('app listening on '+LISTEN_PORT) 47 | // }) 48 | -------------------------------------------------------------------------------- /store.js: -------------------------------------------------------------------------------- 1 | var levelup = require('levelup') 2 | var TopStories = require('./stores/top-story') 3 | var bind = require('./util/bind') 4 | var db = levelup('./db') 5 | 6 | class Store { 7 | constructor() { 8 | this.topStories = new TopStories() 9 | this.topStories.on('change', () => this.persistState()) 10 | } 11 | persistState() { 12 | var batch = db.batch() 13 | this.topStories.all().forEach(item => batch.put(item.id, this.topStories.getNested(item.id))) 14 | batch.write() 15 | } 16 | getNested(id, cb) { 17 | var itemCached = this.topStories.getNested(id) 18 | if (itemCached) { 19 | cb(null, itemCached) 20 | } else { 21 | db.get(id, cb) 22 | } 23 | } 24 | } 25 | 26 | module.exports = Store 27 | -------------------------------------------------------------------------------- /stores/firebase-collection.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore') 2 | var {EventEmitter} = require('events') 3 | 4 | var getFirebase = require('../config/firebase') 5 | var bind = require('../util/bind') 6 | 7 | // TODO: move batching somewhere sensible 8 | var BATCH_INTERVAL = 100 9 | 10 | class FirebaseCollectionStore extends EventEmitter { 11 | constructor(itemPath) { 12 | this.emitChange = _.debounce(() => this.emit('change'), BATCH_INTERVAL) 13 | this.handleItemUpdate = bind(this.handleItemUpdate, this) 14 | 15 | this.itemPath = itemPath 16 | this.items = {} 17 | this.itemFirebases = {} 18 | } 19 | emitChange() { 20 | throw new Error('method not yet defined') 21 | } 22 | reset(items) { 23 | this.items = items 24 | _.each(items, (item) => this.addItem(item.id)) 25 | } 26 | handleItemUpdate(dataSnapshot) { 27 | var item = dataSnapshot.val() 28 | if (!(item && item.id != null)) return 29 | var prevItem = this.items[item.id] 30 | this.items[item.id] = item 31 | if (item.kids) { 32 | var prevKids = prevItem && prevItem.kids || [] 33 | this.handleCollectionUpdate(item.kids, prevKids) 34 | } 35 | this.emitChange() 36 | } 37 | handleCollectionUpdate(current, previous) { 38 | var added = _.difference(current, previous) 39 | var removed = _.difference(previous, current) 40 | 41 | added.forEach(itemId => this.addItem(itemId)) 42 | removed.forEach(itemId => this.removeItem(itemId)) 43 | } 44 | addItem(itemId) { 45 | if (this.itemFirebases[itemId]) return 46 | 47 | var itemFirebase = getFirebase(this.itemPath, itemId) 48 | itemFirebase.on('value', this.handleItemUpdate) 49 | this.itemFirebases[itemId] = itemFirebase 50 | this.emitChange() 51 | } 52 | removeItem(itemId) { 53 | if (!this.itemFirebases[itemId]) return 54 | 55 | var itemFirebase = this.itemFirebases[itemId] 56 | itemFirebase.off('value', this.handleItemUpdate) 57 | delete this.itemFirebases[itemId] 58 | delete this.items[itemId] 59 | this.emitChange() 60 | } 61 | get(id) { 62 | return this.items[id] 63 | } 64 | getNested(id) { 65 | var itemNested 66 | var item = this.items[id] 67 | if (item && item.kids) { 68 | itemNested = Object.assign({}, item, { 69 | childItems: item.kids.map(childId => this.getNested(childId)).filter(Boolean) 70 | }) 71 | } 72 | return itemNested || item 73 | } 74 | all() { 75 | return _.values(this.items) 76 | } 77 | toJSON() { 78 | return this.items 79 | } 80 | } 81 | 82 | module.exports = FirebaseCollectionStore 83 | -------------------------------------------------------------------------------- /stores/top-story.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore') 2 | 3 | var getFirebase = require('../config/firebase') 4 | var bind = require('../util/bind') 5 | var FirebaseCollectionStore = require('./firebase-collection') 6 | 7 | class TopStoryStore extends FirebaseCollectionStore { 8 | constructor(orderPath = '/topstories', itemPath = '/item') { 9 | this.handleOrderUpdate = bind(this.handleOrderUpdate, this) 10 | this.cleanupItems = false 11 | 12 | super(itemPath) 13 | 14 | this.orderPath = orderPath || itemPath 15 | this.orderIds = [] 16 | this.orderFirebase = getFirebase(this.orderPath) 17 | this.orderFirebase.on('value', this.handleOrderUpdate) 18 | } 19 | reset({items, orderIds}) { 20 | super.reset(items) 21 | this.orderIds = orderIds 22 | orderIds.forEach((orderId) => this.addItem(orderId)) 23 | } 24 | handleOrderUpdate(dataSnapshot) { 25 | var currentOrderIds = dataSnapshot.val() 26 | var previousOrderIds = this.orderIds 27 | this.handleCollectionUpdate(currentOrderIds, previousOrderIds) 28 | 29 | this.orderIds = currentOrderIds 30 | this.emitChange() 31 | } 32 | getCurrentIds() { 33 | return this.orderIds 34 | } 35 | ordered() { 36 | return this.orderIds.reduce((orderedItems, itemId, index) => { 37 | var item = this.get(itemId) 38 | if (item) { 39 | orderedItems.push(Object.assign({position: index+1}, item)) 40 | } 41 | return orderedItems 42 | }, []) 43 | } 44 | toJSON() { 45 | return { 46 | items: this.items, 47 | orderIds: this.orderIds, 48 | } 49 | } 50 | } 51 | 52 | module.exports = TopStoryStore 53 | -------------------------------------------------------------------------------- /util/bind.js: -------------------------------------------------------------------------------- 1 | function bind(fn, me) { 2 | return function() { return fn.apply(me, arguments) } 3 | } 4 | 5 | module.exports = bind 6 | -------------------------------------------------------------------------------- /util/not-found-error.js: -------------------------------------------------------------------------------- 1 | var StandardError = require('standard-error') 2 | 3 | class NotFoundError extends StandardError {} 4 | 5 | module.exports = NotFoundError --------------------------------------------------------------------------------