From 0cc0ce13e73ac32f17e99a0c5356443b7f81e80a Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Sun, 28 Sep 2025 14:55:09 -0400 Subject: [PATCH] basic chat --- Web/WebSocket/websocket/frontend/.npmrc | 3 + Web/WebSocket/websocket/frontend/package.json | 4 + Web/WebSocket/websocket/frontend/src/main.ts | 88 ++++++++++++++- .../frontend/src/messages/MessageFactory.ts | 44 ++++++++ .../websocket/frontend/src/styles.css | 44 ++++++++ .../src/websockets/WebSocketConnection.ts | 101 ++++++++++++++++++ .../websockets/WebSocketConnectionStatus.ts | 8 ++ .../websocket/frontend/tsconfig.json | 2 +- .../websocket/frontend/types/Units.ts | 2 + Web/WebSocket/websocket/server/src/index.ts | 60 ++++++++--- .../server/src/net/http/HttpServer.ts | 12 +-- .../src/net/websocket/WebSocketServer.ts | 4 +- 12 files changed, 347 insertions(+), 25 deletions(-) create mode 100644 Web/WebSocket/websocket/frontend/.npmrc create mode 100644 Web/WebSocket/websocket/frontend/src/messages/MessageFactory.ts create mode 100644 Web/WebSocket/websocket/frontend/src/styles.css create mode 100644 Web/WebSocket/websocket/frontend/src/websockets/WebSocketConnection.ts create mode 100644 Web/WebSocket/websocket/frontend/src/websockets/WebSocketConnectionStatus.ts create mode 100644 Web/WebSocket/websocket/frontend/types/Units.ts diff --git a/Web/WebSocket/websocket/frontend/.npmrc b/Web/WebSocket/websocket/frontend/.npmrc new file mode 100644 index 0000000..60d85b5 --- /dev/null +++ b/Web/WebSocket/websocket/frontend/.npmrc @@ -0,0 +1,3 @@ +save-exact=true +package-lock=false +@techniker-me:registry=https://registry-node.techniker.me diff --git a/Web/WebSocket/websocket/frontend/package.json b/Web/WebSocket/websocket/frontend/package.json index 11b4199..4c2d420 100644 --- a/Web/WebSocket/websocket/frontend/package.json +++ b/Web/WebSocket/websocket/frontend/package.json @@ -11,5 +11,9 @@ "devDependencies": { "typescript": "~5.8.3", "vite": "^7.1.7" + }, + "dependencies": { + "@techniker-me/logger": "0.0.15", + "@techniker-me/tools": "2025.0.16" } } diff --git a/Web/WebSocket/websocket/frontend/src/main.ts b/Web/WebSocket/websocket/frontend/src/main.ts index 2cd1494..ba52720 100644 --- a/Web/WebSocket/websocket/frontend/src/main.ts +++ b/Web/WebSocket/websocket/frontend/src/main.ts @@ -1,2 +1,88 @@ +import WebSocketConnection from './websockets/WebSocketConnection'; +import {WebSocketConnectionStatus} from './websockets/WebSocketConnectionStatus'; +import './styles.css'; +import {DisposableList} from '@techniker-me/tools'; -const websocket = new WebSocket('ws://localhost:3000/ws'); +const disposables = new DisposableList(); +let websocket = new WebSocketConnection('ws://' + window.location.hostname + ':3000/ws'); + +let username = ''; + +const appContainer = document.getElementById('app') as HTMLDivElement; +const chatContainer = document.createElement('div'); +chatContainer.id = 'chat-container'; + +const websocketStatusContainer = document.createElement('div'); +websocketStatusContainer.id = 'websocket-status-container'; + +const rttContainer = document.createElement('div'); +rttContainer.id = 'rtt-container'; +rttContainer.innerHTML = `RTT: [0] milliseconds`; + +const messagesElement = document.createElement('div'); +messagesElement.id = 'messages-container'; + +const messageInput = document.createElement('input'); +messageInput.type = 'text'; +messageInput.disabled = true; + +const actionButton = document.createElement('button'); +actionButton.innerText = 'Set your username!'; + +disposables.add(websocket.on('message', (message: any) => { + if (message.pong) { + console.log('[WebSocket] Received message', message); + // Use server-calculated RTT + const rtt = message.pong.rtt || 0; + rttContainer.innerHTML = `RTT: [${rtt.toFixed(2)}] ms`; + } else { + messagesElement.innerHTML += `
[${new Date(message.sentAt).toLocaleString("en-US", {timeStyle: 'short'})}]${message.message.author}: ${message.message.payload}
`; + } +})); +disposables.add(websocket); + +chatContainer.append(websocketStatusContainer); +chatContainer.append(rttContainer); +chatContainer.append(messagesElement); +chatContainer.append(messageInput); +chatContainer.append(actionButton); + +appContainer.append(chatContainer); +actionButton.onclick = () => { + const usernamePrompt = prompt('Enter your name:'); + if (!usernamePrompt) { + return; + } + username = usernamePrompt; + messageInput.disabled = false; + messageInput.placeholder = 'Enter a message...'; + actionButton.innerText = 'Send message!'; + actionButton.onclick = sendMessage; +} + +function sendMessage() { + console.log('[actionButton] Set username'); + if (!messageInput.value) { + return; + } + + websocket.sendRequest('message', { + author: username, + recipient: 'chat', + payload: messageInput.value + } + ); + + messageInput.value = '' +} + +window.addEventListener('load', () => { + console.log('window load'); + const statusSubscription = websocket.status.subscribe(status => { + websocketStatusContainer.innerHTML = `

WebSocket Status [${WebSocketConnectionStatus[status]}]

`; + }); + + disposables.add(statusSubscription); + + window.addEventListener('beforeunload', disposables.dispose.bind(disposables)); +}); diff --git a/Web/WebSocket/websocket/frontend/src/messages/MessageFactory.ts b/Web/WebSocket/websocket/frontend/src/messages/MessageFactory.ts new file mode 100644 index 0000000..f32c8ad --- /dev/null +++ b/Web/WebSocket/websocket/frontend/src/messages/MessageFactory.ts @@ -0,0 +1,44 @@ +export class Message { + private readonly _author: string; + private readonly _recipient: string; + private readonly _payload: T + + constructor(author, recipient, payload) { + this._author = author; + this._recipient = recipient; + this._payload = payload; + } + + get author(): string { + return this._author; + } + + get recipient(): string { + return this._recipient; + } + + get payload(): T { + return this._payload; + } +} + +export type CreateMessageParameters = { + author: string; + recipient: string; + payload: T +} + +export default class MessageFactory { + public static createMessage({ + author, + recipient, + payload + }: CreateMessageParameters): Message { + + return new Message(author, recipient, payload); + } + + private constructor() { + throw new Error('MessageFactory is a static class that may not be instantiated'); + } +} diff --git a/Web/WebSocket/websocket/frontend/src/styles.css b/Web/WebSocket/websocket/frontend/src/styles.css new file mode 100644 index 0000000..7981b65 --- /dev/null +++ b/Web/WebSocket/websocket/frontend/src/styles.css @@ -0,0 +1,44 @@ +body { + margin: 0; + padding: 0; +} + +#app { + display: flex; + flex-direction: column; + width: 100vw; + height: 100vh; +} + +#websocket-status-container { + text-align: center; + margin: auto; +} + +#rtt-container { + text-align: center; + margin: auto; +} + +#chat-container { + width: 500px; + height: 300px; + margin: auto; + display: flex; + flex-direction: column; +} + +#messages-container { + width: 80%; + height: 600px; + margin: auto; + border: 2px solid black; + overflow-y: auto; +} + +#chat-container input, button { + width: 79%; + margin: auto; + margin-top: 0; + padding: 0; +} diff --git a/Web/WebSocket/websocket/frontend/src/websockets/WebSocketConnection.ts b/Web/WebSocket/websocket/frontend/src/websockets/WebSocketConnection.ts new file mode 100644 index 0000000..fe1f69d --- /dev/null +++ b/Web/WebSocket/websocket/frontend/src/websockets/WebSocketConnection.ts @@ -0,0 +1,101 @@ +import {Subject, ReadOnlySubject, Disposable, DisposableList, EventPublisher} from '@techniker-me/tools'; +import type {Milliseconds} from '../../types/Units'; +import {WebSocketConnectionStatus} from './WebSocketConnectionStatus'; + +const pingIntervalDuration: Milliseconds = 2000; + +export default class WebSocketConnection extends EventPublisher { + private readonly _connectionDisposables = new DisposableList(); + private readonly _status: Subject = new Subject(WebSocketConnectionStatus.Closed); + private readonly _readOnlyStatus: ReadOnlySubject = new ReadOnlySubject(this._status); + private readonly _socket: WebSocket; + + constructor(url: string) { + super(); + this._socket = new WebSocket(url); + + this.initialize(this._socket); + } + + get status(): ReadOnlySubject { + return this._readOnlyStatus; + } + + public on(event: string, handler: (...args: unknown[]) => void): Disposable { + return super.subscribe(event, handler); + } + + public sendRequest(name: string, payload?: T) { + try { + const payloadStringified = JSON.stringify({ + sentAt: Date.now(), + [name]: payload + }); + + this._socket.send(payloadStringified); + } catch (error){ + console.error('[WebSocket] Error sending request [%o]', error); + } + } + + public dispose(): void { + this._connectionDisposables.dispose(); + super.dispose(); + } + + private initialize(socket: WebSocket) { + const websocketStart = performance.now(); + let pingIntervalId; + + pingIntervalId = window.setInterval(() => { + this.sendRequest('ping', {sentAt: Date.now()}); + }, pingIntervalDuration); + + this.setStatus(WebSocketConnectionStatus.Connecting); + + socket.onerror = error => { + this.setStatus(WebSocketConnectionStatus.Error); + window.clearInterval(pingIntervalId); + console.error('[WebSocket] Error [%o]', error); + }; + socket.onopen = () => { + this.setStatus(WebSocketConnectionStatus.Open); + + const websocketEnd = performance.now(); + + console.log(`[WebSocket] Connection time [${(websocketEnd - websocketStart)}] milliseconds`); + }; + socket.onclose = () => { + this.setStatus(WebSocketConnectionStatus.Closed); + }; + socket.onmessage = (messageEvent) => { + try { + const messageData = JSON.parse(messageEvent.data); + super.publish('message', messageData); + } catch (error) { + console.log('[WebSocket] Received non-JSON message [%s]', messageEvent.data); + console.error('[WebSocket] Error parsing message [%o]', error); + } + }; + + this._connectionDisposables.add(new Disposable(() => { + window.clearInterval(pingIntervalId); + })); + this._connectionDisposables.add(new Disposable(() => { + socket.onerror = null; + socket.onopen = null; + socket.onclose = null; + socket.onmessage = null; + })); + this._connectionDisposables.add(new Disposable(() => { + // @ts-expect-error Disposing the subject + this._status.value = null; + })); + this._connectionDisposables.add(new Disposable(socket.close.bind(socket))); + + } + + private setStatus(status: WebSocketConnectionStatus) { + this._status.value = status; + } +} diff --git a/Web/WebSocket/websocket/frontend/src/websockets/WebSocketConnectionStatus.ts b/Web/WebSocket/websocket/frontend/src/websockets/WebSocketConnectionStatus.ts new file mode 100644 index 0000000..22e3c6d --- /dev/null +++ b/Web/WebSocket/websocket/frontend/src/websockets/WebSocketConnectionStatus.ts @@ -0,0 +1,8 @@ +export enum WebSocketConnectionStatus { + Connecting = 0, + Open = 1, + Closing = 2, + Closed = 3, + Error = 4 + } + \ No newline at end of file diff --git a/Web/WebSocket/websocket/frontend/tsconfig.json b/Web/WebSocket/websocket/frontend/tsconfig.json index 4ba8dd9..1a3882d 100644 --- a/Web/WebSocket/websocket/frontend/tsconfig.json +++ b/Web/WebSocket/websocket/frontend/tsconfig.json @@ -18,7 +18,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, + "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, diff --git a/Web/WebSocket/websocket/frontend/types/Units.ts b/Web/WebSocket/websocket/frontend/types/Units.ts new file mode 100644 index 0000000..cf3f681 --- /dev/null +++ b/Web/WebSocket/websocket/frontend/types/Units.ts @@ -0,0 +1,2 @@ +export type Milliseconds = number; +export type Seconds = number; diff --git a/Web/WebSocket/websocket/server/src/index.ts b/Web/WebSocket/websocket/server/src/index.ts index 9360c04..8012dc8 100644 --- a/Web/WebSocket/websocket/server/src/index.ts +++ b/Web/WebSocket/websocket/server/src/index.ts @@ -1,9 +1,10 @@ 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 from './net/websocket/WebSocketServer'; +import WebSocketServer, {ExtendedWebSocket} from './net/websocket/WebSocketServer'; const logger = LoggerFactory.getLogger('Server'); const healthCheck = new HealthCheck(); @@ -12,12 +13,45 @@ 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(); + 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) { @@ -29,18 +63,16 @@ function pongDelegate(connection: ExtendedWebSocket, message: Buffer) { } httpServer -.start() -.then(() => { - const server = httpServer.getServer(); + .start() + .then(() => { + const server = httpServer.getServer(); + if (server) { + const websocketServer = new WebSocketServer(server, {path: '/ws'}); - 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)); + 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)); diff --git a/Web/WebSocket/websocket/server/src/net/http/HttpServer.ts b/Web/WebSocket/websocket/server/src/net/http/HttpServer.ts index b95862f..3e3f6a1 100644 --- a/Web/WebSocket/websocket/server/src/net/http/HttpServer.ts +++ b/Web/WebSocket/websocket/server/src/net/http/HttpServer.ts @@ -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, { RequestHandler } from 'express'; +import express, {RequestHandler} from 'express'; import morgan from 'morgan'; import favicon from 'serve-favicon'; import bodyParser from 'body-parser'; @@ -155,7 +155,6 @@ export default class HttpServer { this._eventEmitter.on(event, handler); } - public getServer(): Nullable { return this._server.value; } @@ -351,10 +350,9 @@ export default class HttpServer { const registerRoutes = (method: string, routes: Record): void => { for (const route of Object.entries(routes)) { - - if (!route) { - continue; - } + if (!route) { + continue; + } const [name, handler] = route; @@ -370,7 +368,7 @@ export default class HttpServer { 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()); diff --git a/Web/WebSocket/websocket/server/src/net/websocket/WebSocketServer.ts b/Web/WebSocket/websocket/server/src/net/websocket/WebSocketServer.ts index 42a4d44..e77bd9b 100644 --- a/Web/WebSocket/websocket/server/src/net/websocket/WebSocketServer.ts +++ b/Web/WebSocket/websocket/server/src/net/websocket/WebSocketServer.ts @@ -6,7 +6,7 @@ import Assert from '../../lang/Assert'; import Strings from '../../lang/Strings'; import WebsocketExtensions from 'websocket-extensions'; -interface ExtendedWebSocket extends WebSocket { +export interface ExtendedWebSocket extends WebSocket { id: string; remoteAddress: string; isOpen(): boolean; @@ -124,7 +124,7 @@ export default class WebSocketServer { let closed = false; try { - const extendedConnection = connection as ExtendedWebSocket; + const extendedConnection = connection as ExtendedWebSocket; extendedConnection.id = Strings.randomString(connectionIdLength); extendedConnection.remoteAddress = getRemoteAddress(extendedConnection, req); extendedConnection.isOpen = () => connection.readyState === ws.OPEN;