Update WebSocket server with favicon assets and additional dependencies

* Added favicon assets including various sizes and a manifest file for improved branding
* Updated package.json to include new type definitions and dependencies for body-parser, cors, lru-cache, moment, multer, on-headers, response-time, and serve-favicon
* Enhanced HttpServer class to utilize the favicon and improved
  middleware configuration for handling requests
This commit is contained in:
2025-09-27 18:41:19 -04:00
parent e895704785
commit 9372777296
15 changed files with 394 additions and 39 deletions

View File

@@ -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<Nullable<express.Application>>;
private readonly _eventEmitter: EventEmitter;
private readonly _server: Subject<Nullable<Server>>;
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<string>('viewsPath', viewsPath);
Assert.isString('viewParameters', viewParameters);
Assert.isStringArray('resourcesPaths', resourcesPaths);
Assert.isArrayOf<string>('resourcesPaths', 'string', resourcesPaths);
Assert.isString('favicon', favicon);
Assert.isObject('cors', cors);
// Assert.isObjectOf<string>('cors', cors, 'string');
this._protocol = protocol;
this._port = port;
@@ -47,22 +80,281 @@ export default class HttpServer {
this._server = new Subject<Nullable<Server>>(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<string, string> = {};
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);
// }
}
}

View File

@@ -1,8 +1,7 @@
export default interface IRoutes {
getGETRoutes(): Record<string, () => (Promise<void> | void);
getPOSTRoutes(): Record<string, () => (Promise<void> | void);
getPUTRoutes(): Record<string, () => (Promise<void> | void);
getPATCHRoutes(): Record<string, () => (Promise<void> | void);
getDELETERoutes(): Record<string, () => (Promise<void> | void);
getGETRoutes(): Record<string, () => void | Promise<void>>;
getPOSTRoutes(): Record<string, () => void | Promise<void>>;
getPUTRoutes(): Record<string, () => void | Promise<void>>;
getPATCHRoutes(): Record<string, () => void | Promise<void>>;
getDELETERoutes(): Record<string, () => void | Promise<void>>;
}