Skip to content

Commit f05849d

Browse files
authored
Add support for server-side rendering Top Stories & Comments (#47)
* 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
1 parent ebc124c commit f05849d

File tree

6 files changed

+162
-12
lines changed

6 files changed

+162
-12
lines changed

hn-server-fetch.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
require('isomorphic-fetch')
2+
3+
/*
4+
The official Firebase API (https://github.com/HackerNews/API) requires multiple network
5+
connections to be made in order to fetch the list of Top Stories (indices) and then the
6+
summary content of these stories. Directly requesting these resources makes server-side
7+
rendering cumbersome as it is slow and ultimately requires that you maintain your own
8+
cache to ensure full server renders are efficient.
9+
10+
To work around this problem, we can use one of the unofficial Hacker News APIs, specifically
11+
https://github.com/cheeaun/node-hnapi which directly returns the Top Stories and can cache
12+
responses for 10 minutes. In ReactHN, we can use the unofficial API for a static server-side
13+
render and then 'hydrate' this once our components have mounted to display the real-time
14+
experience.
15+
16+
The benefit of this is clients loading up the app that are on flakey networks (or lie-fi)
17+
can still get a fast render of content before the rest of our JavaScript bundle is loaded.
18+
*/
19+
20+
/**
21+
* Fetch top stories
22+
*/
23+
exports.fetchNews = function(page) {
24+
page = page || ''
25+
return fetch('http://node-hnapi.herokuapp.com/news' + page).then(function(response) {
26+
return response.json()
27+
}).then(function(json) {
28+
var stories = '<ol class="Items__list" start="1">'
29+
json.forEach(function(data, index) {
30+
var story = '<li class="ListItem" style="margin-bottom: 16px;">' +
31+
'<div class="Item__title" style="font-size: 18px;"><a href="' + data.url + '">' + data.title + '</a> ' +
32+
'<span class="Item__host">(' + data.domain + ')</span></div>' +
33+
'<div class="Item__meta"><span class="Item__score">' + data.points + ' points</span> ' +
34+
'<span class="Item__by">by <a href="https://news.ycombinator.com/user?id=' + data.user + '">' + data.user + '</a></span> ' +
35+
'<time class="Item__time">' + data.time_ago + ' </time> | ' +
36+
'<a href="/news/story/' + data.id + '">' + data.comments_count + ' comments</a></div>'
37+
'</li>'
38+
stories += story
39+
})
40+
stories += '</ol>'
41+
return stories
42+
})
43+
}
44+
45+
function renderNestedComment(data) {
46+
return '<div class="Comment__kids">' +
47+
'<div class="Comment Comment--level1">' +
48+
'<div class="Comment__content">' +
49+
'<div class="Comment__meta"><span class="Comment__collapse" tabindex="0">[–]</span> ' +
50+
'<a class="Comment__user" href="#/user/' + data.user + '">' + data.user + '</a> ' +
51+
'<time>' + data.time_ago + '</time> ' +
52+
'<a href="#/comment/' + data.id + '">link</a></div> ' +
53+
'<div class="Comment__text">' +
54+
'<div>' + data.content +'</div> ' +
55+
'<p><a href="https://news.ycombinator.com/reply?id=' + data.id + '">reply</a></p>' +
56+
'</div>' +
57+
'</div>' +
58+
'</div>' +
59+
'</div>'
60+
}
61+
62+
function generateNestedCommentString(data) {
63+
var output = ''
64+
data.comments.forEach(function(comment) {
65+
output+= renderNestedComment(comment)
66+
if (comment.comments) {
67+
output+= generateNestedCommentString(comment)
68+
}
69+
})
70+
return output
71+
}
72+
73+
/**
74+
* Fetch details of the story/post/item with (nested) comments
75+
* TODO: Add article summary at top of nested comment thread
76+
*/
77+
exports.fetchItem = function(itemId) {
78+
return fetch('https://node-hnapi.herokuapp.com/item/' + itemId).then(function(response) {
79+
return response.json()
80+
}).then(function(json) {
81+
var comments = ''
82+
json.comments.forEach(function(data, index) {
83+
var comment = '<div class="Item__kids">' +
84+
'<div class="Comment Comment--level0">' +
85+
'<div class="Comment__content">' +
86+
'<div class="Comment__meta"><span class="Comment__collapse" tabindex="0">[–]</span> ' +
87+
'<a class="Comment__user" href="#/user/' + data.user + '">' + data.user + '</a> ' +
88+
'<time>' + data.time_ago + '</time> ' +
89+
'<a href="#/comment/' + data.id + '">link</a></div> ' +
90+
'<div class="Comment__text">' +
91+
'<div>' + data.content +'</div> ' +
92+
'<p><a href="https://news.ycombinator.com/reply?id=' + data.id + '">reply</a></p>' +
93+
'</div>' +
94+
'</div>' +
95+
'</div>'
96+
comments += generateNestedCommentString(data) + '</div>' + comment
97+
})
98+
return comments
99+
})
100+
}

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,23 @@
2424
"main": "server.js",
2525
"dependencies": {
2626
"ejs": "^2.4.1",
27+
"eslint-config-jonnybuchanan": "2.0.3",
2728
"events": "1.1.0",
2829
"express": "^4.13.4",
2930
"firebase": "2.4.2",
3031
"history": "2.1.1",
3132
"isomorphic-fetch": "^2.2.1",
33+
"nwb": "0.8.1",
34+
"object-assign": "^4.1.0",
3235
"react": "15.0.2",
3336
"react-dom": "15.0.2",
3437
"react-router": "2.4.0",
3538
"react-timeago": "3.0.0",
3639
"reactfire": "0.7.0",
3740
"scroll-behavior": "0.5.0",
3841
"setimmediate": "1.0.4",
39-
"url-parse": "^1.1.1",
40-
"eslint-config-jonnybuchanan": "2.0.3",
41-
"nwb": "0.8.1",
4242
"sw-precache": "^3.1.1",
43-
"sw-toolbox": "^3.1.1"
43+
"sw-toolbox": "^3.1.1",
44+
"url-parse": "^1.1.1"
4445
}
4546
}

server.js

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ var express = require('express')
22
var React = require('react')
33
var renderToString = require('react-dom/server').renderToString
44
var ReactRouter = require('react-router')
5+
var objectAssign = require('object-assign')
6+
var HNServerFetch = require('./hn-server-fetch')
57

68
require('babel/register')
79
var routes = require('./src/routes')
@@ -12,6 +14,47 @@ app.set('views', process.cwd() + '/src/views')
1214
app.set('port', (process.env.PORT || 5000))
1315
app.use(express.static('public'))
1416

17+
18+
app.get(['/', '/news'], function(req, res) {
19+
ReactRouter.match({
20+
routes: routes,
21+
location: req.url
22+
}, function(err, redirectLocation, props) {
23+
if (err) {
24+
res.status(500).send(err.message)
25+
}
26+
else if (redirectLocation) {
27+
res.redirect(302, redirectLocation.pathname + redirectLocation.search)
28+
}
29+
else if (props) {
30+
HNServerFetch.fetchNews().then(function(stories) {
31+
objectAssign(props.params, { prebootHTML: stories })
32+
var markup = renderToString(React.createElement(ReactRouter.RouterContext, props, null))
33+
res.render('index', { markup: markup })
34+
})
35+
}
36+
else {
37+
res.sendStatus(404)
38+
}
39+
})
40+
})
41+
42+
app.get('/news/story/:id', function (req, res, next) {
43+
var storyId = req.params.id
44+
ReactRouter.match({
45+
routes: routes,
46+
location: req.url
47+
}, function(err, redirectLocation, props) {
48+
if (storyId) {
49+
HNServerFetch.fetchItem(storyId).then(function(comments) {
50+
objectAssign(props.params, { prebootHTML: comments })
51+
var markup = renderToString(React.createElement(ReactRouter.RouterContext, props, null))
52+
res.render('index', { markup: markup })
53+
})
54+
}
55+
})
56+
});
57+
1558
app.get('*', function(req, res) {
1659
ReactRouter.match({
1760
routes: routes,
@@ -24,10 +67,8 @@ app.get('*', function(req, res) {
2467
res.redirect(302, redirectLocation.pathname + redirectLocation.search)
2568
}
2669
else if (props) {
27-
var markup = renderToString(
28-
React.createElement(ReactRouter.RouterContext, props, null)
29-
)
30-
res.render('index', { markup: markup })
70+
var markup = renderToString(React.createElement(ReactRouter.RouterContext, props, null))
71+
res.render('index', { markup: markup })
3172
}
3273
else {
3374
res.sendStatus(404)

src/App.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ var SettingsStore = require('./stores/SettingsStore')
1010
var App = React.createClass({
1111
getInitialState() {
1212
return {
13-
showSettings: false
13+
showSettings: false,
14+
showChildren: false,
15+
prebootHTML: this.props.params.prebootHTML
1416
}
1517
},
1618

@@ -22,6 +24,11 @@ var App = React.createClass({
2224
window.addEventListener('beforeunload', this.handleBeforeUnload)
2325
},
2426

27+
componentDidMount() {
28+
// Empty the prebooted HTML and hydrate using live results from Firebase
29+
this.setState({ prebootHTML: '', showChildren: true })
30+
},
31+
2532
componentWillUnmount() {
2633
if (typeof window === 'undefined') return
2734
window.removeEventListener('beforeunload', this.handleBeforeUnload)
@@ -58,7 +65,8 @@ var App = React.createClass({
5865
{this.state.showSettings && <Settings key="settings"/>}
5966
</div>
6067
<div className="App__content">
61-
{this.props.children}
68+
<div dangerouslySetInnerHTML={{ __html: this.state.prebootHTML }}/>
69+
{this.state.showChildren ? this.props.children : ''}
6270
</div>
6371
<div className="App__footer">
6472
<a href="https://github.com/insin/react-hn">insin/react-hn</a>

src/Stories.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ var Stories = React.createClass({
6767

6868
// Display a list of placeholder items while we're waiting for the initial
6969
// list of story ids to load from Firebase.
70-
if (this.state.stories.length === 0 && this.state.ids.length === 0) {
70+
if (this.state.stories.length === 0 && this.state.ids.length === 0 && this.getPageNumber() > 0) {
7171
var dummyItems = []
7272
for (var i = page.startIndex; i < page.endIndex; i++) {
7373
dummyItems.push(

src/views/index.ejs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
<meta name="msapplication-TileColor" content="#222222">
3030
<meta name="msapplication-TileImage" content="img/mstile-144x144.png">
3131
<meta name="msapplication-config" content="img/browserconfig.xml">
32-
32+
<base href="/">
3333
<link rel="stylesheet" href="css/style.css">
3434
</head>
3535
<body>

0 commit comments

Comments
 (0)