basic chat

This commit is contained in:
2025-09-28 14:55:09 -04:00
parent 9306632cc1
commit 0cc0ce13e7
12 changed files with 347 additions and 25 deletions

View File

@@ -0,0 +1,3 @@
save-exact=true
package-lock=false
@techniker-me:registry=https://registry-node.techniker.me

View File

@@ -11,5 +11,9 @@
"devDependencies": { "devDependencies": {
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.1.7" "vite": "^7.1.7"
},
"dependencies": {
"@techniker-me/logger": "0.0.15",
"@techniker-me/tools": "2025.0.16"
} }
} }

View File

@@ -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: [<span id="rtt-value">0</span>] 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: [<span id="rtt-value">${rtt.toFixed(2)}</span>] ms`;
} else {
messagesElement.innerHTML += `<div class="message"> [${new Date(message.sentAt).toLocaleString("en-US", {timeStyle: 'short'})}]${message.message.author}: ${message.message.payload}</div>`;
}
}));
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 = `<h4 id="websocket-status">WebSocket Status [${WebSocketConnectionStatus[status]}]</h4>`;
});
disposables.add(statusSubscription);
window.addEventListener('beforeunload', disposables.dispose.bind(disposables));
});

View File

@@ -0,0 +1,44 @@
export class Message<T> {
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<T> = {
author: string;
recipient: string;
payload: T
}
export default class MessageFactory {
public static createMessage<T>({
author,
recipient,
payload
}: CreateMessageParameters<T>): Message<T> {
return new Message(author, recipient, payload);
}
private constructor() {
throw new Error('MessageFactory is a static class that may not be instantiated');
}
}

View File

@@ -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;
}

View File

@@ -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<WebSocketConnectionStatus> = new Subject(WebSocketConnectionStatus.Closed);
private readonly _readOnlyStatus: ReadOnlySubject<WebSocketConnectionStatus> = new ReadOnlySubject(this._status);
private readonly _socket: WebSocket;
constructor(url: string) {
super();
this._socket = new WebSocket(url);
this.initialize(this._socket);
}
get status(): ReadOnlySubject<WebSocketConnectionStatus> {
return this._readOnlyStatus;
}
public on(event: string, handler: (...args: unknown[]) => void): Disposable {
return super.subscribe(event, handler);
}
public sendRequest<T>(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;
}
}

View File

@@ -0,0 +1,8 @@
export enum WebSocketConnectionStatus {
Connecting = 0,
Open = 1,
Closing = 2,
Closed = 3,
Error = 4
}

View File

@@ -18,7 +18,7 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },

View File

@@ -0,0 +1,2 @@
export type Milliseconds = number;
export type Seconds = number;

View File

@@ -1,9 +1,10 @@
import path from 'node:path'; import path from 'node:path';
import {IncomingMessage} from 'node:http';
import {LoggerFactory} from '@techniker-me/logger'; import {LoggerFactory} from '@techniker-me/logger';
import HttpServer from './net/http/HttpServer'; import HttpServer from './net/http/HttpServer';
import HealthCheckRoute from './health/HealthCheckRoute'; import HealthCheckRoute from './health/HealthCheckRoute';
import HealthCheck from './health/HealthCheck'; import HealthCheck from './health/HealthCheck';
import WebSocketServer from './net/websocket/WebSocketServer'; import WebSocketServer, {ExtendedWebSocket} from './net/websocket/WebSocketServer';
const logger = LoggerFactory.getLogger('Server'); const logger = LoggerFactory.getLogger('Server');
const healthCheck = new HealthCheck(); 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'), {}); const httpServer = new HttpServer('http', 3000, healthCheckRoute, {}, '', [], path.resolve(process.cwd(), 'assets', 'favicon', 'favicon.ico'), {});
httpServer.on('error', () => logger.error('[HttpServer] Error')); httpServer.on('error', () => logger.error('[HttpServer] Error'));
const sockets = new Map<string, ExtendedWebSocket>();
function connectDelegate(connection: ExtendedWebSocket, req: IncomingMessage) { function connectDelegate(connection: ExtendedWebSocket, req: IncomingMessage) {
console.log('[Server] Connect delegate'); console.log('[Server] Connect delegate');
sockets.set(connection.id, connection);
} }
function requestDelegate(connection: ExtendedWebSocket, message: Buffer) { function requestDelegate(connection: ExtendedWebSocket, message: Buffer) {
console.log('[Server] Request delegate'); 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) { function disconnectDelegate(connection: ExtendedWebSocket, reasonCode: number, description: string) {
@@ -29,18 +63,16 @@ function pongDelegate(connection: ExtendedWebSocket, message: Buffer) {
} }
httpServer httpServer
.start() .start()
.then(() => { .then(() => {
const server = httpServer.getServer(); const server = httpServer.getServer();
if (server) {
const websocketServer = new WebSocketServer(server, {path: '/ws'});
if (server) { websocketServer.start(connectDelegate, requestDelegate, disconnectDelegate, pongDelegate);
const websocketServer = new WebSocketServer(server, {path: '/ws'}); } else {
console.error('[Server] Failed to get HTTP server instance');
websocketServer.start(connectDelegate, requestDelegate, disconnectDelegate, pongDelegate); }
})
} else { .catch(err => console.log('[Server] Server failed to start:', err));
console.error('[Server] Failed to get HTTP server instance');
}
})
.catch(err => console.log('[Server] Server failed to start:', err));

View File

@@ -8,7 +8,7 @@ import type {ILogger} from '@techniker-me/logger';
import {LoggerFactory} from '@techniker-me/logger'; import {LoggerFactory} from '@techniker-me/logger';
import IRoutes from './IRoutes'; import IRoutes from './IRoutes';
import {Subject} from '@techniker-me/tools'; import {Subject} from '@techniker-me/tools';
import express, { RequestHandler } from 'express'; import express, {RequestHandler} from 'express';
import morgan from 'morgan'; import morgan from 'morgan';
import favicon from 'serve-favicon'; import favicon from 'serve-favicon';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
@@ -155,7 +155,6 @@ export default class HttpServer {
this._eventEmitter.on(event, handler); this._eventEmitter.on(event, handler);
} }
public getServer(): Nullable<Server> { public getServer(): Nullable<Server> {
return this._server.value; return this._server.value;
} }
@@ -351,10 +350,9 @@ export default class HttpServer {
const registerRoutes = (method: string, routes: Record<string, RequestHandler>): void => { const registerRoutes = (method: string, routes: Record<string, RequestHandler>): void => {
for (const route of Object.entries(routes)) { for (const route of Object.entries(routes)) {
if (!route) {
if (!route) { continue;
continue; }
}
const [name, handler] = route; const [name, handler] = route;
@@ -370,7 +368,7 @@ export default class HttpServer {
this._logger.debug(`Registering [${method}] route [${name}] handler`); this._logger.debug(`Registering [${method}] route [${name}] handler`);
app[method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete'](name, handler); app[method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete'](name, handler);
} }
} };
registerRoutes('GET', this._routes.getGETRoutes()); registerRoutes('GET', this._routes.getGETRoutes());
registerRoutes('POST', this._routes.getPOSTRoutes()); registerRoutes('POST', this._routes.getPOSTRoutes());

View File

@@ -6,7 +6,7 @@ import Assert from '../../lang/Assert';
import Strings from '../../lang/Strings'; import Strings from '../../lang/Strings';
import WebsocketExtensions from 'websocket-extensions'; import WebsocketExtensions from 'websocket-extensions';
interface ExtendedWebSocket extends WebSocket { export interface ExtendedWebSocket extends WebSocket {
id: string; id: string;
remoteAddress: string; remoteAddress: string;
isOpen(): boolean; isOpen(): boolean;
@@ -124,7 +124,7 @@ export default class WebSocketServer {
let closed = false; let closed = false;
try { try {
const extendedConnection = connection as ExtendedWebSocket; const extendedConnection = connection as ExtendedWebSocket;
extendedConnection.id = Strings.randomString(connectionIdLength); extendedConnection.id = Strings.randomString(connectionIdLength);
extendedConnection.remoteAddress = getRemoteAddress(extendedConnection, req); extendedConnection.remoteAddress = getRemoteAddress(extendedConnection, req);
extendedConnection.isOpen = () => connection.readyState === ws.OPEN; extendedConnection.isOpen = () => connection.readyState === ws.OPEN;