Create WebSocket Server

- Created a WebSocket server with connection handling and message broadcasting
- Added frontend files including HTML, TypeScript, and CSS for a WebSocket test client
- Configured package.json for both server and frontend with necessary scripts and dependencies
- Introduced Prettier configuration for code formatting
- Established a basic structure for handling WebSocket connections and messages
This commit is contained in:
2025-09-05 23:44:28 -04:00
parent 7733ef2488
commit d8fe4a9dc6
23 changed files with 2180 additions and 39 deletions

12
.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"arrowParens": "avoid",
"bracketSameLine": true,
"bracketSpacing": false,
"printWidth": 180,
"semi": true,
"singleAttributePerLine": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": false
}

13
frontend/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

15
frontend/web/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~5.8.3",
"vite": "^7.1.2"
}
}

13
frontend/web/src/main.ts Normal file
View File

@@ -0,0 +1,13 @@
const webSocket = new WebSocket('http://localhost:3000');
webSocket.onerror = (e: Event) => {
console.error(`${new Date().toISOString()} [WebSocket] error [%o]`, e);
};
webSocket.onopen = () => {
console.log(`${new Date().toISOString()} [WebSocket] open`);
};
webSocket.onmessage = () => {
console.log(`${new Date().toISOString()} [WebSocket] message!`);
};

View File

@@ -0,0 +1,4 @@
body {
margin: 0px;
padding: 0px;
}

1
frontend/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

107
inlined.html Normal file
View File

@@ -0,0 +1,107 @@
private getTestPage(): string {
return `
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Test</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
#messages { border: 1px solid #ccc; height: 300px; overflow-y: scroll; padding: 10px; margin: 10px 0; }
#input { width: 70%; padding: 5px; }
button { padding: 5px 10px; margin-left: 5px; }
.status { padding: 5px; margin: 5px 0; border-radius: 3px; }
.connected { background-color: #d4edda; color: #155724; }
.disconnected { background-color: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<h1>WebSocket Test Client</h1>
<div id="status" class="status disconnected">Disconnected</div>
<div id="messages"></div>
<input type="text" id="input" placeholder="Type a message..." disabled>
<button id="send" disabled>Send</button>
<button id="connect">Connect</button>
<button id="disconnect" disabled>Disconnect</button>
<script>
let ws = null;
const status = document.getElementById('status');
const messages = document.getElementById('messages');
const input = document.getElementById('input');
const sendBtn = document.getElementById('send');
const connectBtn = document.getElementById('connect');
const disconnectBtn = document.getElementById('disconnect');
function addMessage(message) {
const div = document.createElement('div');
div.textContent = new Date().toLocaleTimeString() + ': ' + message;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function updateStatus(connected) {
if (connected) {
status.textContent = 'Connected';
status.className = 'status connected';
input.disabled = false;
sendBtn.disabled = false;
connectBtn.disabled = true;
disconnectBtn.disabled = false;
} else {
status.textContent = 'Disconnected';
status.className = 'status disconnected';
input.disabled = true;
sendBtn.disabled = true;
connectBtn.disabled = false;
disconnectBtn.disabled = true;
}
}
connectBtn.onclick = function() {
ws = new WebSocket('ws://localhost:3000');
ws.onopen = function() {
addMessage('Connected to WebSocket server');
updateStatus(true);
};
ws.onmessage = function(event) {
addMessage('Received: ' + event.data);
};
ws.onclose = function() {
addMessage('Disconnected from WebSocket server');
updateStatus(false);
};
ws.onerror = function(error) {
addMessage('Error: ' + error);
};
};
disconnectBtn.onclick = function() {
if (ws) {
ws.close();
}
};
sendBtn.onclick = function() {
if (ws && input.value) {
ws.send(input.value);
addMessage('Sent: ' + input.value);
input.value = '';
}
};
input.onkeypress = function(e) {
if (e.key === 'Enter') {
sendBtn.click();
}
};
</script>
</body>
</html>
`;
}

1083
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,14 @@
"type": "module",
"main": "index.js",
"scripts": {
"dev:server": "npm run dev --workspace=server"
"dev:web": "npm run dev --workspace=web",
"dev:server": "npm run dev --workspace=server",
"lint:server": "npm run lint --workspace=server",
"lint:fix:server": "npm run lint:fix --workspace=server"
},
"workspaces": [
"server"
"server",
"frontend/web"
],
"devDependencies": {
"@types/node": "24.3.1",

View File

@@ -7,7 +7,11 @@
"type": "module",
"main": "index.js",
"scripts": {
"dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node-esm' src/index.ts"
"dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node-esm' src/index.ts",
"format": "prettier --write 'src/**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"prelint:fix": "npm run format",
"lint:fix": "eslint 'src/**/*.ts' --fix"
},
"dependencies": {
"@techniker-me/logger": "0.0.15"
@@ -19,6 +23,7 @@
"globals": "14.0.0",
"jiti": "2.5.1",
"nodemon": "^3.1.0",
"prettier": "3.6.2",
"ts-node": "^10.9.0",
"typescript": "^5.0.0",
"typescript-eslint": "8.42.0"

View File

@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Test</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
#messages { border: 1px solid #ccc; height: 300px; overflow-y: scroll; padding: 10px; margin: 10px 0; }
#input { width: 70%; padding: 5px; }
button { padding: 5px 10px; margin-left: 5px; }
.status { padding: 5px; margin: 5px 0; border-radius: 3px; }
.connected { background-color: #d4edda; color: #155724; }
.disconnected { background-color: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<h1>WebSocket Test Client</h1>
<div id="status" class="status disconnected">Disconnected</div>
<div id="messages"></div>
<input type="text" id="input" placeholder="Type a message..." disabled>
<button id="send" disabled>Send</button>
<button id="connect">Connect</button>
<button id="disconnect" disabled>Disconnect</button>
<script>
let ws = null;
const status = document.getElementById('status');
const messages = document.getElementById('messages');
const input = document.getElementById('input');
const sendBtn = document.getElementById('send');
const connectBtn = document.getElementById('connect');
const disconnectBtn = document.getElementById('disconnect');
function addMessage(message) {
const div = document.createElement('div');
div.textContent = new Date().toLocaleTimeString() + ': ' + message;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function updateStatus(connected) {
if (connected) {
status.textContent = 'Connected';
status.className = 'status connected';
input.disabled = false;
sendBtn.disabled = false;
connectBtn.disabled = true;
disconnectBtn.disabled = false;
} else {
status.textContent = 'Disconnected';
status.className = 'status disconnected';
input.disabled = true;
sendBtn.disabled = true;
connectBtn.disabled = false;
disconnectBtn.disabled = true;
}
}
connectBtn.onclick = function() {
ws = new WebSocket('ws://localhost:3000');
ws.onopen = function() {
addMessage('Connected to WebSocket server');
updateStatus(true);
};
ws.onmessage = function(event) {
addMessage('Received: ' + event.data);
};
ws.onclose = function() {
addMessage('Disconnected from WebSocket server');
updateStatus(false);
};
ws.onerror = function(error) {
addMessage('Error: ' + error);
};
};
disconnectBtn.onclick = function() {
if (ws) {
ws.close();
}
};
sendBtn.onclick = function() {
if (ws && input.value) {
ws.send(input.value);
addMessage('Sent: ' + input.value);
input.value = '';
}
};
input.onkeypress = function(e) {
if (e.key === 'Enter') {
sendBtn.click();
}
};
</script>
</body>
</html>

View File

@@ -1,29 +0,0 @@
import {LoggerFactory} from '@techniker-me/logger';
import {createServer as createHttpServer, type IncomingMessage, type ServerResponse, type Server as HTTPServer, type ServerOptions as HTTPServerOptions} from 'node:http';
export class Server {
private readonly _logger = LoggerFactory.getLogger('Server');
private readonly _http: HTTPServer;
constructor(serverOptions: HTTPServerOptions) {
this._http = createHttpServer(serverOptions, this.httpRequestHandler.bind(this));
}
public async listen(port: number, callback?: (port: number) => void) {
await new Promise<void>((resolve, reject) => {
this._http.listen(port, () => {
resolve();
});
this._http.on('error', (error) => {
reject(error);
});
});
callback?.(port);
}
private httpRequestHandler(req: IncomingMessage, res: ServerResponse) {
this._logger.info(`Received request: ${req.url}`);
res.end('Hello World');
}
}

View File

@@ -1,10 +1,10 @@
import {ServerFactory} from './ServerFactory.ts';
import {ServerFactory} from './net/ServerFactory.ts';
const server = ServerFactory.createServer({
keepAlive: true,
keepAliveTimeout: 10000,
keepAliveTimeout: 10000
});
server.listen(3000, (port: number) => {
console.log(`Server is running on port [${port}]`);
});
});

70
server/src/net/Server.ts Normal file
View File

@@ -0,0 +1,70 @@
import {LoggerFactory} from '@techniker-me/logger';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {createServer as createHttpServer} from 'node:http';
import type {IncomingMessage, ServerResponse, Server as HTTPServer, ServerOptions as HTTPServerOptions} from 'node:http';
import type {IWebSocketServer} from './WebSockets/IWebSocketConnection.ts';
import {WebSocketServerFactory} from './WebSockets/WebSocketServerFactory.ts';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export class Server {
private readonly _logger = LoggerFactory.getLogger('Server');
private readonly _http: HTTPServer;
private readonly _webSocketServer: IWebSocketServer;
constructor(serverOptions: HTTPServerOptions) {
this._http = createHttpServer(serverOptions, this.httpRequestHandler.bind(this));
this._webSocketServer = WebSocketServerFactory.createWebSocketServer(this._http);
this.setupWebSocketHandlers();
}
public async listen(port: number, callback?: (port: number) => void) {
await new Promise<void>((resolve, reject) => {
this._http.listen(port, () => {
resolve();
});
this._http.on('error', error => {
reject(error);
});
});
callback?.(port);
}
private setupWebSocketHandlers(): void {
this._webSocketServer.on('connection', connection => {
this._logger.info(`WebSocket connection established: [${connection.id}]`);
connection.on('message', message => {
this._logger.debug(`WebSocket message from [${connection.id}]: ${message}`);
this._webSocketServer.broadcast(message, connection.id);
});
connection.on('close', () => {
this._logger.info(`WebSocket connection closed: [${connection.id}]`);
});
connection.on('error', error => {
this._logger.error(`WebSocket connection error for [${connection.id}]:`, error);
});
});
this._webSocketServer.on('error', error => {
this._logger.error('WebSocket server error:', error);
});
}
private httpRequestHandler(req: IncomingMessage, res: ServerResponse) {
this._logger.info(`Received request: ${req.url}`);
if (req.url === '/') {
res.writeHead(200, {'Content-Type': 'text/html'});
res.end(fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'test-page.html'), 'utf8'));
} else {
res.end('Hello World');
}
}
}

View File

@@ -1,12 +1,12 @@
import {Server} from './Server.ts';
import { type ServerOptions as HTTPServerOptions } from 'node:http';
import {type ServerOptions as HTTPServerOptions} from 'node:http';
export class ServerFactory {
public static createServer(serverOptions: HTTPServerOptions): Server {
public static createServer(serverOptions: HTTPServerOptions): Server {
return new Server(serverOptions);
}
private constructor() {
throw new Error('ServerFactory is a static class and cannot be instantiated');
}
}
}

View File

@@ -0,0 +1,82 @@
import {type IncomingMessage, type ServerResponse} from 'node:http';
export interface IWebSocketConnection {
readonly id: string;
readonly readyState: WebSocketReadyState;
on(event: 'error', listener: (error: Error) => void): this;
on(event: 'close', listener: () => void): this;
on(event: 'message', listener: (data: string | Buffer) => void): this;
on(event: 'pong', listener: () => void): this;
send(data: string | Buffer): void;
close(code?: number, reason?: string): void;
ping(): void;
pong(): void;
}
export interface IWebSocketServer {
on(event: 'connection', listener: (connection: IWebSocketConnection) => void): this;
on(event: 'error', listener: (error: Error) => void): this;
on(event: 'close', listener: () => void): this;
emit(event: 'connection', connection: IWebSocketConnection): boolean;
emit(event: 'error', error: Error): boolean;
emit(event: 'close'): boolean;
broadcast(message: string | Buffer, excludeConnectionId?: string): void;
}
export interface IWebSocketHandshake {
isValid(request: IncomingMessage): boolean;
performHandshake(request: IncomingMessage, response: ServerResponse): boolean;
}
export interface IWebSocketFrame {
readonly opcode: WebSocketOpcode;
readonly payloadLength: number;
readonly payload: Buffer;
readonly isMasked: boolean;
readonly maskingKey: Buffer | undefined;
readonly isFin: boolean;
toBuffer(): Buffer;
}
export interface IWebSocketFrameParser {
parse(buffer: Buffer): IWebSocketFrame | null;
createFrame(opcode: WebSocketOpcode, payload: Buffer, isMasked?: boolean): IWebSocketFrame;
}
export const WebSocketReadyState = {
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3
} as const;
export const WebSocketOpcode = {
CONTINUATION: 0x0,
TEXT: 0x1,
BINARY: 0x2,
CLOSE: 0x8,
PING: 0x9,
PONG: 0xa
} as const;
export type WebSocketReadyState = (typeof WebSocketReadyState)[keyof typeof WebSocketReadyState];
export type WebSocketOpcode = (typeof WebSocketOpcode)[keyof typeof WebSocketOpcode];
export const WebSocketCloseCode = {
NORMAL_CLOSURE: 1000,
GOING_AWAY: 1001,
PROTOCOL_ERROR: 1002,
UNSUPPORTED_DATA: 1003,
NO_STATUS_RECEIVED: 1005,
ABNORMAL_CLOSURE: 1006,
INVALID_FRAME_PAYLOAD_DATA: 1007,
POLICY_VIOLATION: 1008,
MESSAGE_TOO_BIG: 1009,
MANDATORY_EXTENSION: 1010,
INTERNAL_ERROR: 1011,
SERVICE_RESTART: 1012,
TRY_AGAIN_LATER: 1013,
BAD_GATEWAY: 1014,
TLS_HANDSHAKE: 1015
} as const;

View File

@@ -0,0 +1,212 @@
import {EventEmitter} from 'node:events';
import {randomUUID} from 'node:crypto';
import {type Socket} from 'node:net';
import {type IWebSocketConnection, WebSocketReadyState, WebSocketOpcode, type IWebSocketFrameParser, type IWebSocketFrame} from './IWebSocketConnection.ts';
import {WebSocketFrameParser} from './WebSocketFrame.ts';
export class WebSocketConnection extends EventEmitter implements IWebSocketConnection {
public readonly id: string;
public readyState: WebSocketReadyState = WebSocketReadyState.CONNECTING;
private readonly socket: Socket;
private readonly frameParser: IWebSocketFrameParser;
private buffer: Buffer = Buffer.alloc(0);
private pingInterval: NodeJS.Timeout | undefined = undefined;
private readonly pingIntervalMs: number;
constructor(socket: Socket, pingIntervalMs: number = 30000) {
super();
this.id = randomUUID();
this.socket = socket;
this.frameParser = new WebSocketFrameParser();
this.pingIntervalMs = pingIntervalMs;
this.setupSocketHandlers();
this.startPingInterval();
// Set state to OPEN after handshake is complete
this.readyState = WebSocketReadyState.OPEN;
}
private setupSocketHandlers(): void {
this.socket.on('data', (data: Buffer) => {
this.handleData(data);
});
this.socket.on('close', () => {
this.handleClose();
});
this.socket.on('error', (error: Error) => {
this.handleError(error);
});
}
private handleData(data: Buffer): void {
// Append new data to buffer
this.buffer = Buffer.concat([this.buffer, data]);
// Try to parse frames from buffer
while (this.buffer.length > 0) {
const frame = this.frameParser.parse(this.buffer);
if (!frame) {
break; // Not enough data for a complete frame
}
// Remove parsed frame from buffer
const frameLength = this.calculateFrameLength(frame);
this.buffer = this.buffer.subarray(frameLength);
this.handleFrame(frame);
}
}
private calculateFrameLength(frame: IWebSocketFrame): number {
let length = 2; // Base header length
if (frame.payloadLength > 65535) {
length += 8; // 64-bit length
} else if (frame.payloadLength > 125) {
length += 2; // 16-bit length
}
if (frame.isMasked) {
length += 4; // Masking key
}
length += frame.payloadLength;
return length;
}
private handleFrame(frame: IWebSocketFrame): void {
switch (frame.opcode) {
case WebSocketOpcode.TEXT:
this.emit('message', frame.payload.toString('utf8'));
break;
case WebSocketOpcode.BINARY:
this.emit('message', frame.payload);
break;
case WebSocketOpcode.CLOSE:
this.handleCloseFrame(frame);
break;
case WebSocketOpcode.PING:
this.pong();
break;
case WebSocketOpcode.PONG:
this.emit('pong');
break;
default:
// Unknown opcode, close connection
this.close(1002, 'Unknown opcode');
break;
}
}
private handleCloseFrame(frame: IWebSocketFrame): void {
let code = 1000; // Normal closure
let reason = '';
if (frame.payloadLength >= 2) {
code = frame.payload.readUInt16BE(0);
if (frame.payloadLength > 2) {
reason = frame.payload.subarray(2).toString('utf8');
}
}
this.close(code, reason);
}
private handleClose(): void {
if (this.readyState !== WebSocketReadyState.CLOSED) {
this.readyState = WebSocketReadyState.CLOSED;
this.stopPingInterval();
this.emit('close');
}
}
private handleError(error: Error): void {
this.emit('error', error);
this.close(1011, 'Internal error');
}
send(data: string | Buffer): void {
if (this.readyState !== WebSocketReadyState.OPEN) {
throw new Error('WebSocket connection is not open');
}
const opcode = typeof data === 'string' ? WebSocketOpcode.TEXT : WebSocketOpcode.BINARY;
const payload = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
const frame = this.frameParser.createFrame(opcode, payload);
this.socket.write(frame.toBuffer());
}
close(code: number = 1000, reason: string = ''): void {
if (this.readyState === WebSocketReadyState.CLOSED) {
return;
}
if (this.readyState === WebSocketReadyState.OPEN) {
this.readyState = WebSocketReadyState.CLOSING;
// Send close frame
const reasonBuffer = Buffer.from(reason, 'utf8');
const payload = Buffer.alloc(2 + reasonBuffer.length);
payload.writeUInt16BE(code, 0);
reasonBuffer.copy(payload, 2);
const frame = this.frameParser.createFrame(WebSocketOpcode.CLOSE, payload);
this.socket.write(frame.toBuffer());
}
this.readyState = WebSocketReadyState.CLOSED;
this.stopPingInterval();
this.socket.end();
}
ping(): void {
if (this.readyState === WebSocketReadyState.OPEN) {
const frame = this.frameParser.createFrame(WebSocketOpcode.PING, Buffer.alloc(0));
this.socket.write(frame.toBuffer());
}
}
pong(): void {
if (this.readyState === WebSocketReadyState.OPEN) {
const frame = this.frameParser.createFrame(WebSocketOpcode.PONG, Buffer.alloc(0));
this.socket.write(frame.toBuffer());
}
}
private startPingInterval(): void {
this.pingInterval = setInterval(() => {
if (this.readyState === WebSocketReadyState.OPEN) {
this.ping();
}
}, this.pingIntervalMs);
}
private stopPingInterval(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = undefined;
}
}
// Additional methods for connection management
getRemoteAddress(): string {
return this.socket.remoteAddress || 'unknown';
}
getRemotePort(): number {
return this.socket.remotePort || 0;
}
isAlive(): boolean {
return this.readyState === WebSocketReadyState.OPEN && !this.socket.destroyed;
}
}

View File

@@ -0,0 +1,152 @@
import {type IWebSocketFrame, type IWebSocketFrameParser, WebSocketOpcode} from './IWebSocketConnection.ts';
export class WebSocketFrame implements IWebSocketFrame {
public readonly opcode: WebSocketOpcode;
public readonly payload: Buffer;
public readonly isFin: boolean;
public readonly isMasked: boolean;
public readonly maskingKey: Buffer | undefined;
constructor(opcode: WebSocketOpcode, payload: Buffer, isFin: boolean = true, isMasked: boolean = false, maskingKey?: Buffer) {
this.opcode = opcode;
this.payload = payload;
this.isFin = isFin;
this.isMasked = isMasked;
this.maskingKey = maskingKey;
}
get payloadLength(): number {
return this.payload.length;
}
toBuffer(): Buffer {
const payloadLength = this.payload.length;
let headerLength = 2;
// Calculate header length based on payload size
if (payloadLength > 65535) {
headerLength += 8; // 64-bit length
} else if (payloadLength > 125) {
headerLength += 2; // 16-bit length
}
// Add masking key length if masked
if (this.isMasked) {
headerLength += 4;
}
const buffer = Buffer.alloc(headerLength + payloadLength);
let offset = 0;
// First byte: FIN (1 bit) + RSV (3 bits) + Opcode (4 bits)
const firstByte = (this.isFin ? 0x80 : 0x00) | this.opcode;
buffer[offset++] = firstByte;
// Second byte: MASK (1 bit) + Payload length (7 bits)
let secondByte = this.isMasked ? 0x80 : 0x00;
if (payloadLength <= 125) {
secondByte |= payloadLength;
buffer[offset++] = secondByte;
} else if (payloadLength <= 65535) {
secondByte |= 126;
buffer[offset++] = secondByte;
buffer.writeUInt16BE(payloadLength, offset);
offset += 2;
} else {
secondByte |= 127;
buffer[offset++] = secondByte;
// Write 64-bit length (but we only use 32 bits for practical purposes)
buffer.writeUInt32BE(0, offset);
offset += 4;
buffer.writeUInt32BE(payloadLength, offset);
offset += 4;
}
// Masking key
if (this.isMasked && this.maskingKey) {
this.maskingKey.copy(buffer, offset);
offset += 4;
}
// Payload
if (this.isMasked && this.maskingKey) {
// Apply masking
const mask = this.maskingKey as Buffer;
for (let i = 0; i < payloadLength; i++) {
const srcByte = this.payload.readUInt8(i);
const maskByte = mask.readUInt8(i % 4);
buffer.writeUInt8(srcByte ^ maskByte, offset + i);
}
} else {
this.payload.copy(buffer, offset);
}
return buffer;
}
}
export class WebSocketFrameParser implements IWebSocketFrameParser {
parse(buffer: Buffer): IWebSocketFrame | null {
if (buffer.length < 2) {
return null; // Not enough data for a complete header
}
let offset = 0;
// Parse first byte
const firstByte = buffer[offset++]!;
const isFin = (firstByte & 0x80) !== 0;
const opcode = firstByte & 0x0f;
// Parse second byte
const secondByte = buffer[offset++]!;
const isMasked = (secondByte & 0x80) !== 0;
let payloadLength = secondByte & 0x7f;
// Parse extended payload length
if (payloadLength === 126) {
if (buffer.length < offset + 2) return null;
payloadLength = buffer.readUInt16BE(offset);
offset += 2;
} else if (payloadLength === 127) {
if (buffer.length < offset + 8) return null;
// Skip first 4 bytes (should be 0 for practical purposes)
offset += 4;
payloadLength = buffer.readUInt32BE(offset);
offset += 4;
}
// Parse masking key
let maskingKey: Buffer | undefined;
if (isMasked) {
if (buffer.length < offset + 4) return null;
maskingKey = buffer.subarray(offset, offset + 4);
offset += 4;
}
// Check if we have enough data for the payload
if (buffer.length < offset + payloadLength) {
return null; // Not enough data for complete frame
}
// Extract payload
const payload = buffer.subarray(offset, offset + payloadLength);
// Unmask payload if necessary
if (isMasked && maskingKey) {
const mask = maskingKey as Buffer;
for (let i = 0; i < payload.length; i++) {
const maskByte = mask.readUInt8(i % 4);
const newVal = payload.readUInt8(i) ^ maskByte;
payload.writeUInt8(newVal, i);
}
}
return new WebSocketFrame(opcode as WebSocketOpcode, payload, isFin, isMasked, maskingKey);
}
createFrame(opcode: WebSocketOpcode, payload: Buffer, isMasked: boolean = false): IWebSocketFrame {
return new WebSocketFrame(opcode, payload, true, isMasked);
}
}

View File

@@ -0,0 +1,97 @@
import {createHash} from 'node:crypto';
import {type IncomingMessage, type ServerResponse} from 'node:http';
import {type IWebSocketHandshake} from './IWebSocketConnection.ts';
export class WebSocketHandshake implements IWebSocketHandshake {
private static readonly WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
private static readonly WEBSOCKET_VERSION = '13';
isValid(request: IncomingMessage): boolean {
// Check HTTP method
if (request.method !== 'GET') {
return false;
}
// Check headers
const upgrade = request.headers.upgrade;
const connection = request.headers.connection;
const secWebSocketKey = request.headers['sec-websocket-key'];
const secWebSocketVersion = request.headers['sec-websocket-version'];
if (!upgrade || upgrade.toLowerCase() !== 'websocket') {
return false;
}
if (!connection || !connection.toLowerCase().includes('upgrade')) {
return false;
}
if (!secWebSocketKey || typeof secWebSocketKey !== 'string') {
return false;
}
if (!secWebSocketVersion || secWebSocketVersion !== WebSocketHandshake.WEBSOCKET_VERSION) {
return false;
}
// Validate Sec-WebSocket-Key format (should be 16 bytes base64 encoded)
try {
const keyBuffer = Buffer.from(secWebSocketKey, 'base64');
if (keyBuffer.length !== 16) {
return false;
}
} catch {
return false;
}
return true;
}
performHandshake(request: IncomingMessage, response: ServerResponse): boolean {
if (!this.isValid(request)) {
response.writeHead(400, {'Content-Type': 'text/plain'});
response.end('Bad Request');
return false;
}
const secWebSocketKey = request.headers['sec-websocket-key'] as string;
const secWebSocketProtocol = request.headers['sec-websocket-protocol'];
const secWebSocketExtensions = request.headers['sec-websocket-extensions'];
// Generate Sec-WebSocket-Accept
const acceptKey = this.generateAcceptKey(secWebSocketKey);
// Prepare response headers
const headers: Record<string, string> = {
Upgrade: 'websocket',
Connection: 'Upgrade',
'Sec-WebSocket-Accept': acceptKey
};
// Add protocol if requested
if (secWebSocketProtocol) {
// For simplicity, we'll accept the first protocol
const protocols = secWebSocketProtocol.split(',').map(p => p.trim());
if (protocols.length > 0) {
headers['Sec-WebSocket-Protocol'] = protocols[0];
}
}
// Add extensions if requested (for simplicity, we'll echo back)
if (secWebSocketExtensions) {
headers['Sec-WebSocket-Extensions'] = secWebSocketExtensions as string;
}
// Send the handshake response
response.writeHead(101, headers);
response.end();
return true;
}
private generateAcceptKey(secWebSocketKey: string): string {
const combined = secWebSocketKey + WebSocketHandshake.WEBSOCKET_GUID;
const hash = createHash('sha1').update(combined).digest('base64');
return hash;
}
}

View File

@@ -0,0 +1,156 @@
import {LoggerFactory} from '@techniker-me/logger';
import {EventEmitter} from 'node:events';
import {createHash} from 'node:crypto';
import type {Socket} from 'node:net';
import {type IncomingMessage, type Server as HTTPServer} from 'node:http';
import {type IWebSocketServer, type IWebSocketHandshake} from './IWebSocketConnection.ts';
import {WebSocketHandshake} from './WebSocketHandshake.ts';
import {WebSocketConnection} from './WebSocketConnection.ts';
export class WebSocketServer extends EventEmitter implements IWebSocketServer {
private readonly _logger = LoggerFactory.getLogger('WebSocketServer');
private readonly httpServer: HTTPServer;
private readonly handshake: IWebSocketHandshake;
private readonly connections: Map<string, WebSocketConnection> = new Map();
private readonly pingIntervalMs: number;
constructor(httpServer: HTTPServer, pingIntervalMs: number = 30000) {
super();
this.httpServer = httpServer;
this.handshake = new WebSocketHandshake();
this.pingIntervalMs = pingIntervalMs;
this.setupHttpServer();
}
private setupHttpServer(): void {
this.httpServer.on('upgrade', this.handleUpgrade.bind(this));
}
private handleUpgrade(request: IncomingMessage, socket: Socket, _head: Buffer): void {
this._logger.debug(`WebSocket upgrade request: [${request.url}]`);
if (!this.handshake.isValid(request)) {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
return;
}
const secWebSocketKey = request.headers['sec-websocket-key'] as string;
const acceptKey = this.generateAcceptKey(secWebSocketKey);
const response = ['HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', `Sec-WebSocket-Accept: ${acceptKey}`, '', ''].join('\r\n');
const connection = new WebSocketConnection(socket, this.pingIntervalMs);
this.setupConnectionHandlers(connection);
this.connections.set(connection.id, connection);
this.emit('connection', connection);
socket.write(response);
}
private generateAcceptKey(secWebSocketKey: string): string {
const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const combined = secWebSocketKey + WEBSOCKET_GUID;
return createHash('sha1').update(combined).digest('base64');
}
private setupConnectionHandlers(connection: WebSocketConnection): void {
connection.on('close', () => {
this.connections.delete(connection.id);
});
connection.on('error', (error: Error) => {
this.emit('error', error);
this.connections.delete(connection.id);
});
}
public getConnection(id: string): WebSocketConnection | undefined {
return this.connections.get(id);
}
public getAllConnections(): WebSocketConnection[] {
return Array.from(this.connections.values());
}
public getConnectionCount(): number {
return this.connections.size;
}
public broadcast(message: string | Buffer, excludeConnectionId?: string): void {
const connections = this.getAllConnections();
for (const connection of connections) {
if (connection.id !== excludeConnectionId && connection.readyState === 1) {
// OPEN
try {
connection.send(message);
} catch (error) {
console.error(`Error broadcasting to connection ${connection.id}:`, error);
}
}
}
}
public closeConnection(id: string, code?: number, reason?: string): boolean {
const connection = this.connections.get(id);
if (connection) {
connection.close(code, reason);
return true;
}
return false;
}
public closeAllConnections(code?: number, reason?: string): void {
const connections = this.getAllConnections();
for (const connection of connections) {
connection.close(code, reason);
}
}
public getAliveConnections(): WebSocketConnection[] {
return this.getAllConnections().filter(conn => conn.readyState === 1 && (conn as WebSocketConnection).isAlive?.() !== false);
}
public getConnectionStats(): {
total: number;
alive: number;
connecting: number;
closing: number;
closed: number;
} {
const connections = this.getAllConnections();
const stats = {
total: connections.length,
alive: 0,
connecting: 0,
closing: 0,
closed: 0
};
for (const connection of connections) {
switch (connection.readyState) {
case 0: // CONNECTING
stats.connecting++;
break;
case 1: // OPEN
stats.alive++;
break;
case 2: // CLOSING
stats.closing++;
break;
case 3: // CLOSED
stats.closed++;
break;
}
}
return stats;
}
public dispose(): void {
this.closeAllConnections(1001, 'Server shutting down');
this.connections.clear();
this.removeAllListeners();
}
}

View File

@@ -0,0 +1,13 @@
import {type Server as HTTPServer} from 'node:http';
import {type IWebSocketServer} from './IWebSocketConnection.ts';
import {WebSocketServer} from './WebSocketServer.ts';
export class WebSocketServerFactory {
public static createWebSocketServer(httpServer: HTTPServer, pingIntervalMs: number = 30000): IWebSocketServer {
return new WebSocketServer(httpServer, pingIntervalMs);
}
private constructor() {
throw new Error('WebSocketServerFactory is a static class and cannot be instantiated');
}
}

View File

@@ -0,0 +1,7 @@
// Export all WebSocket-related classes and interfaces
export * from './IWebSocketConnection.ts';
export * from './WebSocketFrame.ts';
export * from './WebSocketHandshake.ts';
export * from './WebSocketConnection.ts';
export * from './WebSocketServer.ts';
export * from './WebSocketServerFactory.ts';