basic chat
This commit is contained in:
3
Web/WebSocket/websocket/frontend/.npmrc
Normal file
3
Web/WebSocket/websocket/frontend/.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
save-exact=true
|
||||||
|
package-lock=false
|
||||||
|
@techniker-me:registry=https://registry-node.techniker.me
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
44
Web/WebSocket/websocket/frontend/src/styles.css
Normal file
44
Web/WebSocket/websocket/frontend/src/styles.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export enum WebSocketConnectionStatus {
|
||||||
|
Connecting = 0,
|
||||||
|
Open = 1,
|
||||||
|
Closing = 2,
|
||||||
|
Closed = 3,
|
||||||
|
Error = 4
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
2
Web/WebSocket/websocket/frontend/types/Units.ts
Normal file
2
Web/WebSocket/websocket/frontend/types/Units.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export type Milliseconds = number;
|
||||||
|
export type Seconds = number;
|
||||||
@@ -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) {
|
if (server) {
|
||||||
const websocketServer = new WebSocketServer(server, {path: '/ws'});
|
const websocketServer = new WebSocketServer(server, {path: '/ws'});
|
||||||
|
|
||||||
websocketServer.start(connectDelegate, requestDelegate, disconnectDelegate, pongDelegate);
|
websocketServer.start(connectDelegate, requestDelegate, disconnectDelegate, pongDelegate);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
console.error('[Server] Failed to get HTTP server instance');
|
console.error('[Server] Failed to get HTTP server instance');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.log('[Server] Server failed to start:', err));
|
.catch(err => console.log('[Server] Server failed to start:', err));
|
||||||
|
|||||||
@@ -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,7 +350,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user