Refactor React server-side rendering

This commit is contained in:
Jerko Steiner 2019-09-26 23:35:53 +07:00
parent 6c56b51b4e
commit 5623001497
11 changed files with 176 additions and 5 deletions

View File

@ -1,5 +1,8 @@
import { UserProfile } from '@rondo.dev/common'
export interface ClientConfig { export interface ClientConfig {
readonly appName: string readonly appName: string
readonly baseUrl: string readonly baseUrl: string
readonly csrfToken: string readonly csrfToken: string
readonly user?: UserProfile
} }

View File

@ -102,7 +102,7 @@ function findTsConfig(file: string): string {
async function browserify(path = '.', ...extraArgs: string[]) { async function browserify(path = '.', ...extraArgs: string[]) {
// mkdirSync(join(path, 'build'), {recursive: true}) // mkdirSync(join(path, 'build'), {recursive: true})
await run('browserify', [ await run('browserify', [
join(path, 'esm', 'index.js'), join(path, 'esm', 'entrypoint.js'),
'-g', '[', 'loose-envify', 'purge', '--NODE_ENV', 'production', ']', '-g', '[', 'loose-envify', 'purge', '--NODE_ENV', 'production', ']',
'-p', '[', 'esmify', ']', '-p', '[', 'esmify', ']',
'-p', '[', 'common-shakeify', '-v', ']', '-p', '[', 'common-shakeify', '-v', ']',
@ -142,7 +142,7 @@ async function buildJs(path: string) {
async function watchJs(path: string, ...extraArgs: string[]) { async function watchJs(path: string, ...extraArgs: string[]) {
await run('watchify', [ await run('watchify', [
join(path, 'esm', 'index.js'), join(path, 'esm', 'entrypoint.js'),
// '-p', '[', 'tsify', '--project', path, ']', // '-p', '[', 'tsify', '--project', path, ']',
'-g', '[', 'loose-envify', 'purge', '--NODE_ENV', 'development', ']', '-g', '[', 'loose-envify', 'purge', '--NODE_ENV', 'development', ']',
'-v', '-v',

View File

@ -10,8 +10,10 @@ export * from './entities/BaseEntitySchemaPart'
export * from './error' export * from './error'
export * from './logger' export * from './logger'
export * from './middleware' export * from './middleware'
export * from './react'
export * from './router' export * from './router'
export * from './routes' export * from './routes'
export * from './rpc'
export * from './services' export * from './services'
export * from './session' export * from './session'

View File

@ -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<Services> = (req: express.Request) =>
BulkActions<BulkClient<Services>>
interface ServerSideRendererParams<Props, State> {
readonly appName: string
readonly assetsPaths: string[]
readonly Application: React.ComponentType<Props>
// TODO remove headers parameter and figure out how to do without it
readonly buildProps: (
config: ClientConfig, headers: IncomingHttpHeaders) => Props
readonly buildStore: (config: ClientConfig) => Store<State>
}
export class Frontend<Services, Props, State> implements Middleware {
readonly handle: Handler
readonly renderer: ServerRenderer<Props>
readonly sink = new HTMLSink()
constructor(
readonly params: ServerSideRendererParams<Props, State>,
) {
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))
}
}

View File

@ -11,3 +11,4 @@ export * from './PromiseHandler'
export * from './TransactionMiddleware' export * from './TransactionMiddleware'
export * from './ensureLoggedIn' export * from './ensureLoggedIn'
export * from './handlePromise' export * from './handlePromise'
export * from './Frontend'

View File

@ -0,0 +1,70 @@
import { Sink } from './Sink'
import { ClientConfig } from '@rondo.dev/client'
import { apiLogger } from '../logger'
export type GetHeader = <Config extends ClientConfig>(config: Config) => string
export type GetFooter = <Config extends ClientConfig>(
config: ClientConfig, stateJSON: string) => string
const getHeaderDefault: GetHeader = config => `<!DOCTYPE html><html>
<head>
<meta charset='UTF-8'>
<title>${config.appName}</title>
<meta name="description" content="An embeddable discussion platform">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel='stylesheet' type='text/css'
href='${config.baseUrl}/assets/style.css'>
<link rel='icon' type='image/png' href='${config.baseUrl}/assets/logo-32.png'>
</head>
<body>
<div id="container">`
const getFooterDefault: GetFooter = (config, state) => `</div>
</body>
<script>
window.__APP_CONFIG__ = ${JSON.stringify(config)};
window.__PRELOADED_STATE__ = ${state};
</script>
<script src='${config.baseUrl}/assets/client.js'></script>
</html>`
export class HTMLSink implements Sink {
constructor(
protected readonly getHeader: GetHeader = getHeaderDefault,
protected readonly getFooter: GetFooter = getFooterDefault,
) {
}
protected stringifyState<State>(state: State): string {
// TODO figure out a better way to escape state JSON
return JSON
.stringify(state)
.replace(/</g, '\\u003c')
}
async pipe<Config extends ClientConfig, State>(
reactStream: NodeJS.ReadableStream,
htmlStream: NodeJS.WritableStream,
config: Config,
state: State,
): Promise<void> {
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()
})
})
}
}

View File

@ -1,3 +1,4 @@
import { ClientConfig, Renderer } from '@rondo.dev/client'
import React from 'react' import React from 'react'
import { renderToNodeStream } from 'react-dom/server' import { renderToNodeStream } from 'react-dom/server'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
@ -6,8 +7,6 @@ import { StaticRouter } from 'react-router-dom'
import ssrPrepass from 'react-ssr-prepass' import ssrPrepass from 'react-ssr-prepass'
import { Store } from 'redux' import { Store } from 'redux'
import { ServerStyleSheet } from 'styled-components' import { ServerStyleSheet } from 'styled-components'
import { ClientConfig } from './ClientConfig'
import { Renderer } from './Renderer'
interface ComponentWithFetchData { interface ComponentWithFetchData {
fetchData(): Promise<unknown> fetchData(): Promise<unknown>
@ -17,6 +16,7 @@ export class ServerRenderer<Props> implements Renderer<Props> {
constructor( constructor(
readonly RootComponent: React.ComponentType<Props>, readonly RootComponent: React.ComponentType<Props>,
) {} ) {}
async render<State>( async render<State>(
url: string, url: string,
store: Store<State>, store: Store<State>,
@ -50,4 +50,5 @@ export class ServerRenderer<Props> implements Renderer<Props> {
const stream = sheet.interleaveWithNodeStream(renderToNodeStream(element)) const stream = sheet.interleaveWithNodeStream(renderToNodeStream(element))
return stream return stream
} }
} }

View File

@ -0,0 +1,10 @@
import { ClientConfig } from '@rondo.dev/client'
export interface Sink {
pipe<Config extends ClientConfig, State>(
reactStream: NodeJS.ReadableStream,
htmlStream: NodeJS.WritableStream,
config: Config,
state: State,
): Promise<void>
}

View File

@ -0,0 +1,3 @@
export * from './ServerRenderer'
export * from './HTMLSink'
export * from './Sink'

View File

@ -1,5 +1,5 @@
export function debounce<A, R>(fn: (...args: A[]) => R, delay: number) { export function debounce<A, R>(fn: (...args: A[]) => R, delay: number) {
let timeout: NodeJS.Timeout | null = null let timeout: number | null = null
const cancel = () => { const cancel = () => {
if (timeout) { if (timeout) {