Skip to content

Commit

Permalink
Add support for server-side rendering Top Stories & Comments (#47)
Browse files Browse the repository at this point in the history
* index.ejs: set base href to fix asset paths for SSR views

* package.json: add object-assign shim

* App.js: support prebooted HTML / hydration of content

* Adds hn-server-fetch using unofficial Firebase API

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.

* server.js: add support for SSR render of top stories and comments

* hn-server-fetch: add support for SSR nested comments

* hn-server-fetch: remove duplicate time indication

* Stories.js: display spinner for pages > 0
  • Loading branch information
addyosmani authored Jun 23, 2016
1 parent ebc124c commit f05849d
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 12 deletions.
100 changes: 100 additions & 0 deletions hn-server-fetch.js
Original file line number Diff line number Diff line change
@@ -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
})
}
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,23 @@
"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",
"react-timeago": "3.0.0",
"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"
}
}
49 changes: 45 additions & 4 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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,
Expand All @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},

Expand All @@ -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)
Expand Down Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion src/Stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/views/index.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down

0 comments on commit f05849d

Please sign in to comment.