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:
@@ -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();
|
||||
httpServer.start();
|
||||
|
||||
@@ -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<T>(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<string, unknown>))) {
|
||||
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)) {
|
||||
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[] {
|
||||
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<T>(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}]`);
|
||||
|
||||
@@ -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);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
18
Web/WebSocket/websocket/server/src/types/Units.ts
Normal file
18
Web/WebSocket/websocket/server/src/types/Units.ts
Normal 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;
|
||||
@@ -1,2 +1,2 @@
|
||||
export type Optional<T> = T | undefined | null;
|
||||
export type Nullable<T> = T | null;
|
||||
export type Nullable<T> = T | null;
|
||||
|
||||
Reference in New Issue
Block a user