import Boom from '@hapi/boom' ; import TwindTypography from '@twind/typography' ; import { htmlEscape } from 'escape-goat' ; import jsesc from 'jsesc' ; import * as React from 'react' ; import { renderToString } from 'react-dom/server' ; import * as Markup from 'react-helmet-async' ; import * as ReactQuery from 'react-query' ; import * as ReactQueryHydration from 'react-query/hydration' ; import type * as ReactRouter from 'react-router' ; import * as ReactRouterDOM from 'react-router-dom' ; import { install } from 'source-map-support' ; import * as Twind from 'twind' ; import * as TwindServer from 'twind/shim/server' ; import type { ChunkDependencies } from '../../../build/types' ; import type { ClientAuth } from '../../auth' ; import { AuthContext , ServerAuth } from '../../auth/server' ; import { ServerQueryContextProvider , ServerQueryExecutorImpl } from '../../functions/server' ; import type { ServerFunction , ServerFunctionContext } from '../../functions/types' ; import { LazyContext } from '../../lazy/context' ; import type { ChunkManager } from '../../lazy/types' ; import { defaultMarkupProps } from '../../markup' ; import { TwindContext } from '../../styling/internal' ; import type { BootstrapOptions } from '../bootstrap/bootstrap' ; declare global { var __nostalgie_css ; } if ( process . env . NODE_ENV === 'development' ) { install ( { environment : 'node' , } ) ; } export interface ServerRenderRequest { auth : ServerAuth ; automaticReload ? : boolean ; path : string ; } interface ServerRendererSettings { defaultDeadline : number ; enableTailwind : boolean ; maxIterations : number ; } export interface ServerRendererOptions extends Partial < ServerRendererSettings > { } export class ServerRenderer { private readonly app : React . ComponentType ; private readonly functions : Record < string , ServerFunction | undefined > ; private readonly chunkDependencies : ChunkDependencies ; private readonly settings : ServerRendererSettings ; constructor ( app , functions , chunkDependencies , options = { } ) { this . app = app ; this . functions = functions ; this . chunkDependencies = chunkDependencies ; this . settings = { defaultDeadline : options . defaultDeadline ?? 500 , enableTailwind : ! ! options . enableTailwind , maxIterations : options . maxIterations ?? 5 , } ; } async invokeFunction ( functionName , ctx , args ) { const functionImpl = this . functions [ functionName ] as ServerFunction | undefined ; if ( ! functionImpl ) { throw Boom . notFound ( ) ; } return functionImpl ( ctx , ... args ) ; } async renderAppOnServer ( request ) { const start = Date . now ( ) ; const bootstrapChunk = 'static/build/bootstrap.js' ; const markupCtx = { } ; const chunkCtx = { chunks : [ ] , lazyComponentState : new Map ( ) , } ; const queryClient = new ReactQuery . QueryClient ( ) ; const initialReactQueryState = ReactQueryHydration . dehydrate ( queryClient ) ; const auth = this . serverAuthToClientAuth ( request . auth ) ; const queryExecutor = new ServerQueryExecutorImpl ( { auth : request . auth , queryClient } ) ; const customSheet = TwindServer . virtualSheet ( ) ; const routerCtx = { } ; const { tw } = Twind . create ( { sheet : customSheet , mode : 'silent' , prefix : true , preflight : this . settings . enableTailwind , plugins : { ... ( this . settings . enableTailwind ? TwindTypography ( ) : { } ) , } , } ) ; const model = ( < TwindContext . Provider value = { tw } > < LazyContext . Provider value = { chunkCtx } > < ServerQueryContextProvider queryExecutor = { queryExecutor } > < ReactQueryHydration . Hydrate state = { initialReactQueryState } > < Markup . HelmetProvider context = { markupCtx } > < AuthContext . Provider value = { auth } > < ReactRouterDOM . StaticRouter location = { request . path } context = { routerCtx } > < Markup . Helmet { ... defaultMarkupProps } > < /Markup.Helmet> < this . app / > < /ReactRouterDOM.StaticRouter> < /AuthContext.Provider> < /Markup.HelmetProvider> < /ReactQueryHydration.Hydrate> < /ServerQueryContextProvider> < /LazyContext.Provider> < /TwindContext.Provider>); let renderCount = 1 ; let renderedMarkup = '' ; try { const deadlineAt = Date . now ( ) + this . settings . defaultDeadline ; const deadlinePromise = new Promise ( ( r ) => setTimeout ( r , this . settings . defaultDeadline ) ) ; let remainingIterations = this . settings . maxIterations ; customSheet . reset ( ) ; let html = renderToString ( model ) ; for ( ; Date . now ( ) <= deadlineAt && queryExecutor . promises . size && remainingIterations && ! routerCtx . url && ! routerCtx . statusCode ; remainingIterations -- ) { try { await Promise . race ( [ deadlinePromise , ... queryExecutor . promises ] ) ; customSheet . reset ( ) ; html = renderToString ( model ) ; renderCount ++ ; } catch { break ; } } renderedMarkup = html ?? ( customSheet . reset ( ) , renderCount ++ , renderToString ( model ) ) ; } catch ( err ) { if ( process . env . NODE_ENV === 'development' ) { console . error ( err ) ; } } queryClient . cancelQueries ( ) ; if ( routerCtx . url ) { return { headers : { location : routerCtx . url , } , statusCode : routerCtx . statusCode || 302 , html : `<p>You are being redirected to ${ htmlEscape ( routerCtx . url ) }</p>` , renderCount : renderCount , queries : queryExecutor . promises . size , latency : Date . now ( ) - start , } ; } const queryClientData = ReactQueryHydration . dehydrate ( queryClient ) ; renderedMarkup ??= ( customSheet . reset ( ) , renderCount ++ , renderToString ( model ) ) ; if ( this . settings . enableTailwind ) { renderedMarkup = TwindServer . shim ( renderedMarkup , tw ) ; } const headTags = [ ] ; const { helmet } = markupCtx as Markup . FilledContext ; const requiredChunks = [ bootstrapChunk , ... chunkCtx . chunks . map ( ( chunk ) => chunk . chunk ) ] ; const seenChunks = new Set ( ) ; while ( requiredChunks . length ) { const chunk = requiredChunks . shift ( ) ! ; if ( seenChunks . has ( chunk ) ) continue ; seenChunks . add ( chunk ) ; const chunkDeps = this . chunkDependencies [ chunk ] ; if ( chunkDeps ) { for ( const chunkDep of chunkDeps . modules ) { headTags . push ( `<link rel="modulepreload" href="${ chunkDep }">` ) ; requiredChunks . push ( chunkDep . slice ( 1 ) ) ; } for ( const chunkDep of chunkDeps . styles ) { headTags . push ( `<style data-ns-css=${ JSON . stringify ( chunkDep . relPath ) }>${ chunkDep . css }</style>` ) ; } } } const publicUrl = encodeURI ( '/' ) ; const htmlAttrs = helmet . htmlAttributes . toString ( ) ; const bodyAttrs = helmet . bodyAttributes . toString ( ) ; const bootstrapOptions = { auth : this . serverAuthToClientAuth ( request . auth ) , automaticReload : request . automaticReload , enableTailwind : this . settings . enableTailwind , lazyComponents : chunkCtx . chunks , publicUrl , reactQueryState : queryClientData , } ; const wrapper = `
<!doctype html>
<html ${ htmlAttrs }>
<head>
${ helmet . title . toString ( ) }
${ helmet . meta . toString ( ) }
<link rel="modulepreload" href="${ publicUrl }static/build/bootstrap.js" />
${ chunkCtx . chunks . map ( ( { chunk } ) => `<link rel="modulepreload" href="${ publicUrl }${ encodeURI ( chunk ) }" />` ) . join ( '\n' ) }
${ helmet . link . toString ( ) }
${ headTags . join ( '\n' ) }
${ helmet . noscript . toString ( ) }
${ helmet . script . toString ( ) }
${ TwindServer . getStyleTag ( customSheet ) }
${ helmet . style . toString ( ) }
</head>
<body ${ bodyAttrs }>
<div id="root">${ renderedMarkup }</div>
<script async type="module">
import { start } from "${ publicUrl }static/build/bootstrap.js";
start(${ jsesc ( bootstrapOptions , { es6 : true , isScriptContext : true , minimal : true } ) });
</script>
</body>
</html>` . trim ( ) ; return { html : wrapper , renderCount : renderCount , queries : queryExecutor . promises . size , latency : Date . now ( ) - start , } ; } private serverAuthToClientAuth ( auth ) { return auth . isAuthenticated ? { isAuthenticated : true , credentials : auth . credentials , loginUrl : '/.nostalgie/login' , logoutUrl : '/.nostalgie/logout' , } : { isAuthenticated : false , error : auth . error , loginUrl : '/.nostalgie/login' , logoutUrl : '/.nostalgie/logout' , } ; } }