diff --git a/Web/WebSocket/websocket/server/assets/favicon/about.txt b/Web/WebSocket/websocket/server/assets/favicon/about.txt new file mode 100644 index 0000000..93143ab --- /dev/null +++ b/Web/WebSocket/websocket/server/assets/favicon/about.txt @@ -0,0 +1,6 @@ +This favicon was generated using the following graphics from Twitter Twemoji: + +- Graphics Title: 1f916.svg +- Graphics Author: Copyright 2020 Twitter, Inc and other contributors (https://github.com/twitter/twemoji) +- Graphics Source: https://github.com/twitter/twemoji/blob/master/assets/svg/1f916.svg +- Graphics License: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/) diff --git a/Web/WebSocket/websocket/server/assets/favicon/android-chrome-192x192.png b/Web/WebSocket/websocket/server/assets/favicon/android-chrome-192x192.png new file mode 100644 index 0000000..a0e145e Binary files /dev/null and b/Web/WebSocket/websocket/server/assets/favicon/android-chrome-192x192.png differ diff --git a/Web/WebSocket/websocket/server/assets/favicon/android-chrome-512x512.png b/Web/WebSocket/websocket/server/assets/favicon/android-chrome-512x512.png new file mode 100644 index 0000000..cf67ab3 Binary files /dev/null and b/Web/WebSocket/websocket/server/assets/favicon/android-chrome-512x512.png differ diff --git a/Web/WebSocket/websocket/server/assets/favicon/apple-touch-icon.png b/Web/WebSocket/websocket/server/assets/favicon/apple-touch-icon.png new file mode 100644 index 0000000..a8d4316 Binary files /dev/null and b/Web/WebSocket/websocket/server/assets/favicon/apple-touch-icon.png differ diff --git a/Web/WebSocket/websocket/server/assets/favicon/favicon-16x16.png b/Web/WebSocket/websocket/server/assets/favicon/favicon-16x16.png new file mode 100644 index 0000000..749a5ec Binary files /dev/null and b/Web/WebSocket/websocket/server/assets/favicon/favicon-16x16.png differ diff --git a/Web/WebSocket/websocket/server/assets/favicon/favicon-32x32.png b/Web/WebSocket/websocket/server/assets/favicon/favicon-32x32.png new file mode 100644 index 0000000..97132dd Binary files /dev/null and b/Web/WebSocket/websocket/server/assets/favicon/favicon-32x32.png differ diff --git a/Web/WebSocket/websocket/server/assets/favicon/favicon.ico b/Web/WebSocket/websocket/server/assets/favicon/favicon.ico new file mode 100644 index 0000000..eee15f5 Binary files /dev/null and b/Web/WebSocket/websocket/server/assets/favicon/favicon.ico differ diff --git a/Web/WebSocket/websocket/server/assets/favicon/site.webmanifest b/Web/WebSocket/websocket/server/assets/favicon/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/Web/WebSocket/websocket/server/assets/favicon/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/Web/WebSocket/websocket/server/package.json b/Web/WebSocket/websocket/server/package.json index 1ab2d9e..8a76507 100644 --- a/Web/WebSocket/websocket/server/package.json +++ b/Web/WebSocket/websocket/server/package.json @@ -20,8 +20,15 @@ "@eslint/js": "9.36.0", "@eslint/json": "0.13.2", "@eslint/markdown": "7.3.0", + "@types/body-parser": "1.19.6", + "@types/cors": "2.8.19", + "@types/lru-cache": "7.10.9", "@types/morgan": "1.9.10", + "@types/multer": "2.0.0", "@types/node": "24.5.2", + "@types/on-headers": "1.0.4", + "@types/response-time": "2.3.9", + "@types/serve-favicon": "2.5.7", "eslint": "9.36.0", "globals": "16.4.0", "jiti": "2.6.0", @@ -34,6 +41,14 @@ "dependencies": { "@techniker-me/logger": "0.0.15", "@techniker-me/tools": "2025.0.16", - "morgan": "1.10.1" + "body-parser": "2.2.0", + "cors": "2.8.5", + "lru-cache": "11.2.2", + "moment": "2.30.1", + "morgan": "1.10.1", + "multer": "2.0.2", + "on-headers": "1.1.0", + "response-time": "2.3.4", + "serve-favicon": "2.5.1" } } diff --git a/Web/WebSocket/websocket/server/src/index.ts b/Web/WebSocket/websocket/server/src/index.ts index 3e62f52..b6e0137 100644 --- a/Web/WebSocket/websocket/server/src/index.ts +++ b/Web/WebSocket/websocket/server/src/index.ts @@ -1,18 +1,28 @@ -import HttpServer from './net/http/HttpServer'; -import {LoggerFactory} from '@techniker-me/logger'; +import {LoggerFactory} from '@techniker-me/logger'; +import HttpServer from './net/http/HttpServer'; +import path from 'node:path'; const logger = LoggerFactory.getLogger('Server'); -const httpServer = new HttpServer('http', 3000, { - getGETRoutes: () => ({}), - getPOSTRoutes: () => ({}), - getPUTRoutes: () => ({}), - getPATCHRoutes: () => ({}), - getDELETERoutes: () => ({}), -}, {}, '', [], '', {}); +const httpServer = new HttpServer( + 'http', + 3000, + { + getGETRoutes: () => ({}), + getPOSTRoutes: () => ({}), + getPUTRoutes: () => ({}), + getPATCHRoutes: () => ({}), + getDELETERoutes: () => ({}) + }, + {}, + '', + [], + path.resolve(process.cwd(), 'assets', 'favicon', 'favicon.ico'), + {} +); httpServer.on('error', () => { - logger.error('[HttpServer] Error'); + logger.error('[HttpServer] Error'); }); -httpServer.start(); \ No newline at end of file +httpServer.start(); diff --git a/Web/WebSocket/websocket/server/src/lang/Assert.ts b/Web/WebSocket/websocket/server/src/lang/Assert.ts index 4ba255d..f3128f0 100644 --- a/Web/WebSocket/websocket/server/src/lang/Assert.ts +++ b/Web/WebSocket/websocket/server/src/lang/Assert.ts @@ -73,27 +73,40 @@ export default class Assert { } } - public static isFunction(name: string, value: unknown): asserts value is Function { + public static isFunction(name: string, value: unknown): asserts value is (...args: unknown[]) => unknown { if (typeof value !== 'function') { throw new Error(`[${name}] must be a function, instead received [${typeof value}]`); } } + public static satisfiesInterface(name: string, obj: unknown, requiredProps: (keyof T)[]): asserts obj is T { Assert.isObject(name, obj); - + for (const prop of requiredProps) { - if (!(prop in (obj as any))) { + if (!(prop in (obj as Record))) { throw new Error(`[${name}] missing required property: ${String(prop)}`); } } } - public static isArray(name: string, value: unknown): asserts value is T[] { + public static isArray(name: string, value: unknown): asserts value is unknown[] { if (!Array.isArray(value)) { throw new Error(`[${name}] must be an array, instead received [${typeof value}]`); } } + public static isArrayOf(name: string, arrayValueType: T, value: unknown): asserts value is T[] { + Assert.isArray(name, value); + + for (const item of value) { + const itemTypeof = typeof item; + + if (itemTypeof !== arrayValueType) { + throw new Error(`[${name}] must be an array of [${arrayValueType}] received [${itemTypeof}]`); + } + } + } + public static isStringArray(name: string, value: unknown): asserts value is string[] { if (!Array.isArray(value)) { throw new Error(`[${name}] must be an array, instead received [${typeof value}]`); @@ -116,6 +129,7 @@ export default class Assert { } } + // eslint-disable-next-line public static isInstance(name: string, parentClass: new (...args: any[]) => T, object: unknown): asserts object is T { if (object === null || object === undefined || typeof object !== 'object') { throw new Error(`[${name}] must be an instance of [${parentClass.constructor.name}], instead received [${typeof object}]`); diff --git a/Web/WebSocket/websocket/server/src/net/http/HttpServer.ts b/Web/WebSocket/websocket/server/src/net/http/HttpServer.ts index aa22a34..e3f1bdb 100644 --- a/Web/WebSocket/websocket/server/src/net/http/HttpServer.ts +++ b/Web/WebSocket/websocket/server/src/net/http/HttpServer.ts @@ -1,5 +1,6 @@ -import { Nullable } from '../../types/optional'; +import {Nullable} from '../../types/optional'; import Assert from '../../lang/Assert'; +import moment from 'moment'; import type {Server} from 'node:http'; import http from 'node:http'; import {EventEmitter} from 'node:events'; @@ -9,30 +10,62 @@ import IRoutes from './IRoutes'; import {Subject} from '@techniker-me/tools'; import express from 'express'; import morgan from 'morgan'; +import favicon from 'serve-favicon'; +import bodyParser from 'body-parser'; +import multer from 'multer'; +import {Kilobytes} from '../../types/Units'; +import responseTime from 'response-time'; +import onHeaders from 'on-headers'; +import {LRUCache} from 'lru-cache'; + +const requestSizeLimit: Kilobytes = 10240; +const defaultTcpSocketTimeout = moment.duration(720, 'seconds'); // Google HTTPS load balancer expects at least 600 seconds +const defaultKeepAliveTimeout = moment.duration(660, 'seconds'); // Google HTTPS load balancer expects at least 600 seconds +// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age#:~:text=The%20Access%2DControl%2DMax%2D,Headers%20headers)%20can%20be%20cached. +const corsAccessControlMaxAge = moment.duration(24, 'hours'); +const shortTermCaching = 'public, max-age=20, s-maxage=20'; +const tlsSessionTimeout = moment.duration(5, 'minutes'); +const maxCachedTlsSessions = 1000; export default class HttpServer { - private readonly _logger: ILogger = LoggerFactory.getLogger('HttpServer'); + private readonly _logger: ILogger = LoggerFactory.getLogger('HttpServer'); + private readonly _eventEmitter: EventEmitter; private readonly _protocol: 'http' | 'https'; private readonly _port: number; + // @ts-expect-error - unused parameter for future functionality private readonly _routes: IRoutes; + // @ts-expect-error - unused parameter for future functionality private readonly _viewsPath: object; + // @ts-expect-error - unused parameter for future functionality private readonly _viewParameters: string; private readonly _resourcesPaths: string[]; private readonly _favicon: string; private readonly _cors: object; private readonly _app: Subject>; - private readonly _eventEmitter: EventEmitter; private readonly _server: Subject>; + private readonly _tlsSessionCache = new LRUCache({ + ttl: tlsSessionTimeout.asMilliseconds(), + max: maxCachedTlsSessions + }); - constructor(protocol: 'https' | 'http', port: number, routes: IRoutes, viewsPath: object, viewParameters: string, resourcesPaths: string[], favicon: string, cors: object) { + constructor( + protocol: 'https' | 'http', + port: number, + routes: IRoutes, + viewsPath: object, + viewParameters: string, + resourcesPaths: string[], + favicon: string, + cors: object + ) { Assert.isString('protocol', protocol); Assert.isNumber('port', port); - Assert.satisfiesInterface ('routes', routes, ['getGETRoutes', 'getPOSTRoutes', 'getPUTRoutes', 'getPATCHRoutes', 'getDELETERoutes']); - Assert.isObject('viewsPath', viewsPath); + Assert.satisfiesInterface('routes', routes, ['getGETRoutes', 'getPOSTRoutes', 'getPUTRoutes', 'getPATCHRoutes', 'getDELETERoutes']); + // Assert.isObjectOf('viewsPath', viewsPath); Assert.isString('viewParameters', viewParameters); - Assert.isStringArray('resourcesPaths', resourcesPaths); + Assert.isArrayOf('resourcesPaths', 'string', resourcesPaths); Assert.isString('favicon', favicon); - Assert.isObject('cors', cors); + // Assert.isObjectOf('cors', cors, 'string'); this._protocol = protocol; this._port = port; @@ -47,22 +80,281 @@ export default class HttpServer { this._server = new Subject>(null); } - public async start() { - const app = this._app.value = express(); - - app.use(morgan('common')); - this._server.value = http.createServer(app); + public start() { + return new Promise((resolve, reject) => { + const app = (this._app.value = express()); - this._server.value.listen(this._port, () => { - this._logger.info(`Server is running on port ${this._port}`); + this.configureListener(); + this.configureMiddleware(); + this.configureResources(); + this.configureRoutes(); + + app.set('x-powered-by', false); + + const server = (this._server.value = http.createServer(app)); + + const onListen = () => { + this._logger.info('HTTP Server listening on %s://*:%s', this._protocol, this._port); + + server.removeListener('error', onError); + + resolve(this); + }; + + const onError = (err: unknown) => { + server.removeListener('listening', onListen); + + reject(err); + }; + + server.keepAliveTimeout = defaultKeepAliveTimeout.milliseconds(); + server.timeout = defaultTcpSocketTimeout.asMilliseconds(); + server.setTimeout(defaultTcpSocketTimeout.asMilliseconds()); + server.once('error', onError); + server.once('listening', onListen); + + server.on('newSession', (sessionId, sessionData, callback) => { + const cacheId = sessionId.toString('hex'); + + this._tlsSessionCache.set(cacheId, sessionData); + this._logger.debug('Created new TLS session [%s]', cacheId); + + callback(); + }); + + server.on('resumeSession', (sessionId, callback) => { + const cacheId = sessionId.toString('hex'); + const sessionData = this._tlsSessionCache.get(cacheId); + + callback(null, sessionData); + + if (sessionData) { + this._logger.debug('Resumed TLS session [%s]', cacheId); + } else { + this._logger.debug('TLS session [%s] not found', cacheId); + } + }); + + server.listen({ + port: this._port, + backlog: 16 * 1024 + }); }); } - public on(event: string, handler: () => void): void { Assert.isNonEmptyString('event', event); Assert.isFunction('handler', handler); this._eventEmitter.on(event, handler); } + + private configureListener() { + if (!this._app.value) { + throw new Error('Unable to configure listener, no app instance found'); + } + + const app = this._app.value; + + app.use((req, res, next) => { + req.on('finish', () => { + this._eventEmitter.emit('request', req.method, req.url, res.statusCode, req.headers); + }); + + next(); + }); + } + + private configureMiddleware() { + if (!this._app.value) { + throw new Error('Unable to configure middleware, no app instance found'); + } + + const app = this._app.value; + const logger = this._logger; + + app.use(morgan('common')); + app.enable('trust proxy'); + app.set('env', 'development'); // setting env to test prevents logging to the console + app.use(favicon(this._favicon)); + app.use( + morgan( + ':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent" :response-time', + { + stream: { + write(line) { + logger.info(line.trim()); + } + } + } + ) + ); + + app.use( + bodyParser.text({ + type: 'application/sdp', + limit: requestSizeLimit + }) + ); + app.use( + bodyParser.text({ + type: 'application/trickle-ice-sdpfrag', + limit: requestSizeLimit + }) + ); + app.use( + bodyParser.urlencoded({ + extended: true, + limit: requestSizeLimit + }) + ); + app.use( + multer({ + limits: { + fields: 1, + fieldNameSize: 100, + fieldSize: requestSizeLimit, + files: 0, + parts: 1, + headerPairs: 1 + } + }).none() + ); + app.use((req, _res, next) => { + const contentType = req?.headers?.['content-type'] || ''; + + if (contentType.startsWith('multipart/form-data;')) { + if (req?.body?.jsonBody) { + req.body = JSON.parse(req.body.jsonBody); + } + } + + next(); + }); + app.use(responseTime()); + app.use((req, res, next) => { + res.set('x-origination', 'Platform'); + + if (req.secure) { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); + } + + next(); + }); + + if (this._cors) { + this._logger.info('Enable CORS on %s://*:%s', this._protocol, this._port); + + const cachedCorsAllowOrigins: Record = {}; + const getCorsAllowOrigin = (url: string) => { + let corsAllowOrigin = cachedCorsAllowOrigins[url]; + + if (!Object.hasOwn(cachedCorsAllowOrigins, url)) { + Object.entries(this._cors).forEach(([key, value]) => { + if (url.startsWith(key)) { + corsAllowOrigin = value; + } + }); + + cachedCorsAllowOrigins[url] = corsAllowOrigin ?? ''; + } + + return corsAllowOrigin; + }; + + app.use((req, res, next) => { + const corsAllowOrigin = getCorsAllowOrigin(req.url); + + if (corsAllowOrigin) { + res.header('Access-Control-Allow-Origin', corsAllowOrigin); + res.header( + 'Access-Control-Allow-Headers', + 'Authorization, Origin, Range, X-Requested-With, If-Modified-Since, Accept, Keep-Alive, Cache-Control, Content-Type, DNT' + ); + res.header('Access-Control-Allow-Methods', 'POST, GET, HEAD, OPTIONS, PUT, PATCH, DELETE'); + res.header('Access-Control-Expose-Headers', 'Server, Range, Date, Content-Disposition, X-Timer, ETag, Link, Location'); + res.header('Access-Control-Max-Age', corsAccessControlMaxAge.asSeconds().toString()); + + if (req.method === 'OPTIONS') { + res.header('Cache-Control', shortTermCaching); + } + } + + next(); + }); + } + app.use((_req, res, next) => { + const startTimeSeconds = Date.now() / 1000; + const startTimeNanoseconds = process.hrtime.bigint(); + + onHeaders(res, () => { + const durationNanoseconds = process.hrtime.bigint() - startTimeNanoseconds; + const durationMilliseconds = durationNanoseconds / 1000000n; + + // https://developer.fastly.com/reference/http/http-headers/X-Timer/ + // S{unixStartTimeSeconds},VS0,VE{durationMilliseconds} + res.setHeader('X-Timer', `S${startTimeSeconds},VS0,VE${durationMilliseconds}`); + }); + + next(); + }); + } + + private configureResources() { + if (!this._app.value) { + throw new Error('Unable to configure resources, no app instance found'); + } + + const app = this._app.value; + + for (const resourcePath of this._resourcesPaths) { + app.use(express.static(resourcePath)); + } + + app.use(this.errorHandler); + app.use(this.genericNotFoundHandler); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private errorHandler(err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) { + this._logger.error(err.message); + + res.status(500).send({status: 'error'}); + + return; + } + + private genericNotFoundHandler(_req: express.Request, res: express.Response) { + res.status(404).send({status: 'not-found'}); + + return; + } + + private configureRoutes() { + if (!this._app.value) { + throw new Error('Unable to configure routes, no app instance found'); + } + + // TODO: Implement routes + // const app = this._app.value; + + // let catchAllHandler = null; + + // for (const route of this._routes.getGETRoutes().entries()) { + // const [handler, name] = route; + + // if (name === '*') { + // if (catchAllHandler) { + // throw new Error(`Only one catch-all handler can ber registered per server, ignoring conflicting catch-all`); + // } + + // catchAllHandler = handler; + + // continue; + // } + + // this._logger.debug(`Registering [GET] route [${name}]`); + // // app.get(name, this._json); + // } + } } diff --git a/Web/WebSocket/websocket/server/src/net/http/IRoutes.ts b/Web/WebSocket/websocket/server/src/net/http/IRoutes.ts index bacd489..d8bc2c9 100644 --- a/Web/WebSocket/websocket/server/src/net/http/IRoutes.ts +++ b/Web/WebSocket/websocket/server/src/net/http/IRoutes.ts @@ -1,8 +1,7 @@ - export default interface IRoutes { - getGETRoutes(): Record (Promise | void); - getPOSTRoutes(): Record (Promise | void); - getPUTRoutes(): Record (Promise | void); - getPATCHRoutes(): Record (Promise | void); - getDELETERoutes(): Record (Promise | void); + getGETRoutes(): Record void | Promise>; + getPOSTRoutes(): Record void | Promise>; + getPUTRoutes(): Record void | Promise>; + getPATCHRoutes(): Record void | Promise>; + getDELETERoutes(): Record void | Promise>; } diff --git a/Web/WebSocket/websocket/server/src/types/Units.ts b/Web/WebSocket/websocket/server/src/types/Units.ts new file mode 100644 index 0000000..5566d9b --- /dev/null +++ b/Web/WebSocket/websocket/server/src/types/Units.ts @@ -0,0 +1,18 @@ +export type Bytes = number; +export type Kilobytes = number; +export type Megabytes = number; +export type Gigabytes = number; +export type Terabytes = number; +export type Petabytes = number; +export type Exabytes = number; +export type Zettabytes = number; +export type Yottabytes = number; + +export type Milliseconds = number; +export type Seconds = number; +export type Minutes = number; +export type Hours = number; +export type Days = number; +export type Weeks = number; +export type Months = number; +export type Years = number; diff --git a/Web/WebSocket/websocket/server/src/types/optional.ts b/Web/WebSocket/websocket/server/src/types/optional.ts index 164bc2e..16dbd62 100644 --- a/Web/WebSocket/websocket/server/src/types/optional.ts +++ b/Web/WebSocket/websocket/server/src/types/optional.ts @@ -1,2 +1,2 @@ export type Optional = T | undefined | null; -export type Nullable = T | null; \ No newline at end of file +export type Nullable = T | null;