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 {
|
export interface ClientConfig {
|
||||||
readonly appName: string
|
readonly appName: string
|
||||||
readonly baseUrl: string
|
readonly baseUrl: string
|
||||||
readonly csrfToken: string
|
readonly csrfToken: string
|
||||||
|
readonly user?: UserProfile
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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'
|
||||||
|
|
||||||
|
|||||||
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 './TransactionMiddleware'
|
||||||
export * from './ensureLoggedIn'
|
export * from './ensureLoggedIn'
|
||||||
export * from './handlePromise'
|
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 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
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
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) {
|
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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user