HttpServer working with GET routes

This commit is contained in:
2025-09-28 09:20:37 -04:00
parent 9372777296
commit 8585549ae1
22 changed files with 1980 additions and 50 deletions

View File

@@ -8,7 +8,7 @@ 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 from 'express';
import express, { RequestHandler } from 'express';
import morgan from 'morgan';
import favicon from 'serve-favicon';
import bodyParser from 'body-parser';
@@ -32,7 +32,6 @@ export default class 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;
@@ -47,6 +46,7 @@ export default class HttpServer {
ttl: tlsSessionTimeout.asMilliseconds(),
max: maxCachedTlsSessions
});
private _jsonHandler: Nullable<express.RequestHandler>;
constructor(
protocol: 'https' | 'http',
@@ -78,11 +78,13 @@ export default class HttpServer {
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();
@@ -113,6 +115,10 @@ export default class HttpServer {
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');
@@ -142,7 +148,7 @@ export default class HttpServer {
});
}
public on(event: string, handler: () => void): void {
public on(event: string, handler: (...args: unknown[]) => void): void {
Assert.isNonEmptyString('event', event);
Assert.isFunction('handler', handler);
@@ -310,9 +316,6 @@ export default class HttpServer {
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
@@ -325,6 +328,8 @@ export default class HttpServer {
}
private genericNotFoundHandler(_req: express.Request, res: express.Response) {
console.log('Generic not found handler');
res.status(404).send({status: 'not-found'});
return;
@@ -335,26 +340,40 @@ export default class HttpServer {
throw new Error('Unable to configure routes, no app instance found');
}
// TODO: Implement routes
// const app = this._app.value;
const app = this._app.value;
let catchAllHandler: Nullable<RequestHandler> = null;
const registerRoutes = (routes: Record<string, RequestHandler>): void => {
for (const route of Object.entries(routes)) {
const [name, handler] = route;
// let catchAllHandler = null;
console.log(`[${name}] registering [%o]`, handler);
if (name === '*') {
console.log('Catch-all handler', name, handler);
if (catchAllHandler) {
throw new Error(`Only one catch-all handler can ber registered per server, ignoring conflicting catch-all`);
}
// for (const route of this._routes.getGETRoutes().entries()) {
// const [handler, name] = route;
catchAllHandler = handler;
continue;
}
// if (name === '*') {
// if (catchAllHandler) {
// throw new Error(`Only one catch-all handler can ber registered per server, ignoring conflicting catch-all`);
// }
this._logger.debug(`Registering [GET] route [${name}] with [%o]`, handler);
app.get(name, handler);
}
}
// catchAllHandler = handler;
const getRoutes = this._routes.getGETRoutes();
const postRoutes = this._routes.getPOSTRoutes();
const putRoutes = this._routes.getPUTRoutes();
const patchRoutes = this._routes.getPATCHRoutes();
const deleteRoutes = this._routes.getDELETERoutes();
// continue;
// }
// this._logger.debug(`Registering [GET] route [${name}]`);
// // app.get(name, this._json);
// }
registerRoutes(getRoutes);
registerRoutes(postRoutes);
registerRoutes(putRoutes);
registerRoutes(patchRoutes);
registerRoutes(deleteRoutes);
}
}

View File

@@ -1,7 +1,9 @@
import type {RequestHandler} from 'express';
export default interface IRoutes {
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>>;
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';
import deflate from 'permessage-deflate';
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) => {
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() {}
}