UNPKG

koa-web-kit

Version:

A modern, production-ready, and full-stack node web framework

297 lines (268 loc) 8.08 kB
const fs = require('fs'); const path = require('path'); const { Transform } = require('stream'); const { minify } = require('html-minifier'); const config = require('../config/env'); const { logger } = require('./logger'); const Cache = require('./Cache'); const isSSREnabled = config.isSSREnabled(); const isHMREnabled = config.isHMREnabled(); const DEV_MODE = config.isDevMode(); const isCSSModules = config.isCSSModules(); let indexHtml = ''; let s; if (isSSREnabled) { if (isCSSModules) { logger.warn( 'When SSR is enabled, [CSS_MODULES] should be disabled for now, you can manually add plugin like "isomorphic-style-loader" to enable both SSR and CSS Modules' ); } const SSR = require('../build/node/main'); s = new SSR(); } else if (!isHMREnabled) { indexHtml = readIndexHtml(); } function readIndexHtml() { let ret = ''; try { ret = fs.readFileSync(path.join(__dirname, `../build/app/index.html`), { encoding: 'utf-8', }); } catch (e) { logger.warn('failed to read build/app/index.html...'); } return ret; } /** * Renderer to render React SSR contents or just static html in koa app's routes * @param {Object<{ * ssr: boolean, * stream: boolean, * cache: ./Cache, * }>} options * @param {boolean} options.ssr - enable/disable SSR manually, if provided, will use the app config * @param {boolean} options.stream - instance of custom Cache * @param {boolean|Cache} options.cache - enable/disable SSR cache manually, if provided with true, will use a default Cache, or you can provide your own Cache instance * @see ./Cache */ class ServerRenderer { constructor(options = {}) { this.ssrEnabled = options.hasOwnProperty('ssr') ? !!options.ssr : isSSREnabled; this.cache = null; if (options.cache && options.cache instanceof Cache) { this.cache = options.cache; } else if (this.ssrEnabled && options.cache === true) { this.cache = new Cache(options); } this.cacheDisabled = !this.cache; this.streaming = !!options.stream || !!options.streaming; } /** * Check if SSR is enabled * @return {boolean|*} */ isSSREnabled() { return this.ssrEnabled; } /** * Render static html content or Server side rendering components * @param {object} ctx koa ctx * @param {object=} data initial data * @param {Boolean=} streaming whether to user streaming api or not */ render(ctx, data = {}, streaming) { if (!this.ssrEnabled) { ctx.body = this.genHtml('', {}, ctx); return; } this.renderSSR(ctx, data, streaming); } /** * Render content from cache * @param key {string} cache key * @param ctx {object} koa ctx object */ renderFromCache(key, ctx) { logger.info('Rendering content from cache...'); this.setHtmlContentType(ctx); ctx.body = this.cache.get(key); } /** * Server side rendering components * @param {object} ctx koa ctx * @param {object=} data initial data * @param {Boolean=} streaming whether to user streaming api or not */ renderSSR(ctx, data = {}, streaming = this.streaming) { this.setHtmlContentType(ctx); if (!this.cacheDisabled && this.isCacheMatched(ctx.path)) { this.renderFromCache(ctx.path, ctx); return; } if (!streaming) { logger.info('-----> Use React SSR Sync API!'); const rendered = s.render(ctx.url, data); rendered.initialData = data; ctx.body = this.genHtml(rendered.html, rendered, ctx); return; } logger.info('-----> Use React SSR Streaming API!'); //use streaming api const rendered = s.renderWithStream(ctx.url, data); rendered.initialData = data; this.genHtmlStream(rendered.htmlStream, rendered, ctx); } /** * Generate static html from component * @param {string=} html SSRed html * @param {object=} extra extra info from SSR#render() * @param {object} ctx koa ctx object * @return {string} final html content */ genHtml(html, extra = {}, ctx) { if (!this.ssrEnabled) { if (!indexHtml) { indexHtml = readIndexHtml(); } if (!DEV_MODE) { return indexHtml; } indexHtml = readIndexHtml(); return indexHtml; } let ret = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no"> <title>${extra.title || 'React App'}</title> <link rel="shortcut icon" href="/favicon.ico"> ${extra.linkTags} ${extra.styleTags} </head> <body> <div id="app">${html}</div> <script id="__INITIAL_DATA__" type="application/json">${JSON.stringify( extra.initialData || {} )}</script> ${extra.scriptTags} </body> </html> `; ret = this.minifyHtml(ret); this.cache && this.cache.set(ctx.path, ret); return ret; } /** * Generate html in stream * @param nodeStreamFromReact * @param extra * @param ctx */ genHtmlStream(nodeStreamFromReact, extra = {}, ctx) { const res = ctx.res; ctx.status = 200; ctx.respond = false; let cacheStream = this.createCacheStream(ctx.path); cacheStream.pipe( res, { end: false } ); const before = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no"> <title>${extra.title || 'React App'}</title> <link rel="shortcut icon" href="/favicon.ico"> ${extra.styleTags} </head> <body><div id="app">`; cacheStream.write(before); // res.write(before); nodeStreamFromReact.pipe( // res, cacheStream, { end: false } ); nodeStreamFromReact.on('end', () => { logger.info('nodeStreamFromReact end'); logger.info('start streaming rest html content...'); const after = `</div> <script id="__INITIAL_DATA__" type="application/json">${JSON.stringify( extra.initialData || {} )}</script> ${extra.extractor.getScriptTags()} </body> </html>`; // res.end(after); cacheStream.write(after); logger.info('streaming rest html content done!'); res.end(); cacheStream.end(); }); } /** * * @param {String} key for now it's ctx.url */ createCacheStream(key) { logger.info(`Creating cache stream for ${key}`); const bufferedChunks = []; const self = this; return new Transform({ // transform() is called with each chunk of data transform(data, enc, cb) { // We store the chunk of data (which is a Buffer) in memory bufferedChunks.push(data); // Then pass the data unchanged onwards to the next stream cb(null, data); }, // flush() is called when everything is done flush(cb) { // We concatenate all the buffered chunks of HTML to get the full HTML // then cache it at "key" if (!self.cacheDisabled && self.cache) { self.cache.set( key, self.minifyHtml(Buffer.concat(bufferedChunks).toString()) ); logger.info(`Cache stream for [${key}] finished!`); } cb(); }, }); } /** * Check if provided key being found in the current cache pool * @param key * @return {boolean} */ isCacheMatched(key) { if (this.cache && this.cache.has(key)) { logger.info(`Cache for [${key}] matched!`); return true; } return false; } /** * Set content-type for rendered html * @param ctx {object} koa ctx */ setHtmlContentType(ctx) { ctx.set({ 'Content-Type': 'text/html; charset=UTF-8', }); } minifyHtml(html) { if (DEV_MODE) { return html; } return minify(html, { collapseWhitespace: true }); } } module.exports = ServerRenderer;