import express, {Express} from 'express'; import { Middleware, Route, HttpMethod, HttpHandler } from '../base/http'; import { RouteNotSetException } from '../base/exceptions'; import { IncorrectMethodException } from '../base/exceptions'; import { Logger, Message, MessageTypes } from '../base/logger'; import { ServerProperties } from './ServerProperties'; import { i18nLoader, $$ } from '../base/i18n'; import path from 'path'; import fs from 'fs'; import http from 'http'; import { Server as WebSocketServer, WebSocket as WS } from 'ws'; import { InvalidMiddlewareException } from '../base/exceptions'; import { InvalidRouteException } from '../base/exceptions'; import { ServerNotInitializedException } from '../base/exceptions'; import { WebSocketHandler } from '../base/websocket'; import swaggerJsDoc, { OAS3Options, Options, SecurityRequirement } from 'swagger-jsdoc'; import swaggerUi from 'swagger-ui-express'; /** @sealed */ class Server { private instance: Express; private httpServer: http.Server; private readonly port: number; private readonly host: string; private readonly logger: Logger; private i18n: i18nLoader; public static readonly i18nDefaultPath = '../resources/i18n.json'; public static readonly defaultMiddlewaresPath = '../middlewares'; public static readonly defaultRoutesPath = '../routes'; private readonly httpHandlers: {[key: string]: HttpHandler}; private readonly wsHandlers: {[url: string]: WebSocketHandler}; private readonly wsServers: {[url: string]: WebSocketServer}; private initialized: boolean; private readonly i18nPath?: string; private readonly middlewaresPath?: string; private readonly routesPath?: string; private readonly viewEngine?: string; private readonly viewsPath?: string; private readonly options?: {[key: string]: any}; private readonly swaggerComponents? : object; private readonly swaggerSecurity? : object; private readonly swaggerDocsPath? : string; private readonly swaggerTitle?: string; private readonly swaggerDescription?: string; private readonly swaggerApiVersion?: string; private readonly swaggerRoute?: string; public constructor(properties: ServerProperties) { this.instance = express(); this.httpServer = http.createServer(this.instance); this.port = properties.port; this.host = properties.host || `http://localhost:${this.port}`; this.i18n = i18nLoader.getInstance(); if(properties.locale) this.i18n.setLocale(properties.locale); this.logger = new Logger(); this.httpHandlers = {}; this.wsHandlers = properties.wsHandlers || {}; this.wsServers = {}; this.i18nPath = properties.i18nPath; this.middlewaresPath = properties.middlewaresPath; this.routesPath = properties.routesPath; this.viewEngine = properties.viewEngine; this.viewsPath = properties.viewsPath; this.options = properties.options; if(properties.swagger) { this.swaggerDocsPath = properties.swagger?.docsPath; this.swaggerTitle = properties.swagger?.title || 'API Documentation'; this.swaggerDescription = properties.swagger?.description || 'API Documentation'; this.swaggerApiVersion = properties.swagger?.version ||'1.0.0'; this.swaggerRoute = properties.swagger?.route || '/api-docs'; this.swaggerComponents = properties.swagger?.components; this.swaggerSecurity = properties.swagger?.security; } this.initialized = false; } public async init(): Promise { if(this.viewEngine) this.instance.set('view engine', this.viewEngine); if(this.viewsPath) this.instance.set('views', this.viewsPath); this.i18n.load(path.resolve(__dirname, Server.i18nDefaultPath)); await this.registerMiddlewares(path.resolve(__dirname, Server.defaultMiddlewaresPath)); await this.registerRoutes(path.resolve(__dirname, Server.defaultRoutesPath)); await this.postInit(); this.initialized = true; return this; } private async postInit(): Promise { if(this.i18nPath) this.i18n.load(this.i18nPath); if(this.middlewaresPath) await this.registerMiddlewares(this.middlewaresPath); if(this.routesPath) await this.registerRoutes(this.routesPath); if(this.swaggerDocsPath) fs.writeFileSync(this.swaggerDocsPath, ''); if(Object.keys(this.wsHandlers).length > 0) { this.registerWsServers(); this.applyWsHandlers(); } } private processHttpHandlers(): void { const handlers: HttpHandler[] = []; for(const key in this.httpHandlers) handlers.push(this.httpHandlers[key]); handlers.sort((a, b) => a.getOrder() - b.getOrder()); for(const handler of handlers) { if(handler instanceof Middleware) this.addMiddleware(handler); else if (handler instanceof Route) this.addRoute(handler); } } public addMiddleware(middleware: Middleware): Server { if(middleware.getRoute() != null) this.instance.use(middleware.getRoute(), middleware.getAction()); else this.instance.use(middleware.getAction()); return this; } public addRoute(route: Route): Server { if(route.getRoute() == null) throw new RouteNotSetException(); switch(route.getMethod()) { case HttpMethod.GET: return this.get(route); case HttpMethod.POST: return this.post(route); default: throw new IncorrectMethodException(); } } private registerRoutesDocumentation(): Server { for(const routeName in this.httpHandlers) { const route = this.httpHandlers[routeName]; if(!(route instanceof Route)) continue; if(route.getRoute() == null) throw new RouteNotSetException(); if(![HttpMethod.GET, HttpMethod.POST].includes(route.getMethod())) throw new IncorrectMethodException(); const docs = route.getDocumentation(); if(this.swaggerDocsPath && docs.length > 0) { fs.appendFileSync(this.swaggerDocsPath, `${docs}\n`); this.logInfo(`Swagger documentation for route '${route.getRoute()}' generated!`); } } return this; } public registerWsServers(): Server { for(const url of Object.keys(this.wsHandlers)) { const wsServer = this.wsServers[url] = new WebSocketServer({ noServer: true }); const wsHandler = this.wsHandlers[url]; wsServer.on(WebSocketHandler.Event.CONNECTION, (ws: WS) => { wsHandler.onConnect(ws); ws.on(WebSocketHandler.Event.MESSAGE, wsHandler.onMessage); ws.on(WebSocketHandler.Event.ERROR, wsHandler.onError); ws.on(WebSocketHandler.Event.CLOSE, wsHandler.onClose); }); } return this; } public applyWsHandlers(): Server { this.httpServer.on('upgrade', (request, socket, head) => { const url = request.url?.split('?')[0]; if(url && this.wsHandlers[url] && this.wsServers[url]) { const wsServer = this.wsServers[url]; wsServer.handleUpgrade(request, socket, head, (ws) => { wsServer.emit(WebSocketHandler.Event.CONNECTION, ws, request); }); } else { socket.destroy(); } }); return this; } public getWsConnections(url: string): Set | null { const wsServer = this.wsServers[url]; return wsServer ? wsServer.clients : null; } public logInfo(message: string): void { this.logger.getLogger().info(message); } public logError(message: string): void { this.logger.getLogger().error(message); } public logWarn(message: string): void { this.logger.getLogger().warn(message); } public log(message: Message): void { switch(message.type) { case MessageTypes.WARNING: return this.logWarn(message.text); case MessageTypes.ERROR: return this.logError(message.text); default: return this.logInfo(message.text); } } private get(route: Route): Server { this.instance.get(route.getRoute(), route.getAction()); return this; } private post(route: Route): Server { this.instance.post(route.getRoute(), route.getAction()); return this; } private registerSwaggerMiddleware(): Server { if(!this.swaggerDocsPath) return this; const swaggerOptions : OAS3Options = { swaggerDefinition: { openapi: '3.0.1', info: { title: this.swaggerTitle!, version: this.swaggerApiVersion!, description: this.swaggerDescription!, }, servers: [ { url: this.host } ], }, apis: [this.swaggerDocsPath], }; if(this.swaggerComponents) swaggerOptions.swaggerDefinition!.components = this.swaggerComponents; if(this.swaggerSecurity) swaggerOptions.swaggerDefinition!.security = this.swaggerSecurity as SecurityRequirement[]; const swaggerDocs = swaggerJsDoc(swaggerOptions as Options); this.instance.use(this.swaggerRoute!, swaggerUi.serve, swaggerUi.setup(swaggerDocs)); return this; } public async registerRoutes(dir: string): Promise { const files = fs.readdirSync(dir, { recursive: true, encoding: 'utf8' }); for(const file of files) { if(/\.js$/.test(file)) { const {default: RouteClass} = await import(path.join(dir, file)); if(RouteClass.prototype instanceof Route) { this.httpHandlers[RouteClass.name] = new RouteClass(this); } else throw new InvalidRouteException(file); } } return this; } public async registerMiddlewares(dir: string): Promise { const files = fs.readdirSync(dir, { recursive: true, encoding: 'utf8' }); for(const file of files) { if(/\.js$/.test(file)) { const {default: MiddlewareClass} = await import(path.join(dir, file)); if(MiddlewareClass.prototype instanceof Middleware) { this.httpHandlers[MiddlewareClass.name] = new MiddlewareClass(this); } else throw new InvalidMiddlewareException(file); } } return this; } public getLogger(): Logger { return this.logger; } public i18nLoad(path: string): Server { this.i18n.load(path); return this; } public getHost(): string { return this.host; } public getOption(key: string): any { return this.options ? this.options[key] || null : null; } public start(callback?: () => any): void { if(!this.initialized) throw new ServerNotInitializedException(); this.registerRoutesDocumentation(); this.registerSwaggerMiddleware(); this.processHttpHandlers(); const cb = (): void => { this.logInfo($$('org.crazydoctor.expressts.start', { 'port': this.port })); if(callback) callback(); }; this.httpServer.listen(this.port, cb); } } export { Server };