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

@@ -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/)

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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"}

View File

@@ -20,8 +20,15 @@
"@eslint/js": "9.36.0", "@eslint/js": "9.36.0",
"@eslint/json": "0.13.2", "@eslint/json": "0.13.2",
"@eslint/markdown": "7.3.0", "@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/morgan": "1.9.10",
"@types/multer": "2.0.0",
"@types/node": "24.5.2", "@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", "eslint": "9.36.0",
"globals": "16.4.0", "globals": "16.4.0",
"jiti": "2.6.0", "jiti": "2.6.0",
@@ -34,6 +41,14 @@
"dependencies": { "dependencies": {
"@techniker-me/logger": "0.0.15", "@techniker-me/logger": "0.0.15",
"@techniker-me/tools": "2025.0.16", "@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"
} }
} }

View File

@@ -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 logger = LoggerFactory.getLogger('Server');
const httpServer = new HttpServer('http', 3000, { const httpServer = new HttpServer(
getGETRoutes: () => ({}), 'http',
getPOSTRoutes: () => ({}), 3000,
getPUTRoutes: () => ({}), {
getPATCHRoutes: () => ({}), getGETRoutes: () => ({}),
getDELETERoutes: () => ({}), getPOSTRoutes: () => ({}),
}, {}, '', [], '', {}); getPUTRoutes: () => ({}),
getPATCHRoutes: () => ({}),
getDELETERoutes: () => ({})
},
{},
'',
[],
path.resolve(process.cwd(), 'assets', 'favicon', 'favicon.ico'),
{}
);
httpServer.on('error', () => { httpServer.on('error', () => {
logger.error('[HttpServer] Error'); logger.error('[HttpServer] Error');
}); });
httpServer.start(); httpServer.start();

View File

@@ -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') { if (typeof value !== 'function') {
throw new Error(`[${name}] must be a function, instead received [${typeof value}]`); throw new Error(`[${name}] must be a function, instead received [${typeof value}]`);
} }
} }
public static satisfiesInterface<T>(name: string, obj: unknown, requiredProps: (keyof T)[]): asserts obj is T { public static satisfiesInterface<T>(name: string, obj: unknown, requiredProps: (keyof T)[]): asserts obj is T {
Assert.isObject(name, obj); Assert.isObject(name, obj);
for (const prop of requiredProps) { for (const prop of requiredProps) {
if (!(prop in (obj as any))) { if (!(prop in (obj as Record<string, unknown>))) {
throw new Error(`[${name}] missing required property: ${String(prop)}`); throw new Error(`[${name}] missing required property: ${String(prop)}`);
} }
} }
} }
public static isArray<T>(name: string, value: unknown): asserts value is T[] { public static isArray(name: string, value: unknown): asserts value is unknown[] {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
throw new Error(`[${name}] must be an array, instead received [${typeof value}]`); throw new Error(`[${name}] must be an array, instead received [${typeof value}]`);
} }
} }
public static isArrayOf<T>(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[] { public static isStringArray(name: string, value: unknown): asserts value is string[] {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
throw new Error(`[${name}] must be an array, instead received [${typeof 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<T>(name: string, parentClass: new (...args: any[]) => T, object: unknown): asserts object is T { public static isInstance<T>(name: string, parentClass: new (...args: any[]) => T, object: unknown): asserts object is T {
if (object === null || object === undefined || typeof object !== 'object') { if (object === null || object === undefined || typeof object !== 'object') {
throw new Error(`[${name}] must be an instance of [${parentClass.constructor.name}], instead received [${typeof object}]`); throw new Error(`[${name}] must be an instance of [${parentClass.constructor.name}], instead received [${typeof object}]`);

View File

@@ -1,5 +1,6 @@
import { Nullable } from '../../types/optional'; import {Nullable} from '../../types/optional';
import Assert from '../../lang/Assert'; import Assert from '../../lang/Assert';
import moment from 'moment';
import type {Server} from 'node:http'; import type {Server} from 'node:http';
import http from 'node:http'; import http from 'node:http';
import {EventEmitter} from 'node:events'; import {EventEmitter} from 'node:events';
@@ -9,30 +10,62 @@ import IRoutes from './IRoutes';
import {Subject} from '@techniker-me/tools'; import {Subject} from '@techniker-me/tools';
import express from 'express'; import express from 'express';
import morgan from 'morgan'; 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 { 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 _protocol: 'http' | 'https';
private readonly _port: number; private readonly _port: number;
// @ts-expect-error - unused parameter for future functionality
private readonly _routes: IRoutes; private readonly _routes: IRoutes;
// @ts-expect-error - unused parameter for future functionality
private readonly _viewsPath: object; private readonly _viewsPath: object;
// @ts-expect-error - unused parameter for future functionality
private readonly _viewParameters: string; private readonly _viewParameters: string;
private readonly _resourcesPaths: string[]; private readonly _resourcesPaths: string[];
private readonly _favicon: string; private readonly _favicon: string;
private readonly _cors: object; private readonly _cors: object;
private readonly _app: Subject<Nullable<express.Application>>; private readonly _app: Subject<Nullable<express.Application>>;
private readonly _eventEmitter: EventEmitter;
private readonly _server: Subject<Nullable<Server>>; 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.isString('protocol', protocol);
Assert.isNumber('port', port); Assert.isNumber('port', port);
Assert.satisfiesInterface ('routes', routes, ['getGETRoutes', 'getPOSTRoutes', 'getPUTRoutes', 'getPATCHRoutes', 'getDELETERoutes']); Assert.satisfiesInterface('routes', routes, ['getGETRoutes', 'getPOSTRoutes', 'getPUTRoutes', 'getPATCHRoutes', 'getDELETERoutes']);
Assert.isObject('viewsPath', viewsPath); // Assert.isObjectOf<string>('viewsPath', viewsPath);
Assert.isString('viewParameters', viewParameters); Assert.isString('viewParameters', viewParameters);
Assert.isStringArray('resourcesPaths', resourcesPaths); Assert.isArrayOf<string>('resourcesPaths', 'string', resourcesPaths);
Assert.isString('favicon', favicon); Assert.isString('favicon', favicon);
Assert.isObject('cors', cors); // Assert.isObjectOf<string>('cors', cors, 'string');
this._protocol = protocol; this._protocol = protocol;
this._port = port; this._port = port;
@@ -47,22 +80,281 @@ export default class HttpServer {
this._server = new Subject<Nullable<Server>>(null); this._server = new Subject<Nullable<Server>>(null);
} }
public async start() { public start() {
const app = this._app.value = express(); return new Promise((resolve, reject) => {
const app = (this._app.value = express());
app.use(morgan('common'));
this._server.value = http.createServer(app);
this._server.value.listen(this._port, () => { this.configureListener();
this._logger.info(`Server is running on port ${this._port}`); 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 { public on(event: string, handler: () => void): void {
Assert.isNonEmptyString('event', event); Assert.isNonEmptyString('event', event);
Assert.isFunction('handler', handler); Assert.isFunction('handler', handler);
this._eventEmitter.on(event, 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 { export default interface IRoutes {
getGETRoutes(): Record<string, () => (Promise<void> | void); getGETRoutes(): Record<string, () => void | Promise<void>>;
getPOSTRoutes(): Record<string, () => (Promise<void> | void); getPOSTRoutes(): Record<string, () => void | Promise<void>>;
getPUTRoutes(): Record<string, () => (Promise<void> | void); getPUTRoutes(): Record<string, () => void | Promise<void>>;
getPATCHRoutes(): Record<string, () => (Promise<void> | void); getPATCHRoutes(): Record<string, () => void | Promise<void>>;
getDELETERoutes(): Record<string, () => (Promise<void> | void); getDELETERoutes(): Record<string, () => void | Promise<void>>;
} }

View File

@@ -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;

View File

@@ -1,2 +1,2 @@
export type Optional<T> = T | undefined | null; export type Optional<T> = T | undefined | null;
export type Nullable<T> = T | null; export type Nullable<T> = T | null;