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;