From 5623001497e3f928eb31f4ed46e5b97239516c29 Mon Sep 17 00:00:00 2001 From: Jerko Steiner Date: Thu, 26 Sep 2019 23:35:53 +0700 Subject: [PATCH] Refactor React server-side rendering --- packages/client/src/renderer/ClientConfig.ts | 3 + packages/scripts/src/scripts/build.ts | 4 +- packages/server/src/index.ts | 2 + packages/server/src/middleware/Frontend.ts | 81 +++++++++++++++++++ packages/server/src/middleware/index.ts | 1 + packages/server/src/react/HTMLSink.ts | 70 ++++++++++++++++ .../src/react}/ServerRenderer.tsx | 5 +- packages/server/src/react/Sink.ts | 10 +++ packages/server/src/react/index.ts | 3 + .../types/react-ssr-prepass.d.ts | 0 packages/tasq/src/debounce.ts | 2 +- 11 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/middleware/Frontend.ts create mode 100644 packages/server/src/react/HTMLSink.ts rename packages/{client/src/renderer => server/src/react}/ServerRenderer.tsx (94%) create mode 100644 packages/server/src/react/Sink.ts create mode 100644 packages/server/src/react/index.ts rename packages/{client => server}/types/react-ssr-prepass.d.ts (100%) diff --git a/packages/client/src/renderer/ClientConfig.ts b/packages/client/src/renderer/ClientConfig.ts index ae990c0..63c688a 100644 --- a/packages/client/src/renderer/ClientConfig.ts +++ b/packages/client/src/renderer/ClientConfig.ts @@ -1,5 +1,8 @@ +import { UserProfile } from '@rondo.dev/common' + export interface ClientConfig { readonly appName: string readonly baseUrl: string readonly csrfToken: string + readonly user?: UserProfile } diff --git a/packages/scripts/src/scripts/build.ts b/packages/scripts/src/scripts/build.ts index fca87cd..5e5c509 100644 --- a/packages/scripts/src/scripts/build.ts +++ b/packages/scripts/src/scripts/build.ts @@ -102,7 +102,7 @@ function findTsConfig(file: string): string { async function browserify(path = '.', ...extraArgs: string[]) { // mkdirSync(join(path, 'build'), {recursive: true}) await run('browserify', [ - join(path, 'esm', 'index.js'), + join(path, 'esm', 'entrypoint.js'), '-g', '[', 'loose-envify', 'purge', '--NODE_ENV', 'production', ']', '-p', '[', 'esmify', ']', '-p', '[', 'common-shakeify', '-v', ']', @@ -142,7 +142,7 @@ async function buildJs(path: string) { async function watchJs(path: string, ...extraArgs: string[]) { await run('watchify', [ - join(path, 'esm', 'index.js'), + join(path, 'esm', 'entrypoint.js'), // '-p', '[', 'tsify', '--project', path, ']', '-g', '[', 'loose-envify', 'purge', '--NODE_ENV', 'development', ']', '-v', diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a2ef035..33582cb 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -10,8 +10,10 @@ export * from './entities/BaseEntitySchemaPart' export * from './error' export * from './logger' export * from './middleware' +export * from './react' export * from './router' export * from './routes' +export * from './rpc' export * from './services' export * from './session' diff --git a/packages/server/src/middleware/Frontend.ts b/packages/server/src/middleware/Frontend.ts new file mode 100644 index 0000000..c9f3b09 --- /dev/null +++ b/packages/server/src/middleware/Frontend.ts @@ -0,0 +1,81 @@ +import { ClientConfig } from '@rondo.dev/client' +import { BulkActions, BulkClient } from '@rondo.dev/jsonrpc' +import express from 'express' +import { Store } from 'redux' +import { ServerRenderer, HTMLSink } from '../react' +import { Middleware } from './Middleware' +import { Handler } from './Handler' +import { apiLogger } from '../logger' +import { IncomingHttpHeaders } from 'http' + +type ServiceFactory = (req: express.Request) => + BulkActions> + +interface ServerSideRendererParams { + readonly appName: string + readonly assetsPaths: string[] + readonly Application: React.ComponentType + // TODO remove headers parameter and figure out how to do without it + readonly buildProps: ( + config: ClientConfig, headers: IncomingHttpHeaders) => Props + readonly buildStore: (config: ClientConfig) => Store +} + +export class Frontend implements Middleware { + readonly handle: Handler + readonly renderer: ServerRenderer + readonly sink = new HTMLSink() + + constructor( + readonly params: ServerSideRendererParams, + ) { + const router = express.Router() + this.renderer = new ServerRenderer(params.Application) + + this.configure(router) + this.handle = router + } + + protected configure(router: express.Router) { + const {appName} = this.params + this.params.assetsPaths.forEach(path => { + apiLogger.info('Using assets path: %s', path) + router.use('/assets', express.static(path)) + }) + + router.get('/*', (request, response, next) => (async (req, res) => { + const {baseUrl} = req + const csrfToken = req.csrfToken() + const config: ClientConfig = { + appName, + baseUrl, + csrfToken, + user: req.user, + } + + const store = this.params.buildStore(config) + const props = this.params.buildProps(config, req.headers) + + try { + const stream = await this.renderer.render(req.url, store, props, config) + const state = store.getState() + await this.sink.pipe(stream, res, config, state) + } catch (err) { + // TODO test thoroughly if the fix below works + apiLogger.error('Error in React SSR: %s', err.stack) + if (!res.writable) { + return + } + if (res.headersSent) { + res.write('An error occurred') + res.end() + } else { + res.status(500) + res.send('An error occurred') + } + } + })(request, response)) + + } + +} diff --git a/packages/server/src/middleware/index.ts b/packages/server/src/middleware/index.ts index e8e0a7d..f68b958 100644 --- a/packages/server/src/middleware/index.ts +++ b/packages/server/src/middleware/index.ts @@ -11,3 +11,4 @@ export * from './PromiseHandler' export * from './TransactionMiddleware' export * from './ensureLoggedIn' export * from './handlePromise' +export * from './Frontend' diff --git a/packages/server/src/react/HTMLSink.ts b/packages/server/src/react/HTMLSink.ts new file mode 100644 index 0000000..c048f25 --- /dev/null +++ b/packages/server/src/react/HTMLSink.ts @@ -0,0 +1,70 @@ +import { Sink } from './Sink' +import { ClientConfig } from '@rondo.dev/client' +import { apiLogger } from '../logger' + +export type GetHeader = (config: Config) => string +export type GetFooter = ( + config: ClientConfig, stateJSON: string) => string + +const getHeaderDefault: GetHeader = config => ` + + + ${config.appName} + + + + + + +
` + +const getFooterDefault: GetFooter = (config, state) => `
+ + + +` + +export class HTMLSink implements Sink { + + constructor( + protected readonly getHeader: GetHeader = getHeaderDefault, + protected readonly getFooter: GetFooter = getFooterDefault, + ) { + + } + + protected stringifyState(state: State): string { + // TODO figure out a better way to escape state JSON + return JSON + .stringify(state) + .replace(/( + reactStream: NodeJS.ReadableStream, + htmlStream: NodeJS.WritableStream, + config: Config, + state: State, + ): Promise { + return new Promise((resolve, reject) => { + htmlStream.write(this.getHeader(config)) + + reactStream.on('error', (err: Error) => { + apiLogger.error('Error in React SSR: %s', err.stack) + reject(err) + }) + + reactStream.pipe(htmlStream, { end: false }) + + reactStream.on('end', () => { + htmlStream.write(this.getFooter(config, this.stringifyState(state))) + htmlStream.end() + resolve() + }) + }) + } +} diff --git a/packages/client/src/renderer/ServerRenderer.tsx b/packages/server/src/react/ServerRenderer.tsx similarity index 94% rename from packages/client/src/renderer/ServerRenderer.tsx rename to packages/server/src/react/ServerRenderer.tsx index 96bd262..e71fd69 100644 --- a/packages/client/src/renderer/ServerRenderer.tsx +++ b/packages/server/src/react/ServerRenderer.tsx @@ -1,3 +1,4 @@ +import { ClientConfig, Renderer } from '@rondo.dev/client' import React from 'react' import { renderToNodeStream } from 'react-dom/server' import { Provider } from 'react-redux' @@ -6,8 +7,6 @@ import { StaticRouter } from 'react-router-dom' import ssrPrepass from 'react-ssr-prepass' import { Store } from 'redux' import { ServerStyleSheet } from 'styled-components' -import { ClientConfig } from './ClientConfig' -import { Renderer } from './Renderer' interface ComponentWithFetchData { fetchData(): Promise @@ -17,6 +16,7 @@ export class ServerRenderer implements Renderer { constructor( readonly RootComponent: React.ComponentType, ) {} + async render( url: string, store: Store, @@ -50,4 +50,5 @@ export class ServerRenderer implements Renderer { const stream = sheet.interleaveWithNodeStream(renderToNodeStream(element)) return stream } + } diff --git a/packages/server/src/react/Sink.ts b/packages/server/src/react/Sink.ts new file mode 100644 index 0000000..535df0c --- /dev/null +++ b/packages/server/src/react/Sink.ts @@ -0,0 +1,10 @@ +import { ClientConfig } from '@rondo.dev/client' + +export interface Sink { + pipe( + reactStream: NodeJS.ReadableStream, + htmlStream: NodeJS.WritableStream, + config: Config, + state: State, + ): Promise +} diff --git a/packages/server/src/react/index.ts b/packages/server/src/react/index.ts new file mode 100644 index 0000000..5d83447 --- /dev/null +++ b/packages/server/src/react/index.ts @@ -0,0 +1,3 @@ +export * from './ServerRenderer' +export * from './HTMLSink' +export * from './Sink' diff --git a/packages/client/types/react-ssr-prepass.d.ts b/packages/server/types/react-ssr-prepass.d.ts similarity index 100% rename from packages/client/types/react-ssr-prepass.d.ts rename to packages/server/types/react-ssr-prepass.d.ts diff --git a/packages/tasq/src/debounce.ts b/packages/tasq/src/debounce.ts index affa369..08dee5f 100644 --- a/packages/tasq/src/debounce.ts +++ b/packages/tasq/src/debounce.ts @@ -1,5 +1,5 @@ export function debounce(fn: (...args: A[]) => R, delay: number) { - let timeout: NodeJS.Timeout | null = null + let timeout: number | null = null const cancel = () => { if (timeout) {