UNPKG

9.15 kBJavaScriptView Raw
1var waterfall = require('async-collection/waterfall')
2var mapLimit = require('async-collection/map-limit')
3var explain = require('explain-error')
4var concat = require('concat-stream')
5var resolve = require('resolve')
6var crypto = require('crypto')
7var pump = require('pump')
8var path = require('path')
9
10var critical = require('inline-critical-css')
11var documentify = require('documentify')
12var hyperstream = require('hstream')
13
14var ttyError = require('./tty-error')
15var utils = require('./utils')
16
17var WRITE_CONCURRENCY = 3
18
19module.exports = node
20
21function node (state, createEdge) {
22 var entry = utils.basefile(state.metadata.entry)
23 var ssr = state.metadata.ssr
24 var self = this
25
26 if (ssr.error && ssr.error.isSsr) {
27 self.emit('ssr', { success: false, error: ssr.error })
28 } else if (ssr.error) {
29 ssr.error = ttyError('documents', entry, ssr.error)
30 return self.emit('error', 'documents', entry, ssr.error)
31 }
32
33 // TODO: don't pass a callback here - super hard to reason about. Find a
34 // different way instead. Perhaps a prototype with methods on it instead?
35 self.emit('ssr', { success: true, renderRoute: documentifyRoute })
36
37 var fonts = extractFonts(state.assets)
38 var list = ssr.routes
39
40 mapLimit(list, WRITE_CONCURRENCY, documentifyRoute, function (err) {
41 if (err) return self.emit(err)
42 createEdge('list', Buffer.from(list.join(',')))
43 })
44
45 function documentifyRoute (route, done) {
46 waterfall([
47 renderApp,
48 documentifyApp,
49 pushEdge
50 ], done)
51
52 function renderApp (done) {
53 ssr.render(route, function (err, content) {
54 if (err) {
55 self.emit('ssr', { success: false, error: err })
56 return done(err)
57 }
58 done(null, content)
59 })
60 }
61
62 function pushEdge (buf, done) {
63 var name = route
64 if (name === '/') name = 'index'
65 name = name + '.html'
66 createEdge(name, buf, {
67 mime: 'text/html'
68 })
69 done(null, buf)
70 }
71 }
72
73 function getTemplate (content, done) {
74 // TODO maybe change this depending on the `content.route`, so you can have different templates for different routes?
75 var name = 'index'
76
77 var dir = path.join(path.dirname(entry), name)
78 resolve('.', { basedir: dir, extensions: ['.html'] }, function (err, filename) {
79 if (err) {
80 done(dir, head(content.language))
81 } else {
82 // Only return the filename, documentify will stream it in.
83 done(filename, null)
84 }
85 })
86 }
87
88 function documentifyApp (content, done) {
89 var base = state.metadata.opts.base
90 var route = content.route
91 var title = content.title
92 var body = content.body
93 var selector = content.selector
94
95 var hasDynamicScripts = state.scripts.bundle.dynamicBundles.length > 0
96
97 getTemplate(content, ontemplate)
98
99 function ontemplate (filename, html) {
100 var d = documentify(filename, html)
101 var header = [
102 viewportTag(),
103 scriptTag({ hash: state.scripts.bundle.hash, base: base }),
104 hasDynamicScripts && dynamicScriptsTag({
105 bundleNames: state.scripts.bundle.dynamicBundles,
106 scripts: state.scripts,
107 base: base
108 }),
109 preloadTag(),
110 loadFontsTag({ fonts: fonts, base: base }),
111 faviconsTag({ favicon: state.favicon.bundle.buffer }),
112 manifestTag({ base: base }),
113 descriptionTag({ description: state.manifest.bundle.description }),
114 themeColorTag({ color: state.manifest.bundle.color }),
115 titleTag({ title: title })
116 ].filter(Boolean)
117 // TODO: twitter
118 // TODO: facebook
119 // TODO: apple touch icons
120 // TODO: favicons
121
122 if (state.metadata.reload) {
123 header.push(reloadTag({ bundle: state.reload.bundle.buffer }))
124 }
125
126 d.transform(addToHead, header.join(''))
127
128 d.transform(insertApp, {
129 selector: selector,
130 body: body
131 })
132
133 if (state.styles.bundle.buffer.length) {
134 d.transform(criticalTransform, { css: state.styles.bundle.buffer })
135 }
136
137 d.transform(addToHead, styleTag({ hash: state.styles.bundle.hash, base: base }))
138
139 function complete (buf) { done(null, buf) }
140
141 pump(d.bundle(), concat({ encoding: 'buffer' }, complete), function (err) {
142 if (err) return done(explain(err, 'Error in documentify while operating on ' + route))
143 })
144 }
145 }
146}
147
148function head (lang) {
149 var dir = 'ltr'
150 return `<!DOCTYPE html><html lang="${lang}" dir="${dir}"><head></head><body></body></html>`
151}
152
153// Make sure that rel=preload works in Safari.
154function preloadTag () {
155 var content = ';(function(a){"use strict";var b=function(b,c,d){function e(a){return h.body?a():void setTimeout(function(){e(a)})}function f(){i.addEventListener&&i.removeEventListener("load",f),i.media=d||"all"}var g,h=a.document,i=h.createElement("link");if(c)g=c;else{var j=(h.body||h.getElementsByTagName("head")[0]).childNodes;g=j[j.length-1]}var k=h.styleSheets;i.rel="stylesheet",i.href=b,i.media="only x",e(function(){g.parentNode.insertBefore(i,c?g:g.nextSibling)});var l=function(a){for(var b=i.href,c=k.length;c--;)if(k[c].href===b)return a();setTimeout(function(){l(a)})};return i.addEventListener&&i.addEventListener("load",f),i.onloadcssdefined=l,l(f),i};"undefined"!=typeof exports?exports.loadCSS=b:a.loadCSS=b})("undefined"!=typeof global?global:this);'
156 content += ';(function(a){if(a.loadCSS){var b=loadCSS.relpreload={};if(b.support=function(){try{return a.document.createElement("link").relList.supports("preload")}catch(b){return!1}},b.poly=function(){for(var b=a.document.getElementsByTagName("link"),c=0;c<b.length;c++){var d=b[c];"preload"===d.rel&&"style"===d.getAttribute("as")&&(a.loadCSS(d.href,d,d.getAttribute("media")),d.rel=null)}},!b.support()){b.poly();var c=a.setInterval(b.poly,300);a.addEventListener&&a.addEventListener("load",function(){b.poly(),a.clearInterval(c)}),a.attachEvent&&a.attachEvent("onload",function(){a.clearInterval(c)})}}})(this);'
157 return `<script>${content}</script>`
158}
159
160function scriptTag (opts) {
161 var hex = opts.hash.toString('hex').slice(0, 16)
162 var base64 = 'sha512-' + opts.hash.toString('base64')
163 var link = `${opts.base || ''}/${hex}/bundle.js`
164 return `<script src="${link}" defer integrity="${base64}"></script>`
165}
166
167function dynamicScriptsTag (opts) {
168 return opts.bundleNames.map(function (name) {
169 name = name.replace(/\.js$/, '')
170 var hash = opts.scripts[name].hash
171 var hex = hash.toString('hex').slice(0, 16)
172 var base64 = 'sha512-' + hash.toString('base64')
173 var link = `${opts.base || ''}/${hex}/${name}.js`
174 return `<link rel="prefetch" href="${link}" integrity="${base64}">`
175 }).join('')
176}
177
178// NOTE: in theory we should be able to add integrity checks to stylesheets too,
179// but in practice it turns out that it conflicts with preloading. So it's best
180// to disable it for now. See:
181// https://twitter.com/yoshuawuyts/status/920794607314759681
182function styleTag (opts) {
183 var hex = opts.hash.toString('hex').slice(0, 16)
184 var link = `${opts.base || ''}/${hex}/bundle.css`
185 return `<link rel="preload" as="style" href="${link}" onload="this.rel='stylesheet'">`
186}
187
188function loadFontsTag (opts) {
189 return opts.fonts.reduce(function (html, font) {
190 if (!path.isAbsolute(font)) font = (opts.base || '') + '/' + font
191 return html + `<link rel="preload" as="font" crossorigin href="${font}">`
192 }, '')
193}
194
195function manifestTag (opts) {
196 return `
197 <link rel="manifest" href="${opts.base || ''}/manifest.json">
198 `.replace(/\n +/g, '')
199}
200
201function faviconsTag (opts) {
202 var favicon = opts.favicon.toString()
203 if (favicon.length > 0) {
204 return `<link rel="icon" type="image/x-icon" href="${favicon}">`
205 }
206 return ``
207}
208
209function viewportTag () {
210 return `
211 <meta charset="utf-8">
212 <meta name="viewport" content="width=device-width, initial-scale=1.0">
213 `.replace(/\n +/g, '')
214}
215
216function descriptionTag (opts) {
217 return `
218 <meta name="description" content="${opts.description}">
219 `.replace(/\n +/g, '')
220}
221
222function themeColorTag (opts) {
223 return `
224 <meta name="theme-color" content="${opts.color}">
225 `.replace(/\n +/g, '')
226}
227
228function titleTag (opts) {
229 return `
230 <title>${opts.title}</title>
231 `.replace(/\n +/g, '')
232}
233
234function criticalTransform (opts) {
235 return critical(String(opts.css))
236}
237
238function reloadTag (opts) {
239 var bundle = opts.bundle
240 var base64 = sha512(bundle)
241 return `<script integrity="${base64}">${bundle}</script>`
242}
243
244function addToHead (str) {
245 return hyperstream({
246 head: {
247 _appendHtml: str
248 }
249 })
250}
251
252function insertApp (opts) {
253 return hyperstream({
254 [opts.selector]: {
255 _replaceHtml: opts.body
256 }
257 })
258}
259
260// Specific to the document node's layout
261function extractFonts (state) {
262 var list = String(state.list.buffer).split(',')
263
264 var res = list.filter(function (font) {
265 var extname = path.extname(font)
266
267 return extname === '.woff' ||
268 extname === '.woff2' ||
269 extname === '.eot' ||
270 extname === '.ttf'
271 })
272
273 return res
274}
275
276function sha512 (buf) {
277 return 'sha512-' + crypto.createHash('sha512')
278 .update(buf)
279 .digest('base64')
280}