diff --git a/hn-server-fetch.js b/hn-server-fetch.js new file mode 100644 index 0000000..3872e61 --- /dev/null +++ b/hn-server-fetch.js @@ -0,0 +1,100 @@ +require('isomorphic-fetch') + +/* +The official Firebase API (https://github.com/HackerNews/API) requires multiple network +connections to be made in order to fetch the list of Top Stories (indices) and then the +summary content of these stories. Directly requesting these resources makes server-side +rendering cumbersome as it is slow and ultimately requires that you maintain your own +cache to ensure full server renders are efficient. + +To work around this problem, we can use one of the unofficial Hacker News APIs, specifically +https://github.com/cheeaun/node-hnapi which directly returns the Top Stories and can cache +responses for 10 minutes. In ReactHN, we can use the unofficial API for a static server-side +render and then 'hydrate' this once our components have mounted to display the real-time +experience. + +The benefit of this is clients loading up the app that are on flakey networks (or lie-fi) +can still get a fast render of content before the rest of our JavaScript bundle is loaded. + */ + +/** + * Fetch top stories + */ +exports.fetchNews = function(page) { + page = page || '' + return fetch('http://node-hnapi.herokuapp.com/news' + page).then(function(response) { + return response.json() + }).then(function(json) { + var stories = '<ol class="Items__list" start="1">' + json.forEach(function(data, index) { + var story = '<li class="ListItem" style="margin-bottom: 16px;">' + + '<div class="Item__title" style="font-size: 18px;"><a href="' + data.url + '">' + data.title + '</a> ' + + '<span class="Item__host">(' + data.domain + ')</span></div>' + + '<div class="Item__meta"><span class="Item__score">' + data.points + ' points</span> ' + + '<span class="Item__by">by <a href="https://news.ycombinator.com/user?id=' + data.user + '">' + data.user + '</a></span> ' + + '<time class="Item__time">' + data.time_ago + ' </time> | ' + + '<a href="/news/story/' + data.id + '">' + data.comments_count + ' comments</a></div>' + '</li>' + stories += story + }) + stories += '</ol>' + return stories + }) +} + +function renderNestedComment(data) { + return '<div class="Comment__kids">' + + '<div class="Comment Comment--level1">' + + '<div class="Comment__content">' + + '<div class="Comment__meta"><span class="Comment__collapse" tabindex="0">[–]</span> ' + + '<a class="Comment__user" href="#/user/' + data.user + '">' + data.user + '</a> ' + + '<time>' + data.time_ago + '</time> ' + + '<a href="#/comment/' + data.id + '">link</a></div> ' + + '<div class="Comment__text">' + + '<div>' + data.content +'</div> ' + + '<p><a href="https://news.ycombinator.com/reply?id=' + data.id + '">reply</a></p>' + + '</div>' + + '</div>' + + '</div>' + + '</div>' +} + +function generateNestedCommentString(data) { + var output = '' + data.comments.forEach(function(comment) { + output+= renderNestedComment(comment) + if (comment.comments) { + output+= generateNestedCommentString(comment) + } + }) + return output +} + +/** + * Fetch details of the story/post/item with (nested) comments + * TODO: Add article summary at top of nested comment thread + */ +exports.fetchItem = function(itemId) { + return fetch('https://node-hnapi.herokuapp.com/item/' + itemId).then(function(response) { + return response.json() + }).then(function(json) { + var comments = '' + json.comments.forEach(function(data, index) { + var comment = '<div class="Item__kids">' + + '<div class="Comment Comment--level0">' + + '<div class="Comment__content">' + + '<div class="Comment__meta"><span class="Comment__collapse" tabindex="0">[–]</span> ' + + '<a class="Comment__user" href="#/user/' + data.user + '">' + data.user + '</a> ' + + '<time>' + data.time_ago + '</time> ' + + '<a href="#/comment/' + data.id + '">link</a></div> ' + + '<div class="Comment__text">' + + '<div>' + data.content +'</div> ' + + '<p><a href="https://news.ycombinator.com/reply?id=' + data.id + '">reply</a></p>' + + '</div>' + + '</div>' + + '</div>' + comments += generateNestedCommentString(data) + '</div>' + comment + }) + return comments + }) +} \ No newline at end of file diff --git a/package.json b/package.json index 3279d35..e918b28 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,14 @@ "main": "server.js", "dependencies": { "ejs": "^2.4.1", + "eslint-config-jonnybuchanan": "2.0.3", "events": "1.1.0", "express": "^4.13.4", "firebase": "2.4.2", "history": "2.1.1", "isomorphic-fetch": "^2.2.1", + "nwb": "0.8.1", + "object-assign": "^4.1.0", "react": "15.0.2", "react-dom": "15.0.2", "react-router": "2.4.0", @@ -36,10 +39,8 @@ "reactfire": "0.7.0", "scroll-behavior": "0.5.0", "setimmediate": "1.0.4", - "url-parse": "^1.1.1", - "eslint-config-jonnybuchanan": "2.0.3", - "nwb": "0.8.1", "sw-precache": "^3.1.1", - "sw-toolbox": "^3.1.1" + "sw-toolbox": "^3.1.1", + "url-parse": "^1.1.1" } } diff --git a/server.js b/server.js index dda4625..d163804 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,8 @@ var express = require('express') var React = require('react') var renderToString = require('react-dom/server').renderToString var ReactRouter = require('react-router') +var objectAssign = require('object-assign') +var HNServerFetch = require('./hn-server-fetch') require('babel/register') var routes = require('./src/routes') @@ -12,6 +14,47 @@ app.set('views', process.cwd() + '/src/views') app.set('port', (process.env.PORT || 5000)) app.use(express.static('public')) + +app.get(['/', '/news'], function(req, res) { + ReactRouter.match({ + routes: routes, + location: req.url + }, function(err, redirectLocation, props) { + if (err) { + res.status(500).send(err.message) + } + else if (redirectLocation) { + res.redirect(302, redirectLocation.pathname + redirectLocation.search) + } + else if (props) { + HNServerFetch.fetchNews().then(function(stories) { + objectAssign(props.params, { prebootHTML: stories }) + var markup = renderToString(React.createElement(ReactRouter.RouterContext, props, null)) + res.render('index', { markup: markup }) + }) + } + else { + res.sendStatus(404) + } + }) +}) + +app.get('/news/story/:id', function (req, res, next) { + var storyId = req.params.id + ReactRouter.match({ + routes: routes, + location: req.url + }, function(err, redirectLocation, props) { + if (storyId) { + HNServerFetch.fetchItem(storyId).then(function(comments) { + objectAssign(props.params, { prebootHTML: comments }) + var markup = renderToString(React.createElement(ReactRouter.RouterContext, props, null)) + res.render('index', { markup: markup }) + }) + } + }) +}); + app.get('*', function(req, res) { ReactRouter.match({ routes: routes, @@ -24,10 +67,8 @@ app.get('*', function(req, res) { res.redirect(302, redirectLocation.pathname + redirectLocation.search) } else if (props) { - var markup = renderToString( - React.createElement(ReactRouter.RouterContext, props, null) - ) - res.render('index', { markup: markup }) + var markup = renderToString(React.createElement(ReactRouter.RouterContext, props, null)) + res.render('index', { markup: markup }) } else { res.sendStatus(404) diff --git a/src/App.js b/src/App.js index 15aaa7a..ea9e480 100644 --- a/src/App.js +++ b/src/App.js @@ -10,7 +10,9 @@ var SettingsStore = require('./stores/SettingsStore') var App = React.createClass({ getInitialState() { return { - showSettings: false + showSettings: false, + showChildren: false, + prebootHTML: this.props.params.prebootHTML } }, @@ -22,6 +24,11 @@ var App = React.createClass({ window.addEventListener('beforeunload', this.handleBeforeUnload) }, + componentDidMount() { + // Empty the prebooted HTML and hydrate using live results from Firebase + this.setState({ prebootHTML: '', showChildren: true }) + }, + componentWillUnmount() { if (typeof window === 'undefined') return window.removeEventListener('beforeunload', this.handleBeforeUnload) @@ -58,7 +65,8 @@ var App = React.createClass({ {this.state.showSettings && <Settings key="settings"/>} </div> <div className="App__content"> - {this.props.children} + <div dangerouslySetInnerHTML={{ __html: this.state.prebootHTML }}/> + {this.state.showChildren ? this.props.children : ''} </div> <div className="App__footer"> <a href="https://github.com/insin/react-hn">insin/react-hn</a> diff --git a/src/Stories.js b/src/Stories.js index c60d523..dcc5368 100644 --- a/src/Stories.js +++ b/src/Stories.js @@ -67,7 +67,7 @@ var Stories = React.createClass({ // Display a list of placeholder items while we're waiting for the initial // list of story ids to load from Firebase. - if (this.state.stories.length === 0 && this.state.ids.length === 0) { + if (this.state.stories.length === 0 && this.state.ids.length === 0 && this.getPageNumber() > 0) { var dummyItems = [] for (var i = page.startIndex; i < page.endIndex; i++) { dummyItems.push( diff --git a/src/views/index.ejs b/src/views/index.ejs index 202afd3..0bfda24 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -29,7 +29,7 @@ <meta name="msapplication-TileColor" content="#222222"> <meta name="msapplication-TileImage" content="img/mstile-144x144.png"> <meta name="msapplication-config" content="img/browserconfig.xml"> - + <base href="/"> <link rel="stylesheet" href="css/style.css"> </head> <body>