koa-ssr v0.3.0
koa-ssr
Use JSDOM to evaluate (and cache) your client-side app on server before serving response as HTML in Koa.
Perfect for serving static content generated by webpack production build.
Install
npm i koa-ssrUsage
import Koa from 'koa';
import koaStatic from 'koa-static';
import koaSSR from 'koa-ssr';
const app = new Koa();
const root = __dirname + '/dist'
// serve static content as usual:
app.use(koaStatic(root, {
// DON'T let index.html be served statically
index: false // << important!
}))
app.use(koaSSR(root, {
// we'll (re-)generate it here
index: 'index.html'
}))API
koaMiddleware = koaSSR(root, opts)rootroot directoryoptsoptions:
Options
index[str](default:'index.html') Main index filehtml[str]Instead of index.html, provide an html stringtimeout[num](default:5000) After which if JSDOM hasn't finished loading (i.e.window[opts.modulesLoadedEventLabel]hasn't been called (see below)) it throws an error (with{ koaSSR: {ctx, window} }property attached).jsdom[obj]Config passed to JSDOM: jsdom.jsdom(opts.html, opts.jsdom).Eg. for shimming unimplemented APIs:
koaSSR(root, { jsdom: { created: (e, window) => { window.localStorage = new MockLocalStorage(); }, } })console[obj](default: modified debug (setDEBUG=koa-ssr:jsdom-client))consoleobject for JSDOM'svirtualConsoleused as jsdom.createVirtualConsole().sendTo(console)Eg.
koaSSR(root, { console: console // native console object })Note: You can also do this manually in
opts.jsdom.virtualConsole, this is just a shorter version. It also tries to infer the type ofconsole(checking for.log/erretc methods) and adds the additional prefixes ('[JSDOM]') to messages.resourceLoader[func](default:(res, cb, def) => def(res, cb)) Wrapper around JSDOM'sresourceLoaderwith an extra argumentdefto load resources automatically fromroot.Eg.
koaSSR(root, { resourceLoader: (res, cb, def) => { // either load the resource manually fs.readFile(res.url.pathname, 'utf8', cb) // or let koaSSR handle it def(res, cb); // or intercept def(res, (err, body) => { cb(null, body || 'something else') }) } })Note: You can also provide this option as
opts.jsdom.resourceLoaderbut it won't have the additional third argumentdef.
modulesLoadedEventLabel[str](default:'onModulesLoaded') A special function is attached towindow[modulesLoadedEventLabel]which **must be called** to indicate that your app has finished rendering. Failure would result in a timeout and an error thrown (with{ koaSSR: {ctx, window} }property attached). See JSDOM: Dealing with asynchronous script loading as to why it needs you to do this instead of relying on defaultonloador other such events. This can also be used as an indicator that your app is being rendered server-side so you may choose to deal with that aspect in your app as well.Eg.
client-app.jsimport {h, render} from 'preact' if (window.onModulesLoaded) { // rendered on server const userData = window.userData // as attached in render function below } else { // not rendered on server const userData = localStorage.get('userdata') || await fetch('/api/user...') } render(h('div', {...data}, ['Hello world!']), document.body) if (window.onModulesLoaded) { window.onModulesLoaded(); }cache[bool|obj|function](default:true) Whether (and where/how) to cache JSDOM responsesfalseDoesn't uses a cache, JSDOM is run for every requesttrue|{}Uses an object in memory (created or provided) to store JSDOM generated response as {url: body}functionDelegate caching and retrivingCalled with args:
ctxKoa'sctx[html](Pre-)final HTML string to be cached[window]JSDOM's window object (JSDOM.jsdom(...).defaultView)[serialize]Alias forJSDOM.serializeDocument
The optional arguments (html, window, serialize) are passed only when the page was rendered with JSDOM. So when they're not passed, it expects you to return a pre-cached (if available) html string to use as a response instead. With this you can essentially control whether or not to actually invoke JSDOM for each request.
Eg. Caching to disk selectively (this functionality is available as a helper function
cacheToDisk)const cacheIndex = {} koaSSR(root, { cache: (ctx, html, window, serialize) => { // parse URL and omit query strings const url = URL.parse(ctx.url).pathname; // ignore '?query=xyz' // choose a sanitized filename const filename = '.ssr-cache/' + (_.kebabCase(url) || 'index') + '.html'; // if html is provided, cache it: if (html) { fs.writeFile(filename, html); cacheIndex[filename] = true; return html; // and return it to be rendered } // if html isn't provided... // check if filename was cached if (cacheIndex[filename]) { ctx.type = 'html'; // (override stream's inferred type "application/octet-stream") return fs.createReadStream(filename); } // check if file exists anyways (from a previous run) if (await fs.exists(filename)) { cacheIndex[filename] = true; ctx.type = 'html'; return fs.createReadStream(filename); } // if nothing is returned, JSDOM will be invoked } })
render[func](defaut:(ctx, html) => ctx.body = html) Final function responsible for sending the finalhtmlas a response to the client by settingctx.body=.Called with args:
ctxKoa'sctxhtmlFinal rendered HTML[window]JSDOM's window object (JSDOM.jsdom(...).defaultView)[serialize]Alias forJSDOM.serializeDocument
The optional arguments (window, serialize) are passed only when the page was rendered with JSDOM (either before caching for the first time, or when
cacheis set to false orcachefunction decides not to cache).Use this to customize response (even the cached response) for different users. Eg.
koaSSR(root, { render: async (ctx, html) => { html = html.replace('</body>', ` <script> window.userData = ${await User.findOne(ctx.user)} window.queryData = ${await Search.findResult(ctx.query)} </script> </body>`) ctx.body = html } })Note that in earlier eg. with
cachewe returned a stream in which case (use stream-replace because)htmlhere would also have been the same stream object (renderis called with the result ofcache).
Helpers
Helper functions
cacheToDiskHelper function to be used asopts.cachefor cahing to disk (as shown above).import koaSSR from 'koa-ssr' import {cacheToDisk} from 'koa-ssr/helpers' koaSSR(root, { cache: cacheToDisk(opts) })Options:
parseUrl[func](defaut:url => URL.parse(url).pathname) Parse the urldir[str](defaut:'.ssr-cache/') Directory to use for cache filesfilename[func](defaut:url => Path.join(opts.dir, (_.kebabCase(url)||'index')+'.html')) Generate filenameinvalidatePrevious[bool](defaut:false) Do not use cache created from a previous run