From 7b6b278e9053f6be5d32ad8b92f66f90bd72a7b4 Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Fri, 28 Nov 2025 13:47:17 -0500 Subject: [PATCH] working poc --- frontend-web-vanilla/index.html | 10 +- frontend-web-vanilla/package-lock.json | 9 + frontend-web-vanilla/package.json | 1 + frontend-web-vanilla/src/PeerConnection.ts | 170 ++++++++++++++++++ frontend-web-vanilla/src/Signaling.ts | 110 ------------ frontend-web-vanilla/src/SignalingServer.ts | 48 +++++ frontend-web-vanilla/src/User.ts | 75 ++++++++ .../src/call/CallCoordinator.ts | 120 +++++++++++++ frontend-web-vanilla/src/call/Callee.ts | 57 ++++++ frontend-web-vanilla/src/call/Caller.ts | 67 +++++++ .../interfaces/IPeerConnectionOperations.ts | 31 ++++ .../src/interfaces/ISignalingClient.ts | 29 +++ frontend-web-vanilla/src/main.ts | 149 +++++++++------ .../src/messaging/IMessage.ts | 24 +++ .../src/messaging/MessageKind.ts | 32 ++++ frontend-web-vanilla/tsconfig.json | 2 +- signaling/.gitignore | 34 ++++ signaling/README.md | 1 + signaling/bunfig.toml | 0 signaling/package-lock.json | 75 ++++++++ signaling/package.json | 15 ++ signaling/src/SignalingServer.ts | 89 +++++++++ signaling/src/index.ts | 7 + signaling/src/messaging | 1 + signaling/tsconfig.json | 30 ++++ 25 files changed, 1018 insertions(+), 168 deletions(-) create mode 100644 frontend-web-vanilla/src/PeerConnection.ts delete mode 100644 frontend-web-vanilla/src/Signaling.ts create mode 100644 frontend-web-vanilla/src/SignalingServer.ts create mode 100644 frontend-web-vanilla/src/User.ts create mode 100644 frontend-web-vanilla/src/call/CallCoordinator.ts create mode 100644 frontend-web-vanilla/src/call/Callee.ts create mode 100644 frontend-web-vanilla/src/call/Caller.ts create mode 100644 frontend-web-vanilla/src/interfaces/IPeerConnectionOperations.ts create mode 100644 frontend-web-vanilla/src/interfaces/ISignalingClient.ts create mode 100644 frontend-web-vanilla/src/messaging/IMessage.ts create mode 100644 frontend-web-vanilla/src/messaging/MessageKind.ts create mode 100644 signaling/.gitignore create mode 100644 signaling/README.md create mode 100644 signaling/bunfig.toml create mode 100644 signaling/package-lock.json create mode 100644 signaling/package.json create mode 100644 signaling/src/SignalingServer.ts create mode 100644 signaling/src/index.ts create mode 120000 signaling/src/messaging create mode 100644 signaling/tsconfig.json 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"] +}