rename websocket directory to websocket-chat

This commit is contained in:
2025-10-01 07:51:50 -04:00
parent a762bed15c
commit e63615eb46
70 changed files with 0 additions and 0 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,11 @@
{
"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

@@ -0,0 +1,15 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import json from '@eslint/json';
import markdown from '@eslint/markdown';
import css from '@eslint/css';
import {defineConfig} from 'eslint/config';
export default defineConfig([
{files: ['**/*.{js,mjs,cjs,ts,mts,cts}'], plugins: {js}, extends: ['js/recommended'], languageOptions: {globals: globals.node}},
tseslint.configs.recommended,
{files: ['**/*.json'], plugins: {json}, language: 'json/json', extends: ['json/recommended']},
{files: ['**/*.md'], plugins: {markdown}, language: 'markdown/commonmark', extends: ['markdown/recommended']},
{files: ['**/*.css'], plugins: {css}, language: 'css/css', extends: ['css/recommended']}
]);

View File

@@ -0,0 +1,61 @@
{
"name": "@techniker-me/websocket-server",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"format": "prettier --write .",
"prelint": "npm install",
"lint": "eslint --max-warnings 0 .",
"prelint:fix": "npm run format",
"lint:fix": "eslint --fix .",
"dev": "tsx watch --clear-screen=false src/index.ts",
"build": "tsc",
"typecheck": "tsc",
"clean": "rm -rf dist"
},
"author": "Alexander Zinn",
"license": "ISC",
"description": "",
"devDependencies": {
"@eslint/css": "0.11.1",
"@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",
"@types/ws": "8.18.1",
"eslint": "9.36.0",
"globals": "16.4.0",
"jiti": "2.6.0",
"nodemon": "3.1.10",
"prettier": "3.6.2",
"tsx": "4.20.6",
"typescript": "5.9.2",
"typescript-eslint": "8.44.1"
},
"dependencies": {
"@techniker-me/logger": "0.0.15",
"@techniker-me/tools": "2025.0.16",
"body-parser": "2.2.0",
"cors": "2.8.5",
"express": "5.1.0",
"lru-cache": "11.2.2",
"moment": "2.30.1",
"morgan": "1.10.1",
"multer": "2.0.2",
"on-headers": "1.1.0",
"permessage-deflate": "0.1.7",
"response-time": "2.3.4",
"serve-favicon": "2.5.1",
"websocket-extensions": "0.1.4",
"ws": "8.18.3"
}
}

View File

@@ -0,0 +1,13 @@
import type {HealthStatus} from './IHealthCheck';
export default class HealthCheck {
public checkHealth(): HealthStatus {
return {
status: 'ok',
environment: 'development',
app: 'websocket-server',
version: '0.0.1',
zone: 'us-central1'
};
}
}

View File

@@ -0,0 +1,72 @@
import type {Request, Response} from 'express';
import type IRoute from '../net/http/IRoutes';
import IHealthCheck from './IHealthCheck';
export default class HealthCheckRoute implements IRoute {
private readonly _healthCheck: IHealthCheck;
constructor(healthCheck: IHealthCheck) {
this._healthCheck = healthCheck;
}
public getGETRoutes() {
console.log('[HealthCheckRoute] getGETRoutes called');
const routes = {
'/ok.html': (req, res) => this.externalReadiness.call(this, req, res),
'/ping': (req, res) => this.ping.call(this, req, res)
};
console.log('[HealthCheckRoute] returning routes:', Object.keys(routes));
return routes;
}
public getPOSTRoutes() {
return {};
}
public getPUTRoutes() {
return {};
}
public getPATCHRoutes() {
return {};
}
public getDELETERoutes() {
return {};
}
private externalReadiness(_req: Request, res: Response) {
console.log('[HealthCheckRoute] External readiness');
res.setHeader('Cache-Control', 'public, max-age=0, no-cache, no-store');
try {
const result = this._healthCheck.checkHealth();
if (!result || !result.status) {
return res.status(500).end();
}
switch (result.status) {
case 'ok':
case 'draining':
case 'draining2':
case 'disabled':
return res.status(200).json(result);
case 'starting':
case 'drained':
case 'stopped':
return res.status(503).json(result);
default:
return res.status(500).json(result);
}
} catch {
return res.status(500).end();
}
}
private ping(_req: Request, res: Response) {
console.log('[HealthCheckRoute] Ping handler called from [%s]', _req.ip);
res.status(200).send({status: 'pong'}).end();
}
}

View File

@@ -0,0 +1,11 @@
export type HealthStatus = {
status: 'ok' | 'draining' | 'draining2' | 'disabled' | 'starting' | 'drained' | 'stopped';
environment: string;
app: string;
version: string;
zone: string;
};
export default interface IHealthCheck {
checkHealth(): HealthStatus;
}

View File

@@ -0,0 +1,78 @@
import path from 'node:path';
import {IncomingMessage} from 'node:http';
import {LoggerFactory} from '@techniker-me/logger';
import HttpServer from './net/http/HttpServer';
import HealthCheckRoute from './health/HealthCheckRoute';
import HealthCheck from './health/HealthCheck';
import WebSocketServer, {ExtendedWebSocket} from './net/websocket/WebSocketServer';
const logger = LoggerFactory.getLogger('Server');
const healthCheck = new HealthCheck();
const healthCheckRoute = new HealthCheckRoute(healthCheck);
const httpServer = new HttpServer('http', 3000, healthCheckRoute, {}, '', [], path.resolve(process.cwd(), 'assets', 'favicon', 'favicon.ico'), {});
httpServer.on('error', () => logger.error('[HttpServer] Error'));
const sockets = new Map<string, ExtendedWebSocket>();
function connectDelegate(connection: ExtendedWebSocket, req: IncomingMessage) {
console.log('[Server] Connect delegate');
sockets.set(connection.id, connection);
}
function requestDelegate(connection: ExtendedWebSocket, message: Buffer) {
console.log('[Server] Request delegate');
try {
const messageJson = JSON.parse(message.toString());
console.log('messageJson', messageJson);
if (messageJson.ping) {
const serverTime = Date.now();
const rtt = serverTime - messageJson.ping.sentAt;
connection.send(
JSON.stringify({
pong: {
sentAt: serverTime,
rtt: rtt
},
...messageJson
})
);
}
else if (messageJson.message) {
sockets.forEach(socket => {
socket.send(JSON.stringify({...messageJson}));
});
}
} catch (error) {
console.log('error requestingDelegate to handle websocket message', error);
}
}
function disconnectDelegate(connection: ExtendedWebSocket, reasonCode: number, description: string) {
console.log('[Server] Disconnect delegate');
}
function pongDelegate(connection: ExtendedWebSocket, message: Buffer) {
console.log('[Server] Pong delegate');
}
httpServer
.start()
.then(() => {
const server = httpServer.getServer();
if (server) {
const websocketServer = new WebSocketServer(server, {path: '/ws'});
websocketServer.start(connectDelegate, requestDelegate, disconnectDelegate, pongDelegate);
} else {
console.error('[Server] Failed to get HTTP server instance');
}
})
.catch(err => console.log('[Server] Server failed to start:', err));

View File

@@ -0,0 +1,170 @@
export default class Assert {
public static isUndefined(name: string, value: unknown): asserts value is undefined {
if (value !== undefined) {
throw new Error(`[${name}] must be undefined instead received [${typeof value}]`);
}
}
public static isNull(name: string, value: unknown): asserts value is null {
if (value !== null) {
throw new Error(`[${name}] must be null instead received [${typeof value}]`);
}
}
public static isDefined(name: string, value: unknown): asserts value is NonNullable<typeof value> {
if (value === undefined || value === null) {
throw new Error(`[${name}] must be defined instead received [${typeof value}]`);
}
}
public static isBoolean(name: string, value: unknown): asserts value is boolean {
if (!Assert._isBoolean(value)) {
throw new Error(`[${name}] must be a boolean instead received [${typeof value}]`);
}
}
public static isTrue(name: string, value: unknown): asserts value is true {
Assert.isBoolean(name, value);
if (!value) {
throw new Error(`[${name}] must be true`);
}
}
public static isString(name: string, value: unknown): asserts value is string {
if (!Assert._isString(value)) {
throw new Error(`[${name}] must be a string instead received [${typeof value}]`);
}
}
public static isNonEmptyString(name: string, value: unknown): asserts value is string {
if (!Assert._isNonEmptyString(value)) {
throw new Error(`[${name}] must be a non-empty string instead received [${typeof value}]`);
}
}
public static isNumber(name: string, value: unknown): asserts value is number {
if (!Assert._isNumber(value)) {
throw new Error(`[${name}] must be a number instead received [${typeof value}]`);
}
}
public static isInteger(name: string, value: unknown): asserts value is number {
Assert.isNumber(name, value);
if (!Number.isInteger(value)) {
throw new Error(`[${name}] must be an integer, received [${value}]`);
}
}
public static isPositiveNumber(name: string, value: unknown): asserts value is number {
if (!Assert._isNumberPositive(value)) {
throw new Error(`[${name}] must be a positive number instead received [${typeof value}]`);
}
}
public static isNumberInRange(name: string, lowerBound: number, upperBound: number, value: unknown): asserts value is number {
if (upperBound < lowerBound) {
throw new Error(`Invalid Range: [${name}] bounds are invalid, lower bound [${lowerBound}] must be less than upper bound [${upperBound}]`);
}
if (!(Assert._isNumber(value) && Assert._isNumberInRange(value, lowerBound, upperBound))) {
throw new Error(`[${name}] must have a value between [${lowerBound}] and [${upperBound}] instead received [${value}]`);
}
}
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 Record<string, unknown>))) {
throw new Error(`[${name}] missing required property: ${String(prop)}`);
}
}
}
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}]`);
}
for (const item of value) {
Assert.isString(name, item);
}
}
public static isObject<T>(name: string, value: unknown): asserts value is T {
if (value === null || typeof value !== 'object') {
throw new Error(`[${name}] must be an object, instead received [${typeof value}]`);
}
}
public static isEnumMember<T extends object>(name: string, enumObj: T, value: unknown): asserts value is T[keyof T] {
if (!Object.values(enumObj).includes(value as T[keyof T])) {
throw new Error(`[${name}] is not a member of the enum`);
}
}
// 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}]`);
}
if (!(object instanceof parentClass)) {
throw new Error(`[${name}] must be an instance of [${parentClass.constructor.name}], instead received [${object.constructor.name}]`);
}
}
private static _isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
private static _isString(value: unknown): value is string {
return typeof value === 'string';
}
private static _isNonEmptyString(value: unknown): value is string {
return Assert._isString(value) && value.length > 0;
}
private static _isNumber(value: unknown): value is number {
return typeof value === 'number';
}
private static _isNumberPositive(value: unknown): value is number {
return Assert._isNumber(value) && value > 0;
}
private static _isNumberInRange(value: unknown, lowerBound: number, upperBound: number): value is number {
return Assert._isNumber(value) && value >= lowerBound && value <= upperBound;
}
private constructor() {
throw new Error('Assert is a static class that may not be instantiated');
}
}

View File

@@ -0,0 +1,9 @@
export default class Strings {
public static randomString(length: number): string {
return (Date.now().toString(36) + Math.random().toString(36).substring(2)).substring(0, length);
}
private constructor() {
throw new Error('Strings is a static class that may not be instantiated');
}
}

View File

@@ -0,0 +1,379 @@
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';
import type {ILogger} from '@techniker-me/logger';
import {LoggerFactory} from '@techniker-me/logger';
import IRoutes from './IRoutes';
import {Subject} from '@techniker-me/tools';
import express, {RequestHandler} 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 _eventEmitter: EventEmitter;
private readonly _protocol: 'http' | 'https';
private readonly _port: number;
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 _server: Subject<Nullable<Server>>;
private readonly _tlsSessionCache = new LRUCache({
ttl: tlsSessionTimeout.asMilliseconds(),
max: maxCachedTlsSessions
});
private _jsonHandler: Nullable<express.RequestHandler>;
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.isObjectOf<string>('viewsPath', viewsPath);
Assert.isString('viewParameters', viewParameters);
Assert.isArrayOf<string>('resourcesPaths', 'string', resourcesPaths);
Assert.isString('favicon', favicon);
// Assert.isObjectOf<string>('cors', cors, 'string');
this._protocol = protocol;
this._port = port;
this._routes = routes;
this._viewsPath = viewsPath;
this._viewParameters = viewParameters;
this._resourcesPaths = resourcesPaths;
this._favicon = favicon;
this._cors = cors;
this._app = new Subject<Nullable<express.Application>>(null);
this._eventEmitter = new EventEmitter();
this._server = new Subject<Nullable<Server>>(null);
this._jsonHandler = null;
}
public start() {
return new Promise((resolve, reject) => {
const app = (this._app.value = express());
this._jsonHandler = bodyParser.json({limit: requestSizeLimit});
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('request', (req, res) => {
this._eventEmitter.emit('request', req.method, req.url, res.statusCode, req.headers);
});
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: (...args: unknown[]) => void): void {
Assert.isNonEmptyString('event', event);
Assert.isFunction('handler', handler);
this._eventEmitter.on(event, handler);
}
public getServer(): Nullable<Server> {
return this._server.value;
}
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));
}
}
// 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) {
console.log('Generic not found handler');
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');
}
const app = this._app.value;
let catchAllHandler: Nullable<RequestHandler> = null;
const registerRoutes = (method: string, routes: Record<string, RequestHandler>): void => {
for (const route of Object.entries(routes)) {
if (!route) {
continue;
}
const [name, handler] = 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 [${method}] route [${name}] handler`);
app[method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete'](name, handler);
}
};
registerRoutes('GET', this._routes.getGETRoutes());
registerRoutes('POST', this._routes.getPOSTRoutes());
registerRoutes('PUT', this._routes.getPUTRoutes());
registerRoutes('PATCH', this._routes.getPATCHRoutes());
registerRoutes('DELETE', this._routes.getDELETERoutes());
}
}

View File

@@ -0,0 +1,9 @@
import type {RequestHandler} from 'express';
export default interface IRoutes {
getGETRoutes(): Record<string, RequestHandler>;
getPOSTRoutes(): Record<string, RequestHandler>;
getPUTRoutes(): Record<string, RequestHandler>;
getPATCHRoutes(): Record<string, RequestHandler>;
getDELETERoutes(): Record<string, RequestHandler>;
}

View File

@@ -0,0 +1,177 @@
import {LoggerFactory} from '@techniker-me/logger';
import {Server as HttpServer} from 'node:http';
import {IncomingMessage} from 'node:http';
import ws, {WebSocketServer as WSServer, WebSocket} from 'ws';
import Assert from '../../lang/Assert';
import Strings from '../../lang/Strings';
import WebsocketExtensions from 'websocket-extensions';
export interface ExtendedWebSocket extends WebSocket {
id: string;
remoteAddress: string;
isOpen(): boolean;
isClosed(): boolean;
}
function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as T;
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as T;
}
if (obj instanceof Object) {
const copy = {} as T;
Object.keys(obj).forEach(key => {
copy[key as keyof T] = deepClone(obj[key as keyof T]);
});
return copy;
}
return obj;
}
function getRemoteAddress(connection: ExtendedWebSocket, req: IncomingMessage): string {
// WebSocket doesn't expose socket directly, so we need to use the underlying socket
const socket = (connection as WebSocket & {_socket?: {remoteAddress?: string}})._socket;
const remoteAddress = socket?.remoteAddress || '::';
const xForwardedFor = req.headers['x-forwarded-for'];
let forwardingRemoteAddressChain: string[] = [];
if (typeof xForwardedFor === 'string') {
forwardingRemoteAddressChain = xForwardedFor.split(', ').filter(Boolean);
} else if (Array.isArray(xForwardedFor)) {
forwardingRemoteAddressChain = xForwardedFor.flatMap(addr => addr.split(', ')).filter(Boolean);
}
forwardingRemoteAddressChain.push(remoteAddress);
// For now, just return the first address (most direct)
// TODO: Implement proper proxy trust checking
return forwardingRemoteAddressChain[forwardingRemoteAddressChain.length - 1] || '::';
}
export type WebSovketServerOptions = {
path?: string;
};
const connectionIdLength = 32;
export default class WebSocketServer {
private readonly _logger = LoggerFactory.getLogger('WebSocketServer');
private readonly _httpServer: HttpServer;
private readonly _parameters: Record<string, string>;
private _server?: WSServer;
private _extensions?: WebsocketExtensions;
constructor(httpServer: HttpServer, parameters: WebSovketServerOptions) {
this._httpServer = httpServer;
this._parameters = parameters;
}
public start(
connectDelegate: (connection: ExtendedWebSocket, req: IncomingMessage) => void,
requestDelegate: (connection: ExtendedWebSocket, message: Buffer) => void,
disconnectDelegate: (connection: ExtendedWebSocket, reasonCode: number, description: string) => void,
pongDelegate: (connection: ExtendedWebSocket, message: Buffer) => void
): void {
Assert.isFunction('connectDelegate', connectDelegate);
Assert.isFunction('requestDelegate', requestDelegate);
Assert.isFunction('disconnectDelegate', disconnectDelegate);
Assert.isFunction('pongDelegate', pongDelegate);
const serverOptions = deepClone(this._parameters);
const address = this._httpServer.address();
const port = typeof address === 'string' ? address : address?.port?.toString() || 'unknown';
const path = serverOptions['path'] || '/';
this._logger.info(`Listening on port [${port}] and bound to [${path}]`);
(serverOptions as Record<string, unknown>)['noServer'] = true;
this._server = new WSServer(serverOptions);
this._extensions = new WebsocketExtensions();
// this._extensions.add(deflate());
(this._server as WSServer & {_server?: HttpServer})._server = this._httpServer;
this._httpServer.on('error', this._server.emit.bind(this._server, 'error'));
this._httpServer.on('listening', this._server.emit.bind(this._server, 'listening'));
this._httpServer.on('upgrade', (req, socket, head) => {
this._logger.debug(`[HttpServer] Upgrade to WebSocket: ${req.method} ${req.url}`);
if (!req.url?.startsWith(path)) {
this._logger.debug(`Skipping upgrade of http request due to incorrect path [${req.url}]`);
return;
}
this._server!.handleUpgrade(req, socket, head, (ws: WebSocket) => {
this._server!.emit('connection', ws, req as IncomingMessage);
});
});
this._httpServer.on('request', (req, res) => {
this._logger.debug(`[HttpServer] Request: ${req.method} ${req.url} -> ${res.statusCode}`);
});
this._server.on('error', err => this._logger.error('An error occurred with WebSocket', err));
this._server.on('connection', (connection: WebSocket, req: IncomingMessage) => {
let closed = false;
try {
const extendedConnection = connection as ExtendedWebSocket;
extendedConnection.id = Strings.randomString(connectionIdLength);
extendedConnection.remoteAddress = getRemoteAddress(extendedConnection, req);
extendedConnection.isOpen = () => connection.readyState === ws.OPEN;
extendedConnection.isClosed = () => connection.readyState === ws.CLOSED;
connection.on('error', (e: Error) => {
this._logger.error('An error occurred on websocket', e);
});
connection.on('message', (message: Buffer) => {
try {
requestDelegate(extendedConnection, message);
} catch (e) {
this._logger.error('Request handler failed for message [%s]', message, e);
}
});
connection.on('close', (reasonCode: number, description: string) => {
if (closed) {
this._logger.warn('[%s] Multiple close events [%s] [%s] [%s]', extendedConnection.id, extendedConnection.remoteAddress, reasonCode, description);
return;
}
closed = true;
try {
disconnectDelegate(extendedConnection, reasonCode, description);
} catch (e) {
this._logger.error('Disconnect handler failed', e);
}
});
connection.on('pong', (message: Buffer) => {
try {
pongDelegate(extendedConnection, message);
} catch (e) {
this._logger.error('Pong handler failed', e);
}
});
connectDelegate(extendedConnection, req);
} catch (e) {
this._logger.error('Accept/connect handler failed', e);
}
});
}
public stop() {}
}

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

@@ -0,0 +1,32 @@
declare module 'websocket-extensions' {
interface Extension {
name: string;
params?: Record<string, string | number | boolean>;
}
class WebSocketExtensions {
constructor();
add(extension: {name: string; [key: string]: unknown}): void;
negotiate(protocol: string, extensions: string[]): string | false;
parse(extensions: string): Extension[];
generate(extensions: Extension[]): string;
}
export = WebSocketExtensions;
}
declare module 'permessage-deflate' {
interface DeflateOptions {
serverNoContextTakeover?: boolean;
clientNoContextTakeover?: boolean;
serverMaxWindowBits?: number;
clientMaxWindowBits?: number;
level?: number;
memLevel?: number;
strategy?: number;
}
function deflate(options?: DeflateOptions): {name: string; [key: string]: unknown};
export = deflate;
}

View File

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

View File

@@ -0,0 +1,30 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "es2022",
"lib": ["es2022"],
"moduleResolution": "node",
"module": "Preserve",
"resolveJsonModule": true,
"allowJs": false,
"isolatedModules": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"],
"references": [
{ "path": "../../packages/shared-types" }
]
}