import { IncomingMessage , ServerResponse , Server , createServer } from 'http' ; import util from 'util' ; import path from 'path' ; import { fs , ROUTE_SPEC_FILE } from '@modern-js/utils' ; import { Adapter , APIServerStartInput } from '@modern-js/server-plugin' ; import { createMiddlewareCollecter } from '@modern-js/server-utils' ; import type { NormalizedConfig } from '@modern-js/core' ; import mime from 'mime-types' ; import axios from 'axios' ; import { ModernServerOptions , NextFunction , ServerHookRunner , Metrics , Logger , ReadyOptions , ConfWithBFF , } from '../type' ; import { RouteMatchManager , ModernRouteInterface , ModernRoute , RouteMatcher , } from '../libs/route' ; import { createRenderHandler } from '../libs/render' ; import { createStaticFileHandler } from '../libs/serve-file' ; import { createErrorDocument , mergeExtension , noop } from '../utils' ; import * as reader from '../libs/render/reader' ; import { createProxyHandler , ProxyOptions } from '../libs/proxy' ; import { createContext , ModernServerContext } from '../libs/context' ; import { AGGRED_DIR , ApiServerMode , ERROR_DIGEST , ERROR_PAGE_TEXT , } from '../constants' ; import { createTemplateAPI } from '../libs/hook-api/template' ; import { createRouteAPI } from '../libs/hook-api/route' ; type ModernServerHandler = ( context : ModernServerContext , next : NextFunction ) => Promise < void > | void ; type ModernServerAsyncHandler = ( context : ModernServerContext , next : NextFunction ) => Promise < void > ; const API_DIR = './api' ; const SERVER_DIR = './server' ; export class ModernServer { protected pwd : string ; protected distDir : string ; protected workDir : string ; protected router ! : RouteMatchManager ; protected conf : NormalizedConfig ; protected handlers : ModernServerAsyncHandler [ ] = [ ] ; protected presetRoutes ? : ModernRouteInterface [ ] ; protected runner ! : ServerHookRunner ; protected readonly logger : Logger ; protected readonly metrics : Metrics ; protected readonly proxyTarget : ModernServerOptions [ 'proxyTarget' ] ; private readonly isDev : boolean = false ; private staticFileHandler ! : ReturnType < typeof createStaticFileHandler > ; private routeRenderHandler ! : ReturnType < typeof createRenderHandler > ; private frameWebHandler : Adapter | null = null ; private frameAPIHandler : Adapter | null = null ; private proxyHandler : ReturnType < typeof createProxyHandler > = null ; private _handler ! : ( context : ModernServerContext , next : NextFunction ) => void ; private readonly staticGenerate : boolean = false ; constructor ( { pwd , config , dev , routes , staticGenerate , logger , metrics , proxyTarget , } ) { require ( 'ignore-styles' ) ; this . isDev = Boolean ( dev ) ; this . pwd = pwd ; this . distDir = path . join ( pwd , config . output ?. path || 'dist' ) ; this . workDir = this . isDev ? pwd : this . distDir ; this . conf = config ; this . logger = logger ! ; this . metrics = metrics ! ; this . router = new RouteMatchManager ( ) ; this . presetRoutes = routes ; this . proxyTarget = proxyTarget ; if ( staticGenerate ) { this . staticGenerate = staticGenerate ; } process . env . BUILD_TYPE = `${ this . staticGenerate ? 'ssg' : 'ssr' }` ; } public getRequestHandler ( ) { return this . requestHandler . bind ( this ) ; } public async init ( runner ) { this . runner = runner ; const { distDir , isDev , staticGenerate , conf } = this ; this . addHandler ( ( ctx , next ) => { ctx . res . setHeader ( 'Access-Control-Allow-Origin' , '*' ) ; ctx . res . setHeader ( 'Access-Control-Allow-Credentials' , 'false' ) ; next ( ) ; } ) ; this . proxyHandler = createProxyHandler ( conf . bff ?. proxy as ProxyOptions ) ; if ( this . proxyHandler ) { this . proxyHandler . forEach ( handler => { this . addHandler ( handler ) ; } ) ; } reader . init ( ) ; this . router . reset ( this . filterRoutes ( this . presetRoutes || this . readRouteSpec ( ) ) ) ; if ( ! isDev ) { this . warmupSSRBundle ( ) ; } await this . prepareFrameHandler ( ) ; const { favicon , faviconByEntries } = this . conf . output || { } ; const favicons = this . prepareFavicons ( favicon , faviconByEntries ) ; const staticPathRegExp = new RegExp ( `^/(static/|upload/|favicon.ico|icon.png${ favicons . length > 0 ? `|${ favicons . join ( '|' ) }` : '' })` ) ; this . staticFileHandler = createStaticFileHandler ( [ { path : staticPathRegExp , target : distDir , } , ] ) ; this . routeRenderHandler = createRenderHandler ( { distDir , staticGenerate , } ) ; await this . preServerInit ( ) ; this . addHandler ( this . staticFileHandler ) ; this . addHandler ( this . routeHandler . bind ( this ) ) ; this . compose ( ) ; } public ready ( _ ) { } public onListening ( _ ) { } public close ( ) { reader . close ( ) ; } public async createHTTPServer ( handler ) { return createServer ( handler ) ; } protected readRouteSpec ( ) { const file = path . join ( this . distDir , ROUTE_SPEC_FILE ) ; if ( fs . existsSync ( file ) ) { const content = fs . readJSONSync ( file ) ; return content . routes ; } return [ ] ; } protected addHandler ( handler ) { if ( ( handler as any ) [ Symbol . toStringTag ] === 'AsyncFunction' ) { this . handlers . push ( handler as ModernServerAsyncHandler ) ; } else { this . handlers . push ( util . promisify ( handler ) ) ; } } protected render404 ( context ) { context . error ( ERROR_DIGEST . ENOTF ) ; this . renderErrorPage ( context , 404 ) ; } protected async prepareFrameHandler ( ) { const { workDir , runner } = this ; const { getMiddlewares , ... collector } = createMiddlewareCollecter ( ) ; await runner . gather ( collector ) ; const { api : pluginAPIExt , web : pluginWebExt } = getMiddlewares ( ) ; const apiDir = path . join ( workDir , API_DIR ) ; const serverDir = path . join ( workDir , SERVER_DIR ) ; if ( await fs . pathExists ( path . join ( serverDir ) ) ) { const webExtension = mergeExtension ( pluginWebExt ) ; this . frameWebHandler = await this . prepareWebHandler ( webExtension ) ; } if ( fs . existsSync ( apiDir ) ) { const mode = fs . existsSync ( path . join ( apiDir , AGGRED_DIR . lambda ) ) ? ApiServerMode . frame : ApiServerMode . func ; const apiExtension = mergeExtension ( pluginAPIExt ) ; this . frameAPIHandler = await this . prepareAPIHandler ( mode , apiExtension ) ; } } protected async proxy ( ) { return null as any ; } protected async prepareWebHandler ( extension ) { const { workDir , runner } = this ; return runner . prepareWebServer ( { pwd : workDir , config : extension , } , { onLast : ( ) => null as any } ) ; } protected async prepareAPIHandler ( mode , extension ) { const { workDir , runner , conf } = this ; const { bff } = conf as ConfWithBFF ; const prefix = bff ?. prefix || '/api' ; return runner . prepareApiServer ( { pwd : workDir , mode , config : extension , prefix : Array . isArray ( prefix ) ? prefix [ 0 ] : prefix , } , { onLast : ( ) => null as any } ) ; } protected filterRoutes ( routes ) { return routes ; } protected async emitRouteHook ( eventName , input ) { return this . runner [ eventName ] ( input , { onLast : noop as any } ) ; } protected warmupSSRBundle ( ) { const { distDir } = this ; const bundles = this . router . getBundles ( ) ; bundles . forEach ( bundle => { const filepath = path . join ( distDir , bundle as string ) ; require ( filepath ) ; } ) ; } protected async preServerInit ( ) { const { conf , runner } = this ; const preMiddleware = await runner . preServerInit ( conf ) ; preMiddleware . flat ( ) . forEach ( mid => { this . addHandler ( mid ) ; } ) ; } protected async handleAPI ( context ) { const { req , res } = context ; if ( ! this . frameAPIHandler ) { throw new Error ( 'can not found api hanlder' ) ; } await this . frameAPIHandler ( req , res ) ; } protected async handleWeb ( context , route ) { return this . routeRenderHandler ( { ctx : context , route , runner : this . runner , } ) ; } protected verifyMatch ( _c , _m ) { } private async routeHandler ( context ) { const { req , res } = context ; await this . emitRouteHook ( 'beforeMatch' , { context } ) ; const matched = this . router . match ( context . path ) ; if ( ! matched ) { this . render404 ( context ) ; return ; } else { this . verifyMatch ( context , matched ) ; } if ( res . headersSent ) { return ; } const routeAPI = createRouteAPI ( matched , this . router ) ; await this . emitRouteHook ( 'afterMatch' , { context , routeAPI } ) ; if ( res . headersSent ) { return ; } const { current } = routeAPI as any ; const route = current . generate ( ) ; const params = current . parseURLParams ( context . url ) ; context . setParams ( params ) ; if ( route . isApi ) { this . handleAPI ( context ) ; return ; } if ( this . frameWebHandler ) { await this . frameWebHandler ( req , res ) ; } if ( res . headersSent ) { return ; } if ( route . entryName ) { await this . emitRouteHook ( 'beforeRender' , { context } ) ; } const file = await this . handleWeb ( context , route ) ; if ( ! file ) { this . render404 ( context ) ; return ; } if ( file . redirect ) { res . statusCode = file . statusCode ! ; res . setHeader ( 'Location' , file . content as string ) ; res . end ( ) ; return ; } let response = file . content ; if ( route . entryName ) { const templateAPI = createTemplateAPI ( file . content . toString ( ) ) ; await this . emitRouteHook ( 'afterRender' , { context , templateAPI } ) ; await this . injectMicroFE ( context , templateAPI ) ; response = templateAPI . get ( ) ; } res . setHeader ( 'content-type' , file . contentType ) ; res . end ( response ) ; } private async injectMicroFE ( context , templateAPI ) { const { conf } = this ; const masterApp = conf . runtime ?. masterApp ; if ( ! masterApp ) { return ; } const manifest = masterApp . manifest || { } ; let modules = [ ] ; const { modules : configModules = [ ] } = manifest ; if ( typeof configModules === 'string' ) { const moduleRequestUrl = configModules ; try { const { data : remoteModules } = await axios . get ( moduleRequestUrl ) ; if ( Array . isArray ( remoteModules ) ) { modules . push ( ... remoteModules ) ; } } catch ( e ) { context . error ( ERROR_DIGEST . EMICROINJ , e as Error ) ; } } else if ( Array . isArray ( configModules ) ) { modules . push ( ... configModules ) ; } const { headers } = context . req ; const debugName = headers [ 'x-micro-frontend-module-name' ] || context . query [ '__debug__micro-frontend-module-name' ] ; const debugEntry = headers [ 'x-micro-frontend-module-entry' ] || context . query [ '__debug__micro-frontend-module-entry' ] ; if ( debugName && debugEntry && conf . server ?. enableMicroFrontendDebug ) { modules = modules . map ( m => { if ( m . name === debugName ) { return { name : debugName , entry : debugEntry , } ; } return m ; } ) ; } try { const injection = JSON . stringify ( { ... manifest , modules } ) ; templateAPI . appendHead ( `<script>window.modern_manifest=${ injection }</script>` ) ; } catch ( e ) { context . error ( ERROR_DIGEST . EMICROINJ , e as Error ) ; } } private compose ( ) { const { handlers } = this ; if ( ! Array . isArray ( handlers ) ) { throw new TypeError ( 'Middleware stack must be an array!' ) ; } for ( const fn of handlers ) { if ( typeof fn !== 'function' ) { throw new TypeError ( 'Middleware must be composed of functions!' ) ; } } this . _handler = ( context , next ) => { let i = 0 ; const dispatch = ( ) => { const handler = handlers [ i ++ ] ; if ( ! handler ) { return next ( ) ; } return handler ( context , dispatch as NextFunction ) . catch ( onError ) ; } ; const onError = ( err ) => { this . onError ( context , err ) ; } ; return dispatch ( ) ; } ; } private requestHandler ( req , res , next = ( ) => { } ) { res . statusCode = 200 ; req . logger = req . logger || this . logger ; req . metrics = req . metrics || this . metrics ; const context = createContext ( req , res ) ; try { this . _handler ( context , next ) ; } catch ( err ) { this . onError ( context , err as Error ) ; } } private onError ( context , err ) { context . error ( ERROR_DIGEST . EINTER , err ) ; this . renderErrorPage ( context , 500 ) ; } private async renderErrorPage ( context , status ) { const { res } = context ; context . status = status ; res . setHeader ( 'content-type' , mime . contentType ( 'html' ) as string ) ; const statusPage = `/${ status }` ; const customErrorPage = `/_error` ; const matched = this . router . match ( statusPage ) || this . router . match ( customErrorPage ) ; if ( matched ) { const route = matched . generate ( ) ; const { entryName } = route ; if ( entryName === status . toString ( ) || entryName === '_error' ) { try { const file = await this . routeRenderHandler ( { route , ctx : context , runner : this . runner , } ) ; if ( file ) { context . res . end ( file . content ) ; return ; } } catch ( e ) { } } } const text = ERROR_PAGE_TEXT [ status ] || ERROR_PAGE_TEXT [ 500 ] ; res . end ( createErrorDocument ( status , text ) ) ; } private prepareFavicons ( favicon , faviconByEntries ? ) { const faviconNames = [ ] ; if ( favicon ) { faviconNames . push ( favicon . substring ( favicon . lastIndexOf ( '/' ) + 1 ) ) ; } if ( faviconByEntries ) { Object . keys ( faviconByEntries ) . forEach ( f => { const curFavicon = faviconByEntries [ f ] ; if ( curFavicon ) { faviconNames . push ( curFavicon . substring ( curFavicon . lastIndexOf ( '/' ) + 1 ) ) ; } } ) ; } return faviconNames ; } }