Refactor React server-side rendering
This commit is contained in:
parent
6c56b51b4e
commit
5623001497
@ -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
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
81
packages/server/src/middleware/Frontend.ts
Normal file
81
packages/server/src/middleware/Frontend.ts
Normal 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))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -11,3 +11,4 @@ export * from './PromiseHandler'
|
||||
export * from './TransactionMiddleware'
|
||||
export * from './ensureLoggedIn'
|
||||
export * from './handlePromise'
|
||||
export * from './Frontend'
|
||||
|
||||
70
packages/server/src/react/HTMLSink.ts
Normal file
70
packages/server/src/react/HTMLSink.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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<unknown>
|
||||
@ -17,6 +16,7 @@ export class ServerRenderer<Props> implements Renderer<Props> {
|
||||
constructor(
|
||||
readonly RootComponent: React.ComponentType<Props>,
|
||||
) {}
|
||||
|
||||
async render<State>(
|
||||
url: string,
|
||||
store: Store<State>,
|
||||
@ -50,4 +50,5 @@ export class ServerRenderer<Props> implements Renderer<Props> {
|
||||
const stream = sheet.interleaveWithNodeStream(renderToNodeStream(element))
|
||||
return stream
|
||||
}
|
||||
|
||||
}
|
||||
10
packages/server/src/react/Sink.ts
Normal file
10
packages/server/src/react/Sink.ts
Normal 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>
|
||||
}
|
||||
3
packages/server/src/react/index.ts
Normal file
3
packages/server/src/react/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './ServerRenderer'
|
||||
export * from './HTMLSink'
|
||||
export * from './Sink'
|
||||
@ -1,5 +1,5 @@
|
||||
export function debounce<A, R>(fn: (...args: A[]) => R, delay: number) {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
let timeout: number | null = null
|
||||
|
||||
const cancel = () => {
|
||||
if (timeout) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user