diff --git a/frontend-web-vanilla/index.html b/frontend-web-vanilla/index.html
index f3fda4f..213d3e2 100644
--- a/frontend-web-vanilla/index.html
+++ b/frontend-web-vanilla/index.html
@@ -14,14 +14,20 @@
-
+
+
+
+
Signaling State: None
+
ICE Connection State: None
+
ICE Gathering State: None
+
Connection State: None
Remote
-
+
diff --git a/frontend-web-vanilla/package-lock.json b/frontend-web-vanilla/package-lock.json
index d2bdbb7..fb0f46e 100644
--- a/frontend-web-vanilla/package-lock.json
+++ b/frontend-web-vanilla/package-lock.json
@@ -8,6 +8,7 @@
"name": "frontend-web-vanilla",
"version": "0.0.0",
"dependencies": {
+ "@techniker-me/logger": "^0.0.15",
"@techniker-me/tools": "^2025.0.16"
},
"devDependencies": {
@@ -576,6 +577,14 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@techniker-me/logger": {
+ "version": "0.0.15",
+ "resolved": "https://npm.techniker.me/@techniker-me/logger/-/logger-0.0.15.tgz",
+ "integrity": "sha512-+6aB39lWTO2RDQLse2nZqfTXa7Kp78K7Xy7zobwBQlg01jR4zKmQAMkjQ4iduvnQYEU+1F2k6FDMco2E0mWZ4w==",
+ "dependencies": {
+ "@techniker-me/tools": "2025.0.16"
+ }
+ },
"node_modules/@techniker-me/tools": {
"version": "2025.0.16",
"resolved": "https://npm.techniker.me/@techniker-me/tools/-/tools-2025.0.16.tgz",
diff --git a/frontend-web-vanilla/package.json b/frontend-web-vanilla/package.json
index 3af3e26..a63e146 100644
--- a/frontend-web-vanilla/package.json
+++ b/frontend-web-vanilla/package.json
@@ -26,6 +26,7 @@
"vite": "npm:rolldown-vite@7.2.7"
},
"dependencies": {
+ "@techniker-me/logger": "^0.0.15",
"@techniker-me/tools": "^2025.0.16"
}
}
diff --git a/frontend-web-vanilla/src/PeerConnection.ts b/frontend-web-vanilla/src/PeerConnection.ts
new file mode 100644
index 0000000..011e6e3
--- /dev/null
+++ b/frontend-web-vanilla/src/PeerConnection.ts
@@ -0,0 +1,170 @@
+import {Subject, ReadOnlySubject, Disposable, DisposableList, EventPublisher, type IEvent, type IDisposable} from '@techniker-me/tools';
+import type { IPeerConnectionOperations } from './interfaces/IPeerConnectionOperations';
+
+export default class PeerConnection implements IPeerConnectionOperations {
+ private readonly _disposables: DisposableList = new DisposableList();
+ private readonly _peerConnection: RTCPeerConnection;
+ private readonly _signalingState: Subject;
+ private readonly _connectionState: Subject;
+ private readonly _iceGatheringState: Subject;
+ private readonly _iceConnectionState: Subject;
+ private readonly _readOnlySignalingState: ReadOnlySubject;
+ private readonly _readOnlyConnectionState: ReadOnlySubject;
+ private readonly _readOnlyIceGatheringState: ReadOnlySubject;
+ private readonly _readOnlyIceConnectionState: ReadOnlySubject;
+ private readonly _eventEmitter: EventPublisher = new EventPublisher();
+
+ constructor(configuration: RTCConfiguration) {
+ this._peerConnection = new RTCPeerConnection(configuration);
+ this._signalingState = new Subject(this._peerConnection.signalingState);
+ this._connectionState = new Subject(this._peerConnection.connectionState);
+ this._iceGatheringState = new Subject(this._peerConnection.iceGatheringState);
+ this._iceConnectionState = new Subject(this._peerConnection.iceConnectionState);
+ this._readOnlySignalingState = new ReadOnlySubject(this._signalingState);
+ this._readOnlyConnectionState = new ReadOnlySubject(this._connectionState);
+ this._readOnlyIceGatheringState = new ReadOnlySubject(this._iceGatheringState);
+ this._readOnlyIceConnectionState = new ReadOnlySubject(this._iceConnectionState);
+
+ this.initialize();
+ }
+
+ get signalingState(): ReadOnlySubject {
+ return this._readOnlySignalingState;
+ }
+
+ get connectionState(): ReadOnlySubject {
+ return this._readOnlyConnectionState;
+ }
+
+ get iceGatheringState(): ReadOnlySubject {
+ return this._readOnlyIceGatheringState;
+ }
+
+ get iceConnectionState(): ReadOnlySubject {
+ return this._readOnlyIceConnectionState;
+ }
+
+ get currentRemoteDescription(): RTCSessionDescriptionInit | null {
+ return this._peerConnection.remoteDescription;
+ }
+
+ get currentLocalDescription(): RTCSessionDescriptionInit | null {
+ return this._peerConnection.localDescription;
+ }
+
+ public on(event: string, callback: (event: IEvent) => void | Promise): IDisposable {
+ return this._eventEmitter.subscribe(event, callback);
+ }
+
+ public async createOffer(options?: RTCOfferOptions): Promise {
+ try {
+ const offer = await this._peerConnection.createOffer(options);
+ return offer;
+ } catch (error) {
+ throw new Error(`Failed to create offer [${error instanceof Error ? error.message : String(error)}]`);
+ }
+ }
+
+ public async createAnswer(options?: RTCAnswerOptions): Promise {
+ try {
+ const answer = await this._peerConnection.createAnswer(options);
+
+ return answer;
+ } catch (error) {
+ throw new Error(`Failed to create answer [${error instanceof Error ? error.message : String(error)}]`);
+ }
+ }
+
+ public async setRemoteDescription(description: RTCSessionDescriptionInit): Promise {
+ try {
+ await this._peerConnection.setRemoteDescription(description);
+ } catch (error) {
+ throw new Error(`Failed to set remote description: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+
+ public async setLocalDescription(description: RTCSessionDescriptionInit): Promise {
+ try {
+ await this._peerConnection.setLocalDescription(description);
+ } catch (error) {
+ throw new Error(`Failed to set local description: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+
+ public async addIceCandidate(candidate: RTCIceCandidateInit | RTCIceCandidate): Promise {
+ try {
+ await this._peerConnection.addIceCandidate(candidate);
+ } catch (error) {
+ throw new Error(`Failed to add ICE candidate: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+
+ public addMediaStream(mediaStream: MediaStream): void {
+ mediaStream.getTracks().forEach(track => {
+ this._peerConnection.addTrack(track, mediaStream);
+ });
+ }
+
+ public getSignalingState(): RTCSignalingState {
+ return this._peerConnection.signalingState;
+ }
+
+ public dispose(): void {
+ this._disposables.dispose();
+ }
+
+ private setPeerConnectionStateEventListeners(peerConnection: RTCPeerConnection): void {
+ peerConnection.onsignalingstatechange = () => {
+ this._signalingState.value = peerConnection.signalingState;
+ };
+ peerConnection.onconnectionstatechange = () => {
+ this._connectionState.value = peerConnection.connectionState;
+ };
+ peerConnection.onicegatheringstatechange = () => {
+ this._iceGatheringState.value = peerConnection.iceGatheringState;
+ };
+ peerConnection.oniceconnectionstatechange = () => {
+ this._iceConnectionState.value = peerConnection.iceConnectionState;
+ };
+
+ this._disposables.add(new Disposable(() => {
+ peerConnection.oniceconnectionstatechange = null;
+ peerConnection.onicegatheringstatechange = null;
+ peerConnection.onconnectionstatechange = null;
+ peerConnection.onsignalingstatechange = null;
+ }));
+ }
+
+ private setPeerConnectionEventListeners(peerConnection: RTCPeerConnection): void {
+ const iceCandidateHandler = (event: RTCPeerConnectionIceEvent) => {
+ // Publish ICE candidate event so subscribers can send it via signaling
+ // Note: event.candidate can be null when ICE gathering is complete
+ this._eventEmitter.publish('icecandidate', {
+ type: 'icecandidate',
+ payload: event
+ });
+ };
+ const trackHandler = (event: RTCTrackEvent) => {
+ if (event.track) {
+ // Publish the full RTCTrackEvent so subscribers can access streams, receiver, etc.
+ this._eventEmitter.publish('track', {
+ type: 'track',
+ payload: event
+ });
+ }
+ };
+
+ peerConnection.onicecandidate = iceCandidateHandler;
+ peerConnection.ontrack = trackHandler;
+
+ this._disposables.add(new Disposable(() => {
+ peerConnection.onicecandidate = null;
+ peerConnection.ontrack = null;
+ }));
+ }
+
+ private initialize(): void {
+ this.setPeerConnectionStateEventListeners(this._peerConnection);
+ this.setPeerConnectionEventListeners(this._peerConnection);
+ }
+}
diff --git a/frontend-web-vanilla/src/Signaling.ts b/frontend-web-vanilla/src/Signaling.ts
deleted file mode 100644
index 9b4696d..0000000
--- a/frontend-web-vanilla/src/Signaling.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import {Subject, ReadOnlySubject, Disposable, DisposableList} from '@techniker-me/tools';
-
-export default class Signaling {
- private readonly _peerConnection: RTCPeerConnection;
- private readonly _websocket: WebSocket = new WebSocket('ws://' + window.location.host + '/ws');
- private readonly _disposables: DisposableList = new DisposableList();
-
- private readonly _signalingState: Subject = new Subject('closed');
- private readonly _connectionState: Subject = new Subject('closed');
- private readonly _iceGatheringState: Subject = new Subject('new');
- private readonly _iceConnectionState: Subject = new Subject('new');
- private readonly _readOnlySignalingState: ReadOnlySubject = new ReadOnlySubject(this._signalingState);
- private readonly _readOnlyConnectionState: ReadOnlySubject = new ReadOnlySubject(this._connectionState);
- private readonly _readOnlyIceGatheringState: ReadOnlySubject = new ReadOnlySubject(this._iceGatheringState);
- private readonly _readOnlyIceConnectionState: ReadOnlySubject = new ReadOnlySubject(this._iceConnectionState);
-
- constructor(peerConnection: RTCPeerConnection) {
- this._peerConnection = peerConnection;
- }
-
- public start(): void {
- this.setPeerConnectionStateEventListeners(this._peerConnection);
- this.setPeerConnectionEventListeners(this._peerConnection);
- }
-
- get signalingState(): ReadOnlySubject {
- return this._readOnlySignalingState;
- }
-
- get connectionState(): ReadOnlySubject {
- return this._readOnlyConnectionState;
- }
-
- get iceGatheringState(): ReadOnlySubject {
- return this._readOnlyIceGatheringState;
- }
-
- get iceConnectionState(): ReadOnlySubject {
- return this._readOnlyIceConnectionState;
- }
-
- get currentRemoteDescription(): RTCSessionDescriptionInit | null {
- return this._peerConnection.remoteDescription;
- }
-
- get currentLocalDescription(): RTCSessionDescriptionInit | null {
- return this._peerConnection.localDescription;
- }
-
- public async createOffer(options?: RTCOfferOptions): Promise {
- const offer = await this._peerConnection.createOffer(options);
-
- await this._peerConnection.setLocalDescription(offer);
-
- return offer;
- }
-
- public async createAnswer(options?: RTCOfferOptions): Promise {
- const answer = await this._peerConnection.createAnswer(options);
-
- await this._peerConnection.setLocalDescription(answer);
-
- return answer;
- }
-
- public async setRemoteDescription(description: RTCSessionDescriptionInit): Promise {
- await this._peerConnection.setRemoteDescription(description);
- }
-
- public async setLocalDescription(description: RTCSessionDescriptionInit): Promise {
- await this._peerConnection.setLocalDescription(description);
- }
-
- public dispose(): void {
- this._disposables.dispose();
- }
-
- private setPeerConnectionStateEventListeners(peerConnection: RTCPeerConnection): void {
- peerConnection.onsignalingstatechange = () => {
- this._signalingState.value = peerConnection.signalingState;
- };
- peerConnection.onconnectionstatechange = () => {
- this._connectionState.value = peerConnection.connectionState;
- };
- peerConnection.onicegatheringstatechange = () => {
- this._iceGatheringState.value = peerConnection.iceGatheringState;
- };
- peerConnection.oniceconnectionstatechange = () => {
- this._iceConnectionState.value = peerConnection.iceConnectionState;
- };
-
- this._disposables.add(new Disposable(() => {
- peerConnection.oniceconnectionstatechange = null;
- peerConnection.onicegatheringstatechange = null;
- peerConnection.onconnectionstatechange = null;
- peerConnection.onsignalingstatechange = null;
- }));
- }
-
- private setPeerConnectionEventListeners(peerConnection: RTCPeerConnection): void {
- peerConnection.onicecandidate = (event) => {
- if (event.candidate) {
- this._websocket.send(JSON.stringify({
- type: 'icecandidate',
- payload: event.candidate
- }));
- }
- };
- }
-}
diff --git a/frontend-web-vanilla/src/SignalingServer.ts b/frontend-web-vanilla/src/SignalingServer.ts
new file mode 100644
index 0000000..3471d7f
--- /dev/null
+++ b/frontend-web-vanilla/src/SignalingServer.ts
@@ -0,0 +1,48 @@
+import type { ICandidateMessage, IAnswerMessage, IOfferMessage, IMessage } from "./messaging/IMessage";
+import { MessageKind } from "./messaging/MessageKind";
+import type { ISignalingClient } from "./interfaces/ISignalingClient";
+
+export default class SignalingServer implements ISignalingClient {
+ private readonly _webSocket: WebSocket;
+
+ constructor(webSocket: WebSocket) {
+ this._webSocket = webSocket;
+ }
+
+ public on(kind: MessageKind, callback: (message: IMessage) => Promise): void {
+ this._webSocket.addEventListener('message', (event) => {
+ const message = JSON.parse(event.data) as IMessage;
+
+ if (message.type === kind) {
+ callback(message);
+ }
+ });
+ }
+
+ public async sendOffer(offer: RTCSessionDescriptionInit): Promise {
+ const message: IOfferMessage = {
+ type: MessageKind.Offer,
+ payload: offer
+ };
+
+ this._webSocket.send(JSON.stringify(message));
+ }
+
+ public async sendAnswer(answer: RTCSessionDescriptionInit): Promise {
+ const message: IAnswerMessage = {
+ type: MessageKind.Answer,
+ payload: answer
+ };
+
+ this._webSocket.send(JSON.stringify(message));
+ }
+
+ public async sendCandidate(candidate: RTCIceCandidateInit): Promise {
+ const message: ICandidateMessage = {
+ type: MessageKind.Candidate,
+ payload: candidate
+ };
+
+ this._webSocket.send(JSON.stringify(message));
+ }
+}
\ No newline at end of file
diff --git a/frontend-web-vanilla/src/User.ts b/frontend-web-vanilla/src/User.ts
new file mode 100644
index 0000000..2a73012
--- /dev/null
+++ b/frontend-web-vanilla/src/User.ts
@@ -0,0 +1,75 @@
+import type {ILogger} from '@techniker-me/logger';
+import {LoggerFactory} from '@techniker-me/logger';
+import {ReadOnlySubject, Subject, type IEvent} from '@techniker-me/tools';
+import PeerConnection from './PeerConnection';
+import type { ISignalingClient } from './interfaces/ISignalingClient';
+
+export default class User {
+ private readonly _logger: ILogger = LoggerFactory.getLogger('User');
+ private readonly _localVideoElement: HTMLVideoElement;
+ private readonly _remoteVideoElement: HTMLVideoElement;
+ private readonly _peerConnection: PeerConnection;
+ private readonly _signalingClient: ISignalingClient;
+ private readonly _mediaStream: Subject = new Subject(null);
+ private readonly _readOnlyMediaStream: ReadOnlySubject = new ReadOnlySubject(this._mediaStream);
+
+ constructor(localVideoElement: HTMLVideoElement, remoteVideoElement: HTMLVideoElement, peerConnection: PeerConnection, signalingClient: ISignalingClient) {
+ this._localVideoElement = localVideoElement;
+ this._remoteVideoElement = remoteVideoElement;
+ this._peerConnection = peerConnection;
+ this._signalingClient = signalingClient;
+ this.initialize();
+ }
+
+ get videoElement(): HTMLVideoElement {
+ return this._localVideoElement;
+ }
+
+ get peerConnection(): PeerConnection {
+ return this._peerConnection;
+ }
+
+ get mediaStream(): ReadOnlySubject {
+ return this._readOnlyMediaStream;
+ }
+
+ public async startLocalMedia(): Promise {
+ const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
+
+ this._peerConnection.addMediaStream(mediaStream);
+
+ this._mediaStream.value = mediaStream;
+ this._localVideoElement.srcObject = mediaStream;
+ this._logger.info('Local media started');
+ }
+
+ public async stopLocalMedia(): Promise {
+ this._mediaStream.value?.getTracks().forEach(track => {
+ track.stop();
+ });
+ this._mediaStream.value = null;
+ this._localVideoElement.srcObject = null;
+ }
+
+ private initialize(): void {
+ this._peerConnection.on('icecandidate', (event: IEvent) => {
+ // Only send candidate if it exists (null means ICE gathering is complete)
+ if (event.payload?.candidate) {
+ this._signalingClient.sendCandidate(event.payload.candidate);
+ }
+ });
+ this._peerConnection.on('track', (event: IEvent) => {
+ if (!event.payload) {
+ return;
+ }
+
+ console.log('Track event', event);
+
+ if (event.payload.streams?.length === 0) {
+ return;
+ }
+
+ this._remoteVideoElement.srcObject = event.payload.streams[0];
+ });
+ }
+}
\ No newline at end of file
diff --git a/frontend-web-vanilla/src/call/CallCoordinator.ts b/frontend-web-vanilla/src/call/CallCoordinator.ts
new file mode 100644
index 0000000..0ed4b16
--- /dev/null
+++ b/frontend-web-vanilla/src/call/CallCoordinator.ts
@@ -0,0 +1,120 @@
+import { Caller } from './Caller';
+import { Callee } from './Callee';
+import type { ISignalingClient } from '../interfaces/ISignalingClient';
+import type { IPeerConnectionOperations } from '../interfaces/IPeerConnectionOperations';
+import { MessageKind } from '../messaging/MessageKind';
+import type { IMessage } from '../messaging/IMessage';
+import type { ILogger } from '@techniker-me/logger';
+import { LoggerFactory } from '@techniker-me/logger';
+
+/**
+ * CallCoordinator - coordinates between Caller and Callee roles
+ * Follows Single Responsibility Principle - manages call flow coordination
+ * Follows Open/Closed Principle - can be extended without modification
+ */
+export class CallCoordinator {
+ private readonly _logger: ILogger = LoggerFactory.getLogger('CallCoordinator');
+ private readonly _caller: Caller;
+ private readonly _callee: Callee;
+ private readonly _signalingClient: ISignalingClient;
+ private readonly _peerConnection: IPeerConnectionOperations;
+ private _lastReceivedOffer: RTCSessionDescriptionInit | null = null;
+
+ constructor(
+ signalingClient: ISignalingClient,
+ peerConnection: IPeerConnectionOperations
+ ) {
+ this._signalingClient = signalingClient;
+ this._peerConnection = peerConnection;
+ this._caller = new Caller(signalingClient, peerConnection);
+ this._callee = new Callee(signalingClient, peerConnection);
+
+ this.setupMessageHandlers();
+ }
+
+ /**
+ * Set this instance as the caller and initiate a call
+ */
+ public async initiateCall(): Promise {
+ await this._caller.createAndSendOffer();
+ }
+
+ /**
+ * Manually create and send an answer (for callee)
+ * This is called when the callee clicks the "Send Answer" button
+ * Following the sequence diagram:
+ * 1. Create Answer (remote description already set when offer was received)
+ * 2. Set local description to Answer
+ * 3. Send Answer to signaling server
+ */
+ public async createAndSendAnswer(): Promise {
+ const currentState = this._peerConnection.getSignalingState();
+
+ // Verify we have a remote offer
+ if (currentState !== 'have-remote-offer' && currentState !== 'have-remote-pranswer') {
+ throw new Error('Cannot send answer: no remote offer received. Current state: ' + currentState);
+ }
+
+ if (!this._lastReceivedOffer) {
+ throw new Error('Cannot send answer: no offer stored');
+ }
+
+ this._logger.info('Creating and sending answer manually...');
+
+ // Step 1: Create Answer (remote description was already set when offer was received)
+ const answer = await this._peerConnection.createAnswer();
+ this._logger.info('Answer created');
+
+ // Step 2: Set local description to Answer
+ await this._peerConnection.setLocalDescription(answer);
+ this._logger.info('Local description set to answer');
+
+ // Step 3: Send Answer to signaling server
+ await this._signalingClient.sendAnswer(answer);
+ this._logger.info('Answer sent to signaling server');
+ }
+
+ /**
+ * Setup message handlers for offer and answer
+ */
+ private setupMessageHandlers(): void {
+ // Handle incoming offers (when acting as callee)
+ // Only set remote description - don't automatically create/send answer
+ // The answer will be sent when the user clicks the "Send Answer" button
+ this._signalingClient.on(
+ MessageKind.Offer,
+ async (message: IMessage) => {
+ // Only handle offer if we're not the caller (don't have local offer)
+ const currentState = this._peerConnection.getSignalingState();
+ if (currentState === 'have-local-offer' || currentState === 'have-local-pranswer') {
+ this._logger.info('Already have local offer, ignoring incoming offer');
+ return;
+ }
+
+ this._logger.info('Received offer, setting remote description (waiting for manual answer)');
+ this._lastReceivedOffer = message.payload;
+
+ // Only set the remote description - don't create/send answer yet
+ // This allows the callee to manually click "Send Answer" button
+ await this._peerConnection.setRemoteDescription(message.payload);
+ }
+ );
+
+ // Handle incoming answers (when acting as caller)
+ this._signalingClient.on(
+ MessageKind.Answer,
+ async (message: IMessage) => {
+ // Only handle answer if we're the caller (have local offer)
+ const currentState = this._peerConnection.getSignalingState();
+ if (currentState !== 'have-local-offer' && currentState !== 'have-local-pranswer') {
+ this._logger.info('Not in caller state, ignoring incoming answer');
+ return;
+ }
+
+ this._logger.info('Received answer, acting as caller');
+ await this._caller.handleAnswer(message.payload);
+ }
+ );
+ }
+}
+
diff --git a/frontend-web-vanilla/src/call/Callee.ts b/frontend-web-vanilla/src/call/Callee.ts
new file mode 100644
index 0000000..f6c35e1
--- /dev/null
+++ b/frontend-web-vanilla/src/call/Callee.ts
@@ -0,0 +1,57 @@
+import type { ISignalingClient } from '../interfaces/ISignalingClient';
+import type { IPeerConnectionOperations } from '../interfaces/IPeerConnectionOperations';
+import type { ILogger } from '@techniker-me/logger';
+import { LoggerFactory } from '@techniker-me/logger';
+
+/**
+ * Callee class - responsible for receiving and responding to WebRTC calls
+ * Follows Single Responsibility Principle - only handles answer creation flow
+ * Follows Dependency Inversion Principle - depends on abstractions (interfaces)
+ */
+export class Callee {
+ private readonly _logger: ILogger = LoggerFactory.getLogger('Callee');
+ private readonly _signalingClient: ISignalingClient;
+ private readonly _peerConnection: IPeerConnectionOperations;
+
+ constructor(
+ signalingClient: ISignalingClient,
+ peerConnection: IPeerConnectionOperations
+ ) {
+ this._signalingClient = signalingClient;
+ this._peerConnection = peerConnection;
+ }
+
+ /**
+ * Handle receiving an offer from the caller
+ * Following the sequence diagram:
+ * 1. Set remote description to offer
+ * 2. Create Answer
+ * 3. Set local description to Answer
+ * 4. Send Answer to signaling server
+ */
+ public async handleOffer(offer: RTCSessionDescriptionInit): Promise {
+ try {
+ this._logger.info('Handling offer from caller...');
+
+ // Step 1: Set remote description to offer
+ await this._peerConnection.setRemoteDescription(offer);
+ this._logger.info('Remote description set to offer');
+
+ // Step 2: Create Answer
+ const answer = await this._peerConnection.createAnswer();
+ this._logger.info('Answer created');
+
+ // Step 3: Set local description to Answer
+ await this._peerConnection.setLocalDescription(answer);
+ this._logger.info('Local description set to answer');
+
+ // Step 4: Send Answer to signaling server
+ await this._signalingClient.sendAnswer(answer);
+ this._logger.info('Answer sent to signaling server');
+ } catch (error) {
+ this._logger.error('Failed to handle offer', error);
+ throw error;
+ }
+ }
+}
+
diff --git a/frontend-web-vanilla/src/call/Caller.ts b/frontend-web-vanilla/src/call/Caller.ts
new file mode 100644
index 0000000..e73fd22
--- /dev/null
+++ b/frontend-web-vanilla/src/call/Caller.ts
@@ -0,0 +1,67 @@
+import type { ISignalingClient } from '../interfaces/ISignalingClient';
+import type { IPeerConnectionOperations } from '../interfaces/IPeerConnectionOperations';
+import type { ILogger } from '@techniker-me/logger';
+import { LoggerFactory } from '@techniker-me/logger';
+
+/**
+ * Caller class - responsible for initiating WebRTC calls
+ * Follows Single Responsibility Principle - only handles offer creation flow
+ * Follows Dependency Inversion Principle - depends on abstractions (interfaces)
+ */
+export class Caller {
+ private readonly _logger: ILogger = LoggerFactory.getLogger('Caller');
+ private readonly _signalingClient: ISignalingClient;
+ private readonly _peerConnection: IPeerConnectionOperations;
+
+ constructor(
+ signalingClient: ISignalingClient,
+ peerConnection: IPeerConnectionOperations
+ ) {
+ this._signalingClient = signalingClient;
+ this._peerConnection = peerConnection;
+ }
+
+ /**
+ * Create and send an offer following the sequence diagram:
+ * 1. Create Offer
+ * 2. Set local description to offer
+ * 3. Send Offer to signaling server
+ */
+ public async createAndSendOffer(): Promise {
+ try {
+ this._logger.info('Creating offer...');
+
+ // Step 1: Create Offer
+ const offer = await this._peerConnection.createOffer();
+
+ // Step 2: Set local description to offer
+ await this._peerConnection.setLocalDescription(offer);
+ this._logger.info('Local description set to offer');
+
+ // Step 3: Send Offer to signaling server
+ await this._signalingClient.sendOffer(offer);
+ this._logger.info('Offer sent to signaling server');
+ } catch (error) {
+ this._logger.error('Failed to create and send offer', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Handle receiving an answer from the callee
+ * Sets the answer as remote description
+ */
+ public async handleAnswer(answer: RTCSessionDescriptionInit): Promise {
+ try {
+ this._logger.info('Handling answer from callee...');
+
+ // Set Answer as remote description
+ await this._peerConnection.setRemoteDescription(answer);
+ this._logger.info('Answer set as remote description');
+ } catch (error) {
+ this._logger.error('Failed to handle answer', error);
+ throw error;
+ }
+ }
+}
+
diff --git a/frontend-web-vanilla/src/interfaces/IPeerConnectionOperations.ts b/frontend-web-vanilla/src/interfaces/IPeerConnectionOperations.ts
new file mode 100644
index 0000000..f7091b8
--- /dev/null
+++ b/frontend-web-vanilla/src/interfaces/IPeerConnectionOperations.ts
@@ -0,0 +1,31 @@
+/**
+ * Interface for peer connection operations
+ * Follows Interface Segregation Principle - focused interface for WebRTC operations
+ */
+export interface IPeerConnectionOperations {
+ /**
+ * Create a WebRTC offer
+ */
+ createOffer(options?: RTCOfferOptions): Promise;
+
+ /**
+ * Create a WebRTC answer
+ */
+ createAnswer(options?: RTCAnswerOptions): Promise;
+
+ /**
+ * Set the remote description
+ */
+ setRemoteDescription(description: RTCSessionDescriptionInit): Promise;
+
+ /**
+ * Set the local description
+ */
+ setLocalDescription(description: RTCSessionDescriptionInit): Promise;
+
+ /**
+ * Get the current signaling state
+ */
+ getSignalingState(): RTCSignalingState;
+}
+
diff --git a/frontend-web-vanilla/src/interfaces/ISignalingClient.ts b/frontend-web-vanilla/src/interfaces/ISignalingClient.ts
new file mode 100644
index 0000000..9779661
--- /dev/null
+++ b/frontend-web-vanilla/src/interfaces/ISignalingClient.ts
@@ -0,0 +1,29 @@
+import type { IMessage } from '../messaging/IMessage';
+import { MessageKind } from '../messaging/MessageKind';
+
+/**
+ * Interface for signaling client operations
+ * Follows Interface Segregation Principle - focused interface for signaling
+ */
+export interface ISignalingClient {
+ /**
+ * Register a callback for a specific message type
+ */
+ on(kind: MessageKind, callback: (message: IMessage) => Promise): void;
+
+ /**
+ * Send an offer to the signaling server
+ */
+ sendOffer(offer: RTCSessionDescriptionInit): Promise;
+
+ /**
+ * Send an answer to the signaling server
+ */
+ sendAnswer(answer: RTCSessionDescriptionInit): Promise;
+
+ /**
+ * Send an ICE candidate to the signaling server
+ */
+ sendCandidate(candidate: RTCIceCandidateInit): Promise;
+}
+
diff --git a/frontend-web-vanilla/src/main.ts b/frontend-web-vanilla/src/main.ts
index 9e1ed99..767781b 100644
--- a/frontend-web-vanilla/src/main.ts
+++ b/frontend-web-vanilla/src/main.ts
@@ -1,73 +1,112 @@
+import PeerConnection from './PeerConnection';
+import User from './User';
+import SignalingServer from './SignalingServer';
+import { CallCoordinator } from './call/CallCoordinator';
+import { MessageKind } from './messaging/MessageKind';
+import type { IMessage } from './messaging/IMessage';
+import type { ISignalingClient } from './interfaces/ISignalingClient';
+
type Elements = {
localVideo: HTMLVideoElement;
remoteVideo: HTMLVideoElement;
startLocalMediaButton: HTMLButtonElement;
- createAndSendOfferButton: HTMLButtonElement;
- createAndSendAnswerButton: HTMLButtonElement;
+ sendOfferButton: HTMLButtonElement;
+ sendAnswerButton: HTMLButtonElement;
+ signalingStateValue: HTMLSpanElement;
+ iceConnectionStateValue: HTMLSpanElement;
+ iceGatheringStateValue: HTMLSpanElement;
+ connectionStateValue: HTMLSpanElement;
}
-type ApplicationState = {
- localMediaStream: MediaStream | null;
- remoteMediaStream: MediaStream | null;
- peerConnection: RTCPeerConnection;
- offer: RTCSessionDescriptionInit | null;
- answer: RTCSessionDescriptionInit | null;
-};
-
-
const elements: Elements = {
localVideo: document.getElementById('local-video') as HTMLVideoElement,
remoteVideo: document.getElementById('remote-video') as HTMLVideoElement,
startLocalMediaButton: document.getElementById('start-local-media') as HTMLButtonElement,
- createAndSendOfferButton: document.getElementById('create-and-send-offer') as HTMLButtonElement,
- createAndSendAnswerButton: document.getElementById('create-and-send-answer') as HTMLButtonElement
+ sendOfferButton: document.getElementById('send-offer') as HTMLButtonElement,
+ sendAnswerButton: document.getElementById('send-answer') as HTMLButtonElement,
+ signalingStateValue: document.getElementById('signaling-state-value') as HTMLSpanElement,
+ iceConnectionStateValue: document.getElementById('ice-connection-state-value') as HTMLSpanElement,
+ iceGatheringStateValue: document.getElementById('ice-gathering-state-value') as HTMLSpanElement,
+ connectionStateValue: document.getElementById('connection-state-value') as HTMLSpanElement
}
-const state: ApplicationState = {
- localMediaStream: null,
- remoteMediaStream: null,
- peerConnection: new RTCPeerConnection(),
- offer: null,
- answer: null,
-
+// Initialize WebSocket and signaling server
+const websocket = new WebSocket(`ws://${window.location.hostname}:3000/ws`);
+const signalingServer = new SignalingServer(websocket);
+
+// Initialize peer connection configuration
+const peerConnectionConfiguration: RTCConfiguration = {
+ iceServers: [
+ {
+ urls: 'stun:stun.l.google.com:19302'
+ }
+ ]
};
-const actions = {
- getLocalMediaStream: (constraints = {audio: true, video: true}) => navigator.mediaDevices.getUserMedia(constraints),
- setMediaStream: (target: HTMLVideoElement, stream: MediaStream): void => {target.srcObject = stream},
- createOffer: (connection: RTCPeerConnection): Promise => connection.createOffer(),
- createAnswer: (connection: RTCPeerConnection): Promise => connection.createAnswer(),
- enableButton: (button: HTMLButtonElement): void => {button.disabled = false},
- disableButton: (button: HTMLButtonElement): void => {button.disabled = true},
- sendMessage: (message: string): void => {
- // TODO: Implement message sending
- },
- receiveMessage: (message: string): void => {
- // TODO: Implement message receiving
- }
-
-};
+// Initialize peer connection and user
+const peerConnection = new PeerConnection(peerConnectionConfiguration);
+const user = new User(elements.localVideo, elements.remoteVideo, peerConnection, signalingServer as ISignalingClient);
-elements.startLocalMediaButton.addEventListener('click', () => {
- actions.getLocalMediaStream()
- .then(mediaStream => actions.setMediaStream(elements.localVideo, mediaStream))
- .then(() => actions.enableButton(elements.createAndSendOfferButton))
- .then(() => actions.disableButton(elements.startLocalMediaButton))
+// Initialize call coordinator (handles caller/callee logic following SOLID principles)
+const callCoordinator = new CallCoordinator(signalingServer, peerConnection);
+
+// Setup ICE candidate handling
+signalingServer.on(MessageKind.Candidate, async (message: IMessage) => {
+ console.log('Candidate received', message);
+ await peerConnection.addIceCandidate(message.payload);
});
-elements.createAndSendOfferButton.addEventListener('click', () => {
- actions.createOffer(state.peerConnection)
- .then(offer => state.offer = offer)
- .then(() => {
- if (!state.offer) {
- throw new Error('Offer not created');
- }
-
- return state.peerConnection.setLocalDescription(state.offer)
- })
- .then(() => {
-
- })
- .then(() => actions.enableButton(elements.createAndSendAnswerButton))
- .then(() => actions.disableButton(elements.createAndSendOfferButton))
+// Setup UI state subscriptions
+peerConnection.signalingState.subscribe((state) => {
+ elements.signalingStateValue.textContent = state;
+
+ // Enable send answer button when remote offer is received
+ if (state === 'have-remote-offer' || state === 'have-remote-pranswer') {
+ elements.sendAnswerButton.disabled = false;
+ } else if (state === 'stable' || state === 'have-local-offer') {
+ elements.sendAnswerButton.disabled = true;
+ }
+});
+
+peerConnection.iceConnectionState.subscribe((state) => {
+ elements.iceConnectionStateValue.textContent = state;
+});
+
+peerConnection.iceGatheringState.subscribe((state) => {
+ elements.iceGatheringStateValue.textContent = state;
+});
+
+peerConnection.connectionState.subscribe((state) => {
+ elements.connectionStateValue.textContent = state;
+});
+
+// Setup button event handlers
+elements.startLocalMediaButton.addEventListener('click', async () => {
+ await user.startLocalMedia();
+ elements.startLocalMediaButton.disabled = true;
+ elements.sendOfferButton.disabled = false;
+});
+
+elements.sendOfferButton.addEventListener('click', async () => {
+ try {
+ await callCoordinator.initiateCall();
+ elements.sendOfferButton.disabled = true;
+ } catch (error) {
+ console.error('Failed to initiate call', error);
+ }
+});
+
+elements.sendAnswerButton.addEventListener('click', async () => {
+ try {
+ // Start local media if not already started (for callee)
+ if (!user.mediaStream.value) {
+ await user.startLocalMedia();
+ elements.startLocalMediaButton.disabled = true;
+ }
+
+ await callCoordinator.createAndSendAnswer();
+ elements.sendAnswerButton.disabled = true;
+ } catch (error) {
+ console.error('Failed to send answer', error);
+ }
});
\ No newline at end of file
diff --git a/frontend-web-vanilla/src/messaging/IMessage.ts b/frontend-web-vanilla/src/messaging/IMessage.ts
new file mode 100644
index 0000000..5790206
--- /dev/null
+++ b/frontend-web-vanilla/src/messaging/IMessage.ts
@@ -0,0 +1,24 @@
+import type {MessageType} from './MessageKind';
+
+export interface IMessage {
+ type: MessageType;
+ payload: T;
+}
+
+
+export type IOfferMessage = {
+ type: 'offer';
+ payload: RTCSessionDescriptionInit;
+}
+
+export type IAnswerMessage = {
+ type: 'answer';
+ payload: RTCSessionDescriptionInit;
+}
+
+export type ICandidateMessage = {
+ type: 'candidate';
+ payload: RTCIceCandidateInit;
+}
+
+export type Message = IOfferMessage | IAnswerMessage | ICandidateMessage;
\ No newline at end of file
diff --git a/frontend-web-vanilla/src/messaging/MessageKind.ts b/frontend-web-vanilla/src/messaging/MessageKind.ts
new file mode 100644
index 0000000..6ca57ff
--- /dev/null
+++ b/frontend-web-vanilla/src/messaging/MessageKind.ts
@@ -0,0 +1,32 @@
+export enum MessageKind {
+ Offer = 0,
+ Answer = 1,
+ Candidate = 2
+}
+
+export type MessageKindType = 'offer' | 'answer' | 'candidate';
+export type MessageType = MessageKindType;
+
+export class MessageKindMapping {
+ public static convertMessageKindToMessageType(messageKind: MessageKind): MessageType {
+ switch (messageKind) {
+ case MessageKind.Offer:
+ return 'offer';
+ case MessageKind.Answer:
+ return 'answer';
+ case MessageKind.Candidate:
+ return 'candidate';
+ }
+ }
+
+ public static convertMessageTypeToMessageKind(messageType: MessageType): MessageKind {
+ switch (messageType) {
+ case 'offer':
+ return MessageKind.Offer;
+ case 'answer':
+ return MessageKind.Answer;
+ case 'candidate':
+ return MessageKind.Candidate;
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend-web-vanilla/tsconfig.json b/frontend-web-vanilla/tsconfig.json
index 6404455..af16a1e 100644
--- a/frontend-web-vanilla/tsconfig.json
+++ b/frontend-web-vanilla/tsconfig.json
@@ -18,7 +18,7 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
- "erasableSyntaxOnly": true,
+ "erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
diff --git a/signaling/.gitignore b/signaling/.gitignore
new file mode 100644
index 0000000..a14702c
--- /dev/null
+++ b/signaling/.gitignore
@@ -0,0 +1,34 @@
+# dependencies (bun install)
+node_modules
+
+# output
+out
+dist
+*.tgz
+
+# code coverage
+coverage
+*.lcov
+
+# logs
+logs
+_.log
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# caches
+.eslintcache
+.cache
+*.tsbuildinfo
+
+# IntelliJ based IDEs
+.idea
+
+# Finder (MacOS) folder config
+.DS_Store
diff --git a/signaling/README.md b/signaling/README.md
new file mode 100644
index 0000000..9a7e31b
--- /dev/null
+++ b/signaling/README.md
@@ -0,0 +1 @@
+# signaling
diff --git a/signaling/bunfig.toml b/signaling/bunfig.toml
new file mode 100644
index 0000000..e69de29
diff --git a/signaling/package-lock.json b/signaling/package-lock.json
new file mode 100644
index 0000000..5df7b0b
--- /dev/null
+++ b/signaling/package-lock.json
@@ -0,0 +1,75 @@
+{
+ "name": "signaling",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "signaling",
+ "dependencies": {
+ "@techniker-me/logger": "^0.0.15"
+ },
+ "devDependencies": {
+ "@types/bun": "latest"
+ },
+ "peerDependencies": {
+ "typescript": "^5"
+ }
+ },
+ "node_modules/@techniker-me/logger": {
+ "version": "0.0.15",
+ "resolved": "https://npm.techniker.me/@techniker-me/logger/-/logger-0.0.15.tgz",
+ "integrity": "sha512-+6aB39lWTO2RDQLse2nZqfTXa7Kp78K7Xy7zobwBQlg01jR4zKmQAMkjQ4iduvnQYEU+1F2k6FDMco2E0mWZ4w==",
+ "dependencies": {
+ "@techniker-me/tools": "2025.0.16"
+ }
+ },
+ "node_modules/@techniker-me/tools": {
+ "version": "2025.0.16",
+ "resolved": "https://npm.techniker.me/@techniker-me/tools/-/tools-2025.0.16.tgz",
+ "integrity": "sha512-Ul2yj1vd4lCO8g7IW2pHkAsdeRVEUMqGpiIvSedCc1joVXEWPbh4GESW83kMHtisjFjjlZIzb3EVlCE0BCiBWQ=="
+ },
+ "node_modules/@types/bun": {
+ "version": "1.3.3",
+ "resolved": "https://npm.techniker.me/@types/bun/-/bun-1.3.3.tgz",
+ "integrity": "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bun-types": "1.3.3"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.10.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/bun-types": {
+ "version": "1.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "license": "Apache-2.0",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/signaling/package.json b/signaling/package.json
new file mode 100644
index 0000000..173274b
--- /dev/null
+++ b/signaling/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "signaling",
+ "module": "src/index.ts",
+ "type": "module",
+ "private": true,
+ "devDependencies": {
+ "@types/bun": "latest"
+ },
+ "peerDependencies": {
+ "typescript": "^5"
+ },
+ "dependencies": {
+ "@techniker-me/logger": "^0.0.15"
+ }
+}
diff --git a/signaling/src/SignalingServer.ts b/signaling/src/SignalingServer.ts
new file mode 100644
index 0000000..5133c71
--- /dev/null
+++ b/signaling/src/SignalingServer.ts
@@ -0,0 +1,89 @@
+import type { Server, ServerWebSocket } from "bun";
+import {LoggerFactory, type ILogger} from '@techniker-me/logger';
+import { MessageKindMapping } from "./messaging/MessageKind";
+
+export default class SignalingServer {
+ private readonly _logger: ILogger = LoggerFactory.getLogger('SignalingServer');
+ private readonly _port: number;
+ private readonly _hostname: string;
+ private readonly _development: boolean;
+ private readonly _clients: Set> = new Set();
+
+ constructor(port: number, hostname: string = '0.0.0.0', development: boolean = false) {
+ this._port = port;
+ this._hostname = hostname;
+ this._development = development;
+ }
+
+ get port(): number {
+ return this._port;
+ }
+
+ get hostname(): string {
+ return this._hostname;
+ }
+
+ get development(): boolean {
+ return this._development;
+ }
+
+ get websocket() {
+ return {
+ open: this.handleWebSocketOpen.bind(this),
+ message: this.handleWebSocketMessage.bind(this),
+ close: this.handleWebSocketClose.bind(this),
+ drain: this.handleWebSocketDrain.bind(this),
+ error: this.handleWebSocketError.bind(this),
+ perMessageDeflate: true,
+ maxPayloadLength: 10 * 1024
+ };
+ }
+
+ get fetch() {
+ return (req: Request, server: Server) => {
+ this._logger.info(`Fetch request received [${req.url}] from [${server.requestIP(req)?.address}:${server.requestIP(req)?.port}]`);
+ const url = new URL(req.url);
+
+ if (url.pathname.endsWith('/ws')) {
+ this._logger.info('Upgrading to WebSocket');
+ server.upgrade(req)
+
+ return;
+ }
+
+ return new Response('Hello World');
+ };
+ }
+
+ private handleWebSocketOpen(ws: ServerWebSocket): void {
+ this._logger.info('WebSocket opened');
+ this._clients.add(ws);
+ }
+
+ private handleWebSocketMessage(ws: ServerWebSocket, message: string | Buffer): void {
+ const messageString = typeof message === 'string' ? message : message.toString();
+ const jsonMessage = JSON.parse(messageString);
+ this._logger.info(`WebSocket message received [${MessageKindMapping.convertMessageKindToMessageType(jsonMessage.type)}]`);
+
+ // Forward message to all other clients (following sequence diagram)
+ // This allows the signaling server to relay offers/answers between caller and callee
+ this._clients.forEach(client => {
+ if (client !== ws && client.readyState === 1) { // 1 = OPEN
+ client.send(messageString);
+ }
+ });
+ }
+
+ private handleWebSocketClose(ws: ServerWebSocket): void {
+ this._logger.info('WebSocket closed');
+ this._clients.delete(ws);
+ }
+
+ private handleWebSocketError(ws: ServerWebSocket, error: Error): void {
+ this._logger.error('WebSocket error', error);
+ }
+
+ private handleWebSocketDrain(ws: ServerWebSocket): void {
+ this._logger.info('WebSocket drained');
+ }
+}
\ No newline at end of file
diff --git a/signaling/src/index.ts b/signaling/src/index.ts
new file mode 100644
index 0000000..8ece7c4
--- /dev/null
+++ b/signaling/src/index.ts
@@ -0,0 +1,7 @@
+import SignalingServer from "./SignalingServer";
+
+const signalingServer = new SignalingServer(3000, '0.0.0.0', true);
+
+Bun.serve(signalingServer);
+
+console.log(`Signaling server started on [${signalingServer.hostname}:${signalingServer.port}]`);
\ No newline at end of file
diff --git a/signaling/src/messaging b/signaling/src/messaging
new file mode 120000
index 0000000..ffb805c
--- /dev/null
+++ b/signaling/src/messaging
@@ -0,0 +1 @@
+../../frontend-web-vanilla/src/messaging
\ No newline at end of file
diff --git a/signaling/tsconfig.json b/signaling/tsconfig.json
new file mode 100644
index 0000000..190ca92
--- /dev/null
+++ b/signaling/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ // Environment setup & latest features
+ "lib": ["ESNext"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
+
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
+ },
+ "include": ["src"]
+}