Skip to content

Commit 9b2d338

Browse files
committed
More ssr config options. Inline scripts too
1 parent f179dcd commit 9b2d338

File tree

1 file changed

+132
-35
lines changed

1 file changed

+132
-35
lines changed

server.mjs

+132-35
Original file line numberDiff line numberDiff line change
@@ -35,73 +35,146 @@ const RENDER_CACHE = new Map(); // Cache of pre-rendered HTML pages.
3535

3636
const twitter = new Twitter('ChromiumDev');
3737

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+
3845
/**
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.
3953
*
4054
* @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.
4665
* @return {string} Serialized page output as an html string.
4766
*/
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} = {}) {
4970
if (useCache && RENDER_CACHE.has(url)) {
5071
return RENDER_CACHE.get(url);
5172
}
5273

5374
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+
5788
const page = await browser.newPage();
5889

5990
// 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
6292
if (onlyCriticalRequests) {
6393
await page.setRequestInterception(true);
94+
const whitelist = ['document', 'script', 'xhr', 'fetch', 'websocket'];
6495
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+
}
66100
whitelist.includes(req.resourceType()) ? req.continue() : req.abort();
67101
});
68102
}
69103

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+
}
79122

80123
// 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.
82126

83127
// Add param so client-side page can know it's being rendered by headless on the server.
84128
const urlToFetch = new URL(url);
85129
urlToFetch.searchParams.set('headless', '');
86130

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+
}
89141

90142
if (inlineStyles) {
91-
await page.$$eval('link[rel="stylesheet"]', (sheets, sheetsToCSS) => {
143+
await page.$$eval('link[rel="stylesheet"]', (sheets, stylesheetContents) => {
92144
sheets.forEach(link => {
93-
const css = sheetsToCSS[link.href];
145+
const css = stylesheetContents[link.href];
94146
if (css) {
95147
const style = document.createElement('style');
96148
style.textContent = css;
97149
link.replaceWith(style);
98150
}
99151
});
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);
101170
}
102171

103172
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+
}
105178
console.info(`Headless rendered page in: ${Date.now() - tic}ms`);
106179

107180
RENDER_CACHE.set(url, html); // cache rendered page.
@@ -180,15 +253,24 @@ app.use(express.static('node_modules/lit-html'));
180253
// // s.push(null);
181254
// });
182255

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+
});
189271
// res.append('Link', `<${url}/styles.css>; rel=preload; as=style`); // Push styles.
190272
res.status(200).send(html);
191-
});
273+
}));
192274

193275
app.get('/tweets/:username', async (req, res) => {
194276
const username = req.params.username;
@@ -270,3 +352,18 @@ app.listen(PORT, () => {
270352
// updatePosts(feeds.updateFeeds, 1000 * 60 * 60 * 24); // every 24hrs
271353
// updatePosts(twitter.updateTweets, 1000 * 60 * 60 * 1); // every 1hrs
272354
});
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

Comments
 (0)