@@ -35,73 +35,146 @@ const RENDER_CACHE = new Map(); // Cache of pre-rendered HTML pages.
35
35
36
36
const twitter = new Twitter ( 'ChromiumDev' ) ;
37
37
38
+ let browserWSEndpoint = null ;
39
+
40
+ // Async route handlers are wrapped with this to catch rejected promise errors.
41
+ const catchAsyncErrors = fn => ( req , res , next ) => {
42
+ Promise . resolve ( fn ( req , res , next ) ) . catch ( next ) ;
43
+ } ;
44
+
38
45
/**
46
+ * Server-side renders a URL using headless chrome.
47
+ *
48
+ * Measurements:
49
+ * - onlyCriticalRequests: true reduces total render time by < 50ms (<2% slowdown)
50
+ * compared to no optimizations
51
+ * - inlineStyles: true appears to add negligible overhead.
52
+ * - TODO: see if these opts actually matter for FMP in the browser, especially on mobile.
39
53
*
40
54
* @param {string } url The url to prerender.
41
- * @param {boolean } useCache Whether to consult the cache. Default is true.
42
- * @param {boolean } inlineStyles Whether to inline stylesheets. True by default.
43
- * @param {boolean } onlyCriticalRequests Reduces the number of requests the
44
- * browser makes by aborting requests that are non-critical to rendering
45
- * the DOM of the page (stylesheets, images, media). True by default.
55
+ * @param {!Object } config Optional config settings.
56
+ * useCache: Whether to consult the cache. Default is true.
57
+ * inlineStyles: Whether to inline local stylesheets. True by default.
58
+ * inlineScripts: Whether to inline local scripts. True by default.
59
+ * onlyCriticalRequests: Reduces the number of requests the
60
+ * browser makes by aborting requests that are non-critical to rendering
61
+ * the DOM of the page (stylesheets, images, media). True by default.
62
+ * reuseChrome: Set to false to relaunch a new isntance of Chrome on every call. Default is false.
63
+ * headless: Set to false to launch headlful chrome. Default is true. Note: this param will
64
+ * have no effect if Chrome was launched at least once with reuseChrome: true.
46
65
* @return {string } Serialized page output as an html string.
47
66
*/
48
- async function ssr ( url , useCache = true , inlineStyles = true , onlyCriticalRequests = true ) {
67
+ async function ssr ( url , { useCache = true , onlyCriticalRequests = true ,
68
+ inlineStyles = true , inlineScripts = true ,
69
+ reuseChrome = false , headless = true } = { } ) {
49
70
if ( useCache && RENDER_CACHE . has ( url ) ) {
50
71
return RENDER_CACHE . get ( url ) ;
51
72
}
52
73
53
74
const tic = Date . now ( ) ;
54
- const browser = await puppeteer . launch ( {
55
- args : [ '--disable-dev-shm-usage' ] ,
56
- } ) ;
75
+ // Reuse existing browser instance or launch a new one.
76
+ let browser ;
77
+ if ( browserWSEndpoint && reuseChrome ) {
78
+ console . info ( 'Reusing existing chrome instance' ) ;
79
+ browser = await puppeteer . connect ( { browserWSEndpoint} ) ;
80
+ } else {
81
+ browser = await puppeteer . launch ( {
82
+ args : [ '--disable-dev-shm-usage' ] ,
83
+ headless,
84
+ } ) ;
85
+ browserWSEndpoint = await browser . wsEndpoint ( ) ;
86
+ }
87
+
57
88
const page = await browser . newPage ( ) ;
58
89
59
90
// Small optimization. Since we only care about rendered DOM, ignore images,
60
- // other media that don't produce markup. Alo keep CSS requests so we can
61
- // read their responses later.
91
+ // other media that don't produce markup
62
92
if ( onlyCriticalRequests ) {
63
93
await page . setRequestInterception ( true ) ;
94
+ const whitelist = [ 'document' , 'script' , 'xhr' , 'fetch' , 'websocket' ] ;
64
95
page . on ( 'request' , req => {
65
- const whitelist = [ 'document' , 'stylesheet' , 'script' , 'xhr' , 'fetch' , 'websocket' ] ;
96
+ // Keep CSS requests so we can read their responses later to inline.
97
+ if ( inlineStyles ) {
98
+ whitelist . push ( 'stylesheet' ) ;
99
+ }
66
100
whitelist . includes ( req . resourceType ( ) ) ? req . continue ( ) : req . abort ( ) ;
67
101
} ) ;
68
102
}
69
103
70
- const sheetsToCSS = { } ;
71
-
72
- page . on ( 'response' , async resp => {
73
- const href = resp . url ( ) ;
74
- // Only consider local stylesheets to the site.
75
- if ( resp . request ( ) . resourceType ( ) === 'stylesheet' && href . startsWith ( url ) ) {
76
- sheetsToCSS [ href ] = await resp . text ( ) ;
77
- }
78
- } ) ;
104
+ const stylesheetContents = { } ;
105
+ const scriptsContents = { } ;
106
+
107
+ if ( inlineStyles || inlineScripts ) {
108
+ page . on ( 'response' , async resp => {
109
+ const href = resp . url ( ) ;
110
+ const type = resp . request ( ) . resourceType ( ) ;
111
+ const sameOriginResource = href . startsWith ( url ) ;
112
+ // Only inline local resources.
113
+ if ( sameOriginResource ) {
114
+ if ( type === 'stylesheet' ) {
115
+ stylesheetContents [ href ] = await resp . text ( ) ;
116
+ } else if ( type === 'script' ) {
117
+ scriptsContents [ href ] = await resp . text ( ) ;
118
+ }
119
+ }
120
+ } ) ;
121
+ }
79
122
80
123
// TODO: another optimization might be to take entire page out of rendering
81
- // path by adding html { display: none } before page loads.
124
+ // path by adding html { display: none } before page loads. However, this may
125
+ // cause any script that looks at layout to fail e.g. IntersectionObserver.
82
126
83
127
// Add param so client-side page can know it's being rendered by headless on the server.
84
128
const urlToFetch = new URL ( url ) ;
85
129
urlToFetch . searchParams . set ( 'headless' , '' ) ;
86
130
87
- await page . goto ( urlToFetch . href , { waitUntil : 'domcontentloaded' } ) ;
88
- await page . waitForSelector ( '#posts' ) ; // wait for posts to be in filled in page.
131
+ // await page.evaluateOnNewDocument(() => localStorage.setItem('includeTweets', false));
132
+
133
+ try {
134
+ await page . goto ( urlToFetch . href , { waitUntil : 'domcontentloaded' } ) ;
135
+ await page . waitForSelector ( '#posts' ) ; // wait for posts to be in filled in page.
136
+ } catch ( err ) {
137
+ await browser . close ( ) ;
138
+ browserWSEndpoint = null ;
139
+ throw new Error ( 'page.goto/waitForSelector timed out.' ) ;
140
+ }
89
141
90
142
if ( inlineStyles ) {
91
- await page . $$eval ( 'link[rel="stylesheet"]' , ( sheets , sheetsToCSS ) => {
143
+ await page . $$eval ( 'link[rel="stylesheet"]' , ( sheets , stylesheetContents ) => {
92
144
sheets . forEach ( link => {
93
- const css = sheetsToCSS [ link . href ] ;
145
+ const css = stylesheetContents [ link . href ] ;
94
146
if ( css ) {
95
147
const style = document . createElement ( 'style' ) ;
96
148
style . textContent = css ;
97
149
link . replaceWith ( style ) ;
98
150
}
99
151
} ) ;
100
- } , sheetsToCSS ) ;
152
+ } , stylesheetContents ) ;
153
+ }
154
+
155
+ if ( inlineScripts ) {
156
+ await page . $$eval ( 'script[src]' , ( scripts , scriptsContents ) => {
157
+ scripts . forEach ( script => {
158
+ const js = scriptsContents [ script . src ] ;
159
+ if ( js ) {
160
+ const s = document . createElement ( 'script' ) ;
161
+ // s.text = js;
162
+ // Note: not using script.text b/c here we don't need to eval the script.
163
+ // Thaat will be done client side when the browser renders the page.
164
+ s . textContent = js ;
165
+ s . type = script . getAttribute ( 'type' ) || null ;
166
+ script . replaceWith ( s ) ;
167
+ }
168
+ } ) ;
169
+ } , scriptsContents ) ;
101
170
}
102
171
103
172
const html = await page . content ( ) ; // Use browser to prerender page, get serialized DOM output!
104
- await browser . close ( ) ;
173
+ if ( browserWSEndpoint && reuseChrome ) {
174
+ await page . close ( ) ;
175
+ } else {
176
+ await browser . close ( ) ;
177
+ }
105
178
console . info ( `Headless rendered page in: ${ Date . now ( ) - tic } ms` ) ;
106
179
107
180
RENDER_CACHE . set ( url , html ) ; // cache rendered page.
@@ -180,15 +253,24 @@ app.use(express.static('node_modules/lit-html'));
180
253
// // s.push(null);
181
254
// });
182
255
183
- app . get ( '/ssr' , async ( req , res ) => {
184
- const url = req . getOrigin ( ) ;
185
- const useCache = 'nocache' in req . query ? false : true ;
186
- const inlineStyles = 'noinline' in req . query ? false : true ;
187
- const optimizeReqs = 'noreduce' in req . query ? false : true ;
188
- const html = await ssr ( url , useCache , inlineStyles , optimizeReqs ) ;
256
+ app . get ( '/ssr' , catchAsyncErrors ( async ( req , res ) => {
257
+ // This ignores other query params on the URL besides the tweets.
258
+ const url = new URL ( req . getOrigin ( ) ) ;
259
+ if ( 'tweets' in req . query ) {
260
+ url . searchParams . set ( 'tweets' , '' ) ;
261
+ }
262
+
263
+ const html = await ssr ( url . href , {
264
+ useCache : 'nocache' in req . query ? false : true ,
265
+ inlineStyles : 'noinline' in req . query ? false : true ,
266
+ inlineScripts : 'noinline' in req . query ? false : true ,
267
+ onlyCriticalRequests : 'noreduce' in req . query ? false : true ,
268
+ reuseChrome : 'reusechrome' in req . query ? true : false ,
269
+ headless : 'noheadless' in req . query ? false : true ,
270
+ } ) ;
189
271
// res.append('Link', `<${url}/styles.css>; rel=preload; as=style`); // Push styles.
190
272
res . status ( 200 ) . send ( html ) ;
191
- } ) ;
273
+ } ) ) ;
192
274
193
275
app . get ( '/tweets/:username' , async ( req , res ) => {
194
276
const username = req . params . username ;
@@ -270,3 +352,18 @@ app.listen(PORT, () => {
270
352
// updatePosts(feeds.updateFeeds, 1000 * 60 * 60 * 24); // every 24hrs
271
353
// updatePosts(twitter.updateTweets, 1000 * 60 * 60 * 1); // every 1hrs
272
354
} ) ;
355
+
356
+
357
+ // Make sure node server process stops if we get a terminating signal.
358
+ function processTerminator ( sig ) {
359
+ if ( typeof sig === 'string' ) {
360
+ process . exit ( 1 ) ;
361
+ }
362
+ console . log ( '%s: Node server stopped.' , Date ( Date . now ( ) ) ) ;
363
+ }
364
+
365
+ [ 'SIGHUP' , 'SIGINT' , 'SIGQUIT' , 'SIGILL' , 'SIGTRAP' , 'SIGABRT' ,
366
+ 'SIGBUS' , 'SIGFPE' , 'SIGUSR1' , 'SIGSEGV' , 'SIGUSR2' , 'SIGTERM'
367
+ ] . forEach ( sig => {
368
+ process . once ( sig , ( ) => processTerminator ( sig ) ) ;
369
+ } ) ;
0 commit comments