UNPKG

electrode-redux-router-engine

Version:

Handle async data for React Server Side Rendering using Redux and Router

326 lines (268 loc) 9.38 kB
"use strict"; /* eslint-disable max-statements, prefer-spread, global-require, complexity */ const Path = require("path"); const assert = require("assert"); const Url = require("url"); const optionalRequire = require("optional-require")(require); const React = optionalRequire("react"); const ReactDomServer = optionalRequire("react-dom/server"); const Provider = require("react-redux").Provider; const { StaticRouter } = require("react-router-dom"); const { matchRoutes, renderRoutes } = require("react-router-config"); const { combineReducers, createStore } = require("redux"); const pkg = require("../package.json"); const util = require("./util"); const ServerContext = require("./server-context"); const { Stream } = require("stream"); const BAD_CHARS_REGEXP = /[<\u2028\u2029]/g; const REPLACEMENTS_FOR_BAD_CHARS = { "<": "\\u003C", "\u2028": "\\u2028", "\u2029": "\\u2029" }; function escapeBadChars(sourceString) { return sourceString.replace(BAD_CHARS_REGEXP, match => REPLACEMENTS_FOR_BAD_CHARS[match]); } const ROUTE_HANDLER = Symbol("route handler"); class ReduxRouterEngine { constructor(options) { assert(options.routes, "Must provide react-router routes for redux-router-engine"); this.options = Object.assign({ webappPrefix: "", basename: "" }, options); this.options.withIds = Boolean(options.withIds); // generate __PRELOADED_STATE__ or __<prefix>_PRELOADED_STATE__ const preloadedStateName = ["_", this.options.webappPrefix, "PRELOADED_STATE__"] .filter(x => x) .join("_"); if (!options.stringifyPreloadedState) { this.options.stringifyPreloadedState = state => `window.${preloadedStateName} = ${escapeBadChars(JSON.stringify(state))};`; } if (!this.options.logError) { this.options.logError = (req, err) => console.log(`${pkg.name} Error:`, err); //eslint-disable-line } if (this.options.renderToString) { this._renderToString = this.options.renderToString; } this._streaming = Boolean(options.streaming); // if options.routes is a string, then treat it as a path to the routes source for require if (typeof options.routes === "string") { const x = util.resolveModulePath(options.routes); this._routes = util.es6Default(require(x)); } else { this._routes = options.routes; } this._routesDir = options.routesHandlerPath ? Path.resolve(options.routesHandlerPath) : Path.resolve(process.env.APP_SRC_DIR || "", "server/routes"); this._routesComponent = renderRoutes(this._routes); this._envTargets = util.getEnvTargets(); } getStreamWritable() { const writable = new Stream.PassThrough(); writable.setEncoding("utf8"); const output = { result: "" }; writable.on("data", (chunk) => { output.result += chunk; }); const completed = new Promise((resolve) => { writable.on("finish", () => { resolve(); }); }); return { writable, completed, output }; } startMatch(req, options = {}) { // hapi@18 compatibility: use "origin" to determine (WHATWG has origin, Url.parse does not) // https://github.com/hapijs/hapi/issues/3871 const url = typeof req.url === "object" && "origin" in req.url && req.url.href ? req.url.href : req.url; const location = options.location || Url.parse(url || req.path); options = Object.assign({}, options, { req, location }); options.match = this._matchRoute(req, this._routes, location); return options; } checkMatch(options) { const location = options.location; const match = options.match; if (match.length === 0) { return { status: 404, message: `${pkg.name}: Path ${location.path} not found` }; } const methods = match[0].route.methods || "get"; if (methods.toLowerCase().indexOf(options.req.method.toLowerCase()) < 0) { throw new Error( `${pkg.name}: ${location.path} doesn't allow request method ${options.req.method}` ); } return undefined; } async render(req, options) { try { options = this.startMatch(req, options); const earlyOut = this.checkMatch(options); if (earlyOut) return earlyOut; await this.prepReduxStore(options); return await this._handleRender(options); } catch (err) { this.options.logError.call(this, req, err); return { status: err.status || 500, // eslint-disable-line message: err.message, path: err.path || options.location.path, _err: err }; } } // _matchRoute(req, routes, location) { let pathname = location.pathname; if (this.options.basename) { if (!pathname.startsWith(this.options.basename)) { // route has a basename, but path doesn't start with basename return []; } else { pathname = pathname.replace(this.options.basename, ""); } } return matchRoutes(routes, pathname); } async prepReduxStore(options) { options.withIds = options.withIds !== undefined ? options.withIds : this.options.withIds; const inits = []; const match = options.match; for (let ri = 1; ri < match.length; ri++) { const route = match[ri].route; const init = this._getRouteInit(route); if (init) { inits.push( init({ req: options.req, location: options.location, match: options.match, route, inits }) ); } } let awaited = false; const awaitInits = async () => { if (awaited) return; awaited = true; for (let x = 0; x < inits.length; x++) { if (inits[x].then) inits[x] = await inits[x]; } }; let topInit = this._getRouteInit(match[0].route); if (topInit) { topInit = topInit({ req: options.req, location: options.location, match, route: match[0].route, inits, awaitInits }); } if (topInit.then) { await awaitInits(); topInit = await topInit; } if (topInit.store) { // top route provided a ready made store, just use it options.store = topInit.store; } else { if (!awaited) await awaitInits(); let reducer; let initialState; if (topInit.initialState || inits.length > 0) { initialState = Object.assign.apply( null, [{}, topInit.initialState].concat(inits.map(x => x.initialState)) ); } else { // no route provided any initialState initialState = {}; } if (typeof topInit.reducer === "function") { // top route provided a ready made reducer reducer = topInit.reducer; } else if (topInit.reducer || inits.length > 0) { // top route only provide its own reducer and initialState const allReducers = Object.assign.apply( null, [{}, topInit.reducer].concat(inits.map(x => x.reducer)) ); reducer = combineReducers(allReducers); } else { // no route provided any reducer reducer = x => x; } options.store = createStore(reducer, initialState); } return options.store; } async _handleRender(options) { const routeContext = (options.routeContext = {}); const stringifyPreloadedState = options.stringifyPreloadedState || this.options.stringifyPreloadedState; let html = this._renderToString(options); if (html.then !== undefined) { // a Promise? html = await html; } if (this.options.componentRedirect && routeContext.action === "REPLACE") { return { status: 302, html, path: routeContext.url, store: options.store }; } else { return { status: 200, html, prefetch: stringifyPreloadedState(options.store.getState()) }; } } async _renderToString({ req, location, store, routeContext }) { if (req.app && req.app.disableSSR) { return "<!-- SSR disabled by request -->"; } else { const element = React.createElement( // server side context to provide request ServerContext, { request: req }, // redux provider React.createElement( Provider, { store }, // user route component React.createElement( StaticRouter, { location, context: routeContext, basename: this.options.basename }, this._routesComponent ) ) ); if (!this._streaming) return ReactDomServer.renderToString(element); const { writable, output, completed } = this.getStreamWritable(); const { pipe } = await ReactDomServer.renderToPipeableStream(element); pipe(writable); await completed; return output.result; } } _getRouteInit(route) { let h = route[ROUTE_HANDLER]; if (h !== undefined) return h; if (!route.init) { h = false; } else if (route.init === true) { h = Path.join(this._routesDir, route.path); } else { assert(typeof route.init === "string", `${pkg.name}: route init prop must be a string`); h = util.resolveModulePath(route.init, this._routesDir); } if (h) { h = util.es6Default(require(h)); } route[ROUTE_HANDLER] = h; return h; } } module.exports = ReduxRouterEngine;