ran prettier

This commit is contained in:
2025-11-28 14:35:23 -05:00
parent eca9dfc631
commit 8fb108b04f
15 changed files with 3168 additions and 3166 deletions

View File

@@ -24,7 +24,7 @@ export default class PeerConnection implements IPeerConnectionOperations {
this._readOnlyConnectionState = new ReadOnlySubject(this._connectionState); this._readOnlyConnectionState = new ReadOnlySubject(this._connectionState);
this._readOnlyIceGatheringState = new ReadOnlySubject(this._iceGatheringState); this._readOnlyIceGatheringState = new ReadOnlySubject(this._iceGatheringState);
this._readOnlyIceConnectionState = new ReadOnlySubject(this._iceConnectionState); this._readOnlyIceConnectionState = new ReadOnlySubject(this._iceConnectionState);
this.initialize(); this.initialize();
} }
@@ -68,7 +68,7 @@ export default class PeerConnection implements IPeerConnectionOperations {
public async createAnswer(options?: RTCAnswerOptions): Promise<RTCSessionDescriptionInit> { public async createAnswer(options?: RTCAnswerOptions): Promise<RTCSessionDescriptionInit> {
try { try {
const answer = await this._peerConnection.createAnswer(options); const answer = await this._peerConnection.createAnswer(options);
return answer; return answer;
} catch (error) { } catch (error) {
throw new Error(`Failed to create answer [${error instanceof Error ? error.message : String(error)}]`); throw new Error(`Failed to create answer [${error instanceof Error ? error.message : String(error)}]`);
@@ -127,12 +127,14 @@ export default class PeerConnection implements IPeerConnectionOperations {
this._iceConnectionState.value = peerConnection.iceConnectionState; this._iceConnectionState.value = peerConnection.iceConnectionState;
}; };
this._disposables.add(new Disposable(() => { this._disposables.add(
peerConnection.oniceconnectionstatechange = null; new Disposable(() => {
peerConnection.onicegatheringstatechange = null; peerConnection.oniceconnectionstatechange = null;
peerConnection.onconnectionstatechange = null; peerConnection.onicegatheringstatechange = null;
peerConnection.onsignalingstatechange = null; peerConnection.onconnectionstatechange = null;
})); peerConnection.onsignalingstatechange = null;
})
);
} }
private setPeerConnectionEventListeners(peerConnection: RTCPeerConnection): void { private setPeerConnectionEventListeners(peerConnection: RTCPeerConnection): void {
@@ -152,15 +154,17 @@ export default class PeerConnection implements IPeerConnectionOperations {
payload: event payload: event
}); });
} }
}; };
peerConnection.onicecandidate = iceCandidateHandler; peerConnection.onicecandidate = iceCandidateHandler;
peerConnection.ontrack = trackHandler; peerConnection.ontrack = trackHandler;
this._disposables.add(new Disposable(() => { this._disposables.add(
peerConnection.onicecandidate = null; new Disposable(() => {
peerConnection.ontrack = null; peerConnection.onicecandidate = null;
})); peerConnection.ontrack = null;
})
);
} }
private initialize(): void { private initialize(): void {

View File

@@ -1,48 +1,48 @@
import type { ICandidateMessage, IAnswerMessage, IOfferMessage, IMessage } from "./messaging/IMessage"; import type {ICandidateMessage, IAnswerMessage, IOfferMessage, IMessage} from './messaging/IMessage';
import { MessageKind } from "./messaging/MessageKind"; import {MessageKind} from './messaging/MessageKind';
import type { ISignalingClient } from "./interfaces/ISignalingClient"; import type {ISignalingClient} from './interfaces/ISignalingClient';
export default class SignalingServer implements ISignalingClient { export default class SignalingServer implements ISignalingClient {
private readonly _webSocket: WebSocket; private readonly _webSocket: WebSocket;
constructor(webSocket: WebSocket) { constructor(webSocket: WebSocket) {
this._webSocket = webSocket; this._webSocket = webSocket;
} }
public on<T>(kind: MessageKind, callback: (message: IMessage<T>) => Promise<void>): void { public on<T>(kind: MessageKind, callback: (message: IMessage<T>) => Promise<void>): void {
this._webSocket.addEventListener('message', (event) => { this._webSocket.addEventListener('message', event => {
const message = JSON.parse(event.data) as IMessage<T>; const message = JSON.parse(event.data) as IMessage<T>;
if (message.type === kind) { if (message.type === kind) {
callback(message); callback(message);
} }
}); });
} }
public async sendOffer(offer: RTCSessionDescriptionInit): Promise<void> { public async sendOffer(offer: RTCSessionDescriptionInit): Promise<void> {
const message: IOfferMessage = { const message: IOfferMessage = {
type: MessageKind.Offer, type: MessageKind.Offer,
payload: offer payload: offer
}; };
this._webSocket.send(JSON.stringify(message)); this._webSocket.send(JSON.stringify(message));
} }
public async sendAnswer(answer: RTCSessionDescriptionInit): Promise<void> { public async sendAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
const message: IAnswerMessage = { const message: IAnswerMessage = {
type: MessageKind.Answer, type: MessageKind.Answer,
payload: answer payload: answer
}; };
this._webSocket.send(JSON.stringify(message)); this._webSocket.send(JSON.stringify(message));
} }
public async sendCandidate(candidate: RTCIceCandidateInit): Promise<void> { public async sendCandidate(candidate: RTCIceCandidateInit): Promise<void> {
const message: ICandidateMessage = { const message: ICandidateMessage = {
type: MessageKind.Candidate, type: MessageKind.Candidate,
payload: candidate payload: candidate
}; };
this._webSocket.send(JSON.stringify(message)); this._webSocket.send(JSON.stringify(message));
} }
} }

View File

@@ -1,8 +1,8 @@
import type {ILogger} from '@techniker-me/logger'; import type {ILogger} from '@techniker-me/logger';
import {LoggerFactory} from '@techniker-me/logger'; import {LoggerFactory} from '@techniker-me/logger';
import {ReadOnlySubject, Subject, type IEvent} from '@techniker-me/tools'; import {ReadOnlySubject, Subject, type IEvent} from '@techniker-me/tools';
import type { IPeerConnectionOperations } from './interfaces/IPeerConnectionOperations'; import type {IPeerConnectionOperations} from './interfaces/IPeerConnectionOperations';
import type { ISignalingClient } from './interfaces/ISignalingClient'; import type {ISignalingClient} from './interfaces/ISignalingClient';
/** /**
* User class - manages local and remote media streams * User class - manages local and remote media streams
@@ -10,71 +10,71 @@ import type { ISignalingClient } from './interfaces/ISignalingClient';
* Follows Dependency Inversion Principle - depends on abstractions (interfaces) * Follows Dependency Inversion Principle - depends on abstractions (interfaces)
*/ */
export default class User { export default class User {
private readonly _logger: ILogger = LoggerFactory.getLogger('User'); private readonly _logger: ILogger = LoggerFactory.getLogger('User');
private readonly _localVideoElement: HTMLVideoElement; private readonly _localVideoElement: HTMLVideoElement;
private readonly _remoteVideoElement: HTMLVideoElement; private readonly _remoteVideoElement: HTMLVideoElement;
private readonly _peerConnection: IPeerConnectionOperations; private readonly _peerConnection: IPeerConnectionOperations;
private readonly _signalingClient: ISignalingClient; private readonly _signalingClient: ISignalingClient;
private readonly _mediaStream: Subject<MediaStream | null> = new Subject(null); private readonly _mediaStream: Subject<MediaStream | null> = new Subject(null);
private readonly _readOnlyMediaStream: ReadOnlySubject<MediaStream | null> = new ReadOnlySubject(this._mediaStream); private readonly _readOnlyMediaStream: ReadOnlySubject<MediaStream | null> = new ReadOnlySubject(this._mediaStream);
constructor(localVideoElement: HTMLVideoElement, remoteVideoElement: HTMLVideoElement, peerConnection: IPeerConnectionOperations, signalingClient: ISignalingClient) { constructor(localVideoElement: HTMLVideoElement, remoteVideoElement: HTMLVideoElement, peerConnection: IPeerConnectionOperations, signalingClient: ISignalingClient) {
this._localVideoElement = localVideoElement; this._localVideoElement = localVideoElement;
this._remoteVideoElement = remoteVideoElement; this._remoteVideoElement = remoteVideoElement;
this._peerConnection = peerConnection; this._peerConnection = peerConnection;
this._signalingClient = signalingClient; this._signalingClient = signalingClient;
this.initialize(); this.initialize();
} }
get videoElement(): HTMLVideoElement { get videoElement(): HTMLVideoElement {
return this._localVideoElement; return this._localVideoElement;
} }
get peerConnection(): IPeerConnectionOperations { get peerConnection(): IPeerConnectionOperations {
return this._peerConnection; return this._peerConnection;
} }
get mediaStream(): ReadOnlySubject<MediaStream | null> { get mediaStream(): ReadOnlySubject<MediaStream | null> {
return this._readOnlyMediaStream; return this._readOnlyMediaStream;
} }
public async startLocalMedia(): Promise<void> { public async startLocalMedia(): Promise<void> {
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); 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<void> { this._peerConnection.addMediaStream(mediaStream);
this._mediaStream.value?.getTracks().forEach(track => {
track.stop();
});
this._mediaStream.value = null;
this._localVideoElement.srcObject = null;
}
private initialize(): void { this._mediaStream.value = mediaStream;
this._peerConnection.on<RTCPeerConnectionIceEvent>('icecandidate', (event: IEvent<RTCPeerConnectionIceEvent>) => { this._localVideoElement.srcObject = mediaStream;
// Only send candidate if it exists (null means ICE gathering is complete) this._logger.info('Local media started');
if (event.payload?.candidate) { }
this._signalingClient.sendCandidate(event.payload.candidate);
}
});
this._peerConnection.on<RTCTrackEvent>('track', (event: IEvent<RTCTrackEvent>) => {
if (!event.payload) {
return;
}
console.log('Track event', event); public async stopLocalMedia(): Promise<void> {
this._mediaStream.value?.getTracks().forEach(track => {
track.stop();
});
this._mediaStream.value = null;
this._localVideoElement.srcObject = null;
}
if (event.payload.streams?.length === 0) { private initialize(): void {
return; this._peerConnection.on<RTCPeerConnectionIceEvent>('icecandidate', (event: IEvent<RTCPeerConnectionIceEvent>) => {
} // 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<RTCTrackEvent>('track', (event: IEvent<RTCTrackEvent>) => {
if (!event.payload) {
return;
}
this._remoteVideoElement.srcObject = event.payload.streams[0]; console.log('Track event', event);
});
} if (event.payload.streams?.length === 0) {
} return;
}
this._remoteVideoElement.srcObject = event.payload.streams[0];
});
}
}

View File

@@ -1,11 +1,11 @@
import { Caller } from './Caller'; import {Caller} from './Caller';
import { Callee } from './Callee'; import {Callee} from './Callee';
import type { ISignalingClient } from '../interfaces/ISignalingClient'; import type {ISignalingClient} from '../interfaces/ISignalingClient';
import type { IPeerConnectionOperations } from '../interfaces/IPeerConnectionOperations'; import type {IPeerConnectionOperations} from '../interfaces/IPeerConnectionOperations';
import { MessageKind } from '../messaging/MessageKind'; import {MessageKind} from '../messaging/MessageKind';
import type { IMessage } from '../messaging/IMessage'; import type {IMessage} from '../messaging/IMessage';
import type { ILogger } from '@techniker-me/logger'; import type {ILogger} from '@techniker-me/logger';
import { LoggerFactory } from '@techniker-me/logger'; import {LoggerFactory} from '@techniker-me/logger';
/** /**
* CallCoordinator - coordinates between Caller and Callee roles * CallCoordinator - coordinates between Caller and Callee roles
@@ -20,15 +20,12 @@ export class CallCoordinator {
private readonly _peerConnection: IPeerConnectionOperations; private readonly _peerConnection: IPeerConnectionOperations;
private _lastReceivedOffer: RTCSessionDescriptionInit | null = null; private _lastReceivedOffer: RTCSessionDescriptionInit | null = null;
constructor( constructor(signalingClient: ISignalingClient, peerConnection: IPeerConnectionOperations) {
signalingClient: ISignalingClient,
peerConnection: IPeerConnectionOperations
) {
this._signalingClient = signalingClient; this._signalingClient = signalingClient;
this._peerConnection = peerConnection; this._peerConnection = peerConnection;
this._caller = new Caller(signalingClient, peerConnection); this._caller = new Caller(signalingClient, peerConnection);
this._callee = new Callee(signalingClient, peerConnection); this._callee = new Callee(signalingClient, peerConnection);
this.setupMessageHandlers(); this.setupMessageHandlers();
} }
@@ -49,26 +46,26 @@ export class CallCoordinator {
*/ */
public async createAndSendAnswer(): Promise<void> { public async createAndSendAnswer(): Promise<void> {
const currentState = this._peerConnection.getSignalingState(); const currentState = this._peerConnection.getSignalingState();
// Verify we have a remote offer // Verify we have a remote offer
if (currentState !== 'have-remote-offer' && currentState !== 'have-remote-pranswer') { if (currentState !== 'have-remote-offer' && currentState !== 'have-remote-pranswer') {
throw new Error('Cannot send answer: no remote offer received. Current state: ' + currentState); throw new Error('Cannot send answer: no remote offer received. Current state: ' + currentState);
} }
if (!this._lastReceivedOffer) { if (!this._lastReceivedOffer) {
throw new Error('Cannot send answer: no offer stored'); throw new Error('Cannot send answer: no offer stored');
} }
this._logger.info('Creating and sending answer manually...'); this._logger.info('Creating and sending answer manually...');
// Step 1: Create Answer (remote description was already set when offer was received) // Step 1: Create Answer (remote description was already set when offer was received)
const answer = await this._peerConnection.createAnswer(); const answer = await this._peerConnection.createAnswer();
this._logger.info('Answer created'); this._logger.info('Answer created');
// Step 2: Set local description to Answer // Step 2: Set local description to Answer
await this._peerConnection.setLocalDescription(answer); await this._peerConnection.setLocalDescription(answer);
this._logger.info('Local description set to answer'); this._logger.info('Local description set to answer');
// Step 3: Send Answer to signaling server // Step 3: Send Answer to signaling server
await this._signalingClient.sendAnswer(answer); await this._signalingClient.sendAnswer(answer);
this._logger.info('Answer sent to signaling server'); this._logger.info('Answer sent to signaling server');
@@ -81,40 +78,33 @@ export class CallCoordinator {
// Handle incoming offers (when acting as callee) // Handle incoming offers (when acting as callee)
// Only set remote description - don't automatically create/send answer // Only set remote description - don't automatically create/send answer
// The answer will be sent when the user clicks the "Send Answer" button // The answer will be sent when the user clicks the "Send Answer" button
this._signalingClient.on<RTCSessionDescriptionInit>( this._signalingClient.on<RTCSessionDescriptionInit>(MessageKind.Offer, async (message: IMessage<RTCSessionDescriptionInit>) => {
MessageKind.Offer, // Only handle offer if we're not the caller (don't have local offer)
async (message: IMessage<RTCSessionDescriptionInit>) => { const currentState = this._peerConnection.getSignalingState();
// Only handle offer if we're not the caller (don't have local offer) if (currentState === 'have-local-offer' || currentState === 'have-local-pranswer') {
const currentState = this._peerConnection.getSignalingState(); this._logger.info('Already have local offer, ignoring incoming offer');
if (currentState === 'have-local-offer' || currentState === 'have-local-pranswer') { return;
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);
} }
);
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) // Handle incoming answers (when acting as caller)
this._signalingClient.on<RTCSessionDescriptionInit>( this._signalingClient.on<RTCSessionDescriptionInit>(MessageKind.Answer, async (message: IMessage<RTCSessionDescriptionInit>) => {
MessageKind.Answer, // Only handle answer if we're the caller (have local offer)
async (message: IMessage<RTCSessionDescriptionInit>) => { const currentState = this._peerConnection.getSignalingState();
// Only handle answer if we're the caller (have local offer) if (currentState !== 'have-local-offer' && currentState !== 'have-local-pranswer') {
const currentState = this._peerConnection.getSignalingState(); this._logger.info('Not in caller state, ignoring incoming answer');
if (currentState !== 'have-local-offer' && currentState !== 'have-local-pranswer') { return;
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);
} }
);
this._logger.info('Received answer, acting as caller');
await this._caller.handleAnswer(message.payload);
});
} }
} }

View File

@@ -1,7 +1,7 @@
import type { ISignalingClient } from '../interfaces/ISignalingClient'; import type {ISignalingClient} from '../interfaces/ISignalingClient';
import type { IPeerConnectionOperations } from '../interfaces/IPeerConnectionOperations'; import type {IPeerConnectionOperations} from '../interfaces/IPeerConnectionOperations';
import type { ILogger } from '@techniker-me/logger'; import type {ILogger} from '@techniker-me/logger';
import { LoggerFactory } from '@techniker-me/logger'; import {LoggerFactory} from '@techniker-me/logger';
/** /**
* Callee class - responsible for receiving and responding to WebRTC calls * Callee class - responsible for receiving and responding to WebRTC calls
@@ -13,10 +13,7 @@ export class Callee {
private readonly _signalingClient: ISignalingClient; private readonly _signalingClient: ISignalingClient;
private readonly _peerConnection: IPeerConnectionOperations; private readonly _peerConnection: IPeerConnectionOperations;
constructor( constructor(signalingClient: ISignalingClient, peerConnection: IPeerConnectionOperations) {
signalingClient: ISignalingClient,
peerConnection: IPeerConnectionOperations
) {
this._signalingClient = signalingClient; this._signalingClient = signalingClient;
this._peerConnection = peerConnection; this._peerConnection = peerConnection;
} }
@@ -32,19 +29,19 @@ export class Callee {
public async handleOffer(offer: RTCSessionDescriptionInit): Promise<void> { public async handleOffer(offer: RTCSessionDescriptionInit): Promise<void> {
try { try {
this._logger.info('Handling offer from caller...'); this._logger.info('Handling offer from caller...');
// Step 1: Set remote description to offer // Step 1: Set remote description to offer
await this._peerConnection.setRemoteDescription(offer); await this._peerConnection.setRemoteDescription(offer);
this._logger.info('Remote description set to offer'); this._logger.info('Remote description set to offer');
// Step 2: Create Answer // Step 2: Create Answer
const answer = await this._peerConnection.createAnswer(); const answer = await this._peerConnection.createAnswer();
this._logger.info('Answer created'); this._logger.info('Answer created');
// Step 3: Set local description to Answer // Step 3: Set local description to Answer
await this._peerConnection.setLocalDescription(answer); await this._peerConnection.setLocalDescription(answer);
this._logger.info('Local description set to answer'); this._logger.info('Local description set to answer');
// Step 4: Send Answer to signaling server // Step 4: Send Answer to signaling server
await this._signalingClient.sendAnswer(answer); await this._signalingClient.sendAnswer(answer);
this._logger.info('Answer sent to signaling server'); this._logger.info('Answer sent to signaling server');
@@ -54,4 +51,3 @@ export class Callee {
} }
} }
} }

View File

@@ -1,7 +1,7 @@
import type { ISignalingClient } from '../interfaces/ISignalingClient'; import type {ISignalingClient} from '../interfaces/ISignalingClient';
import type { IPeerConnectionOperations } from '../interfaces/IPeerConnectionOperations'; import type {IPeerConnectionOperations} from '../interfaces/IPeerConnectionOperations';
import type { ILogger } from '@techniker-me/logger'; import type {ILogger} from '@techniker-me/logger';
import { LoggerFactory } from '@techniker-me/logger'; import {LoggerFactory} from '@techniker-me/logger';
/** /**
* Caller class - responsible for initiating WebRTC calls * Caller class - responsible for initiating WebRTC calls
@@ -13,10 +13,7 @@ export class Caller {
private readonly _signalingClient: ISignalingClient; private readonly _signalingClient: ISignalingClient;
private readonly _peerConnection: IPeerConnectionOperations; private readonly _peerConnection: IPeerConnectionOperations;
constructor( constructor(signalingClient: ISignalingClient, peerConnection: IPeerConnectionOperations) {
signalingClient: ISignalingClient,
peerConnection: IPeerConnectionOperations
) {
this._signalingClient = signalingClient; this._signalingClient = signalingClient;
this._peerConnection = peerConnection; this._peerConnection = peerConnection;
} }
@@ -30,14 +27,14 @@ export class Caller {
public async createAndSendOffer(): Promise<void> { public async createAndSendOffer(): Promise<void> {
try { try {
this._logger.info('Creating offer...'); this._logger.info('Creating offer...');
// Step 1: Create Offer // Step 1: Create Offer
const offer = await this._peerConnection.createOffer(); const offer = await this._peerConnection.createOffer();
// Step 2: Set local description to offer // Step 2: Set local description to offer
await this._peerConnection.setLocalDescription(offer); await this._peerConnection.setLocalDescription(offer);
this._logger.info('Local description set to offer'); this._logger.info('Local description set to offer');
// Step 3: Send Offer to signaling server // Step 3: Send Offer to signaling server
await this._signalingClient.sendOffer(offer); await this._signalingClient.sendOffer(offer);
this._logger.info('Offer sent to signaling server'); this._logger.info('Offer sent to signaling server');
@@ -54,7 +51,7 @@ export class Caller {
public async handleAnswer(answer: RTCSessionDescriptionInit): Promise<void> { public async handleAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
try { try {
this._logger.info('Handling answer from callee...'); this._logger.info('Handling answer from callee...');
// Set Answer as remote description // Set Answer as remote description
await this._peerConnection.setRemoteDescription(answer); await this._peerConnection.setRemoteDescription(answer);
this._logger.info('Answer set as remote description'); this._logger.info('Answer set as remote description');
@@ -64,4 +61,3 @@ export class Caller {
} }
} }
} }

View File

@@ -1,4 +1,4 @@
import type { IEvent, IDisposable } from '@techniker-me/tools'; import type {IEvent, IDisposable} from '@techniker-me/tools';
/** /**
* Interface for peer connection operations * Interface for peer connection operations
@@ -44,4 +44,3 @@ export interface IPeerConnectionOperations {
*/ */
on<T>(event: string, callback: (event: IEvent<T>) => void | Promise<void>): IDisposable; on<T>(event: string, callback: (event: IEvent<T>) => void | Promise<void>): IDisposable;
} }

View File

@@ -1,5 +1,5 @@
import type { IMessage } from '../messaging/IMessage'; import type {IMessage} from '../messaging/IMessage';
import { MessageKind } from '../messaging/MessageKind'; import {MessageKind} from '../messaging/MessageKind';
/** /**
* Interface for signaling client operations * Interface for signaling client operations
@@ -26,4 +26,3 @@ export interface ISignalingClient {
*/ */
sendCandidate(candidate: RTCIceCandidateInit): Promise<void>; sendCandidate(candidate: RTCIceCandidateInit): Promise<void>;
} }

View File

@@ -1,10 +1,10 @@
import PeerConnection from './PeerConnection'; import PeerConnection from './PeerConnection';
import User from './User'; import User from './User';
import SignalingServer from './SignalingServer'; import SignalingServer from './SignalingServer';
import { CallCoordinator } from './call/CallCoordinator'; import {CallCoordinator} from './call/CallCoordinator';
import { MessageKind } from './messaging/MessageKind'; import {MessageKind} from './messaging/MessageKind';
import type { IMessage } from './messaging/IMessage'; import type {IMessage} from './messaging/IMessage';
import type { ISignalingClient } from './interfaces/ISignalingClient'; import type {ISignalingClient} from './interfaces/ISignalingClient';
type Elements = { type Elements = {
localVideo: HTMLVideoElement; localVideo: HTMLVideoElement;
@@ -16,7 +16,7 @@ type Elements = {
iceConnectionStateValue: HTMLSpanElement; iceConnectionStateValue: HTMLSpanElement;
iceGatheringStateValue: HTMLSpanElement; iceGatheringStateValue: HTMLSpanElement;
connectionStateValue: HTMLSpanElement; connectionStateValue: HTMLSpanElement;
} };
const elements: Elements = { const elements: Elements = {
localVideo: document.getElementById('local-video') as HTMLVideoElement, localVideo: document.getElementById('local-video') as HTMLVideoElement,
@@ -28,7 +28,7 @@ const elements: Elements = {
iceConnectionStateValue: document.getElementById('ice-connection-state-value') as HTMLSpanElement, iceConnectionStateValue: document.getElementById('ice-connection-state-value') as HTMLSpanElement,
iceGatheringStateValue: document.getElementById('ice-gathering-state-value') as HTMLSpanElement, iceGatheringStateValue: document.getElementById('ice-gathering-state-value') as HTMLSpanElement,
connectionStateValue: document.getElementById('connection-state-value') as HTMLSpanElement connectionStateValue: document.getElementById('connection-state-value') as HTMLSpanElement
} };
// Initialize WebSocket and signaling server // Initialize WebSocket and signaling server
const websocket = new WebSocket(`ws://${window.location.hostname}:3000/ws`); const websocket = new WebSocket(`ws://${window.location.hostname}:3000/ws`);
@@ -57,9 +57,9 @@ signalingServer.on<RTCIceCandidateInit>(MessageKind.Candidate, async (message: I
}); });
// Setup UI state subscriptions // Setup UI state subscriptions
peerConnection.signalingState.subscribe((state) => { peerConnection.signalingState.subscribe(state => {
elements.signalingStateValue.textContent = state; elements.signalingStateValue.textContent = state;
// Enable send answer button when remote offer is received // Enable send answer button when remote offer is received
if (state === 'have-remote-offer' || state === 'have-remote-pranswer') { if (state === 'have-remote-offer' || state === 'have-remote-pranswer') {
elements.sendAnswerButton.disabled = false; elements.sendAnswerButton.disabled = false;
@@ -68,15 +68,15 @@ peerConnection.signalingState.subscribe((state) => {
} }
}); });
peerConnection.iceConnectionState.subscribe((state) => { peerConnection.iceConnectionState.subscribe(state => {
elements.iceConnectionStateValue.textContent = state; elements.iceConnectionStateValue.textContent = state;
}); });
peerConnection.iceGatheringState.subscribe((state) => { peerConnection.iceGatheringState.subscribe(state => {
elements.iceGatheringStateValue.textContent = state; elements.iceGatheringStateValue.textContent = state;
}); });
peerConnection.connectionState.subscribe((state) => { peerConnection.connectionState.subscribe(state => {
elements.connectionStateValue.textContent = state; elements.connectionStateValue.textContent = state;
}); });
@@ -103,10 +103,10 @@ elements.sendAnswerButton.addEventListener('click', async () => {
await user.startLocalMedia(); await user.startLocalMedia();
elements.startLocalMediaButton.disabled = true; elements.startLocalMediaButton.disabled = true;
} }
await callCoordinator.createAndSendAnswer(); await callCoordinator.createAndSendAnswer();
elements.sendAnswerButton.disabled = true; elements.sendAnswerButton.disabled = true;
} catch (error) { } catch (error) {
console.error('Failed to send answer', error); console.error('Failed to send answer', error);
} }
}); });

View File

@@ -1,24 +1,23 @@
import type {MessageType} from './MessageKind'; import type {MessageType} from './MessageKind';
export interface IMessage<T> { export interface IMessage<T> {
type: MessageType; type: MessageType;
payload: T; payload: T;
} }
export type IOfferMessage = { export type IOfferMessage = {
type: 'offer'; type: 'offer';
payload: RTCSessionDescriptionInit; payload: RTCSessionDescriptionInit;
} };
export type IAnswerMessage = { export type IAnswerMessage = {
type: 'answer'; type: 'answer';
payload: RTCSessionDescriptionInit; payload: RTCSessionDescriptionInit;
} };
export type ICandidateMessage = { export type ICandidateMessage = {
type: 'candidate'; type: 'candidate';
payload: RTCIceCandidateInit; payload: RTCIceCandidateInit;
} };
export type Message = IOfferMessage | IAnswerMessage | ICandidateMessage; export type Message = IOfferMessage | IAnswerMessage | ICandidateMessage;

View File

@@ -1,32 +1,32 @@
export enum MessageKind { export enum MessageKind {
Offer = 0, Offer = 0,
Answer = 1, Answer = 1,
Candidate = 2 Candidate = 2
} }
export type MessageKindType = 'offer' | 'answer' | 'candidate'; export type MessageKindType = 'offer' | 'answer' | 'candidate';
export type MessageType = MessageKindType; export type MessageType = MessageKindType;
export class MessageKindMapping { export class MessageKindMapping {
public static convertMessageKindToMessageType(messageKind: MessageKind): MessageType { public static convertMessageKindToMessageType(messageKind: MessageKind): MessageType {
switch (messageKind) { switch (messageKind) {
case MessageKind.Offer: case MessageKind.Offer:
return 'offer'; return 'offer';
case MessageKind.Answer: case MessageKind.Answer:
return 'answer'; return 'answer';
case MessageKind.Candidate: case MessageKind.Candidate:
return 'candidate'; return 'candidate';
}
} }
}
public static convertMessageTypeToMessageKind(messageType: MessageType): MessageKind { public static convertMessageTypeToMessageKind(messageType: MessageType): MessageKind {
switch (messageType) { switch (messageType) {
case 'offer': case 'offer':
return MessageKind.Offer; return MessageKind.Offer;
case 'answer': case 'answer':
return MessageKind.Answer; return MessageKind.Answer;
case 'candidate': case 'candidate':
return MessageKind.Candidate; return MessageKind.Candidate;
}
} }
} }
}

5686
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
{ {
"name": "webrtc-real-time-ip-phone", "name": "webrtc-real-time-ip-phone",
"workspaces": [ "workspaces": [
"./frontend-web-vanilla", "./frontend-web-vanilla",
"./signaling" "./signaling"
], ],
"scripts": { "scripts": {
"dev": "concurrently \"cd frontend-web-vanilla && bun run dev\" \"cd signaling && bun run dev\"" "dev": "concurrently \"cd frontend-web-vanilla && bun run dev\" \"cd signaling && bun run dev\""
} }
} }

View File

@@ -1,89 +1,106 @@
import type { Server, ServerWebSocket } from "bun"; import type { Server, ServerWebSocket } from "bun";
import {LoggerFactory, type ILogger} from '@techniker-me/logger'; import { LoggerFactory, type ILogger } from "@techniker-me/logger";
import { MessageKindMapping } from "./messaging/MessageKind"; import { MessageKindMapping } from "./messaging/MessageKind";
export default class SignalingServer { export default class SignalingServer {
private readonly _logger: ILogger = LoggerFactory.getLogger('SignalingServer'); private readonly _logger: ILogger =
private readonly _port: number; LoggerFactory.getLogger("SignalingServer");
private readonly _hostname: string; private readonly _port: number;
private readonly _development: boolean; private readonly _hostname: string;
private readonly _clients: Set<ServerWebSocket<undefined>> = new Set(); private readonly _development: boolean;
private readonly _clients: Set<ServerWebSocket<undefined>> = 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 { constructor(
return this._port; port: number,
} hostname: string = "0.0.0.0",
development: boolean = false,
) {
this._port = port;
this._hostname = hostname;
this._development = development;
}
get hostname(): string { get port(): number {
return this._hostname; return this._port;
} }
get development(): boolean { get hostname(): string {
return this._development; return this._hostname;
} }
get websocket() { get development(): boolean {
return { return this._development;
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() { get websocket() {
return (req: Request, server: Server<undefined>) => { return {
this._logger.info(`Fetch request received [${req.url}] from [${server.requestIP(req)?.address}:${server.requestIP(req)?.port}]`); open: this.handleWebSocketOpen.bind(this),
const url = new URL(req.url); 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,
};
}
if (url.pathname.endsWith('/ws')) { get fetch() {
this._logger.info('Upgrading to WebSocket'); return (req: Request, server: Server<undefined>) => {
server.upgrade(req) this._logger.info(
`Fetch request received [${req.url}] from [${server.requestIP(req)?.address}:${server.requestIP(req)?.port}]`,
);
const url = new URL(req.url);
return; if (url.pathname.endsWith("/ws")) {
} this._logger.info("Upgrading to WebSocket");
server.upgrade(req);
return new Response('Hello World'); return;
}; }
}
private handleWebSocketOpen(ws: ServerWebSocket<undefined>): void { return new Response("Hello World");
this._logger.info('WebSocket opened'); };
this._clients.add(ws); }
}
private handleWebSocketMessage(ws: ServerWebSocket<undefined>, message: string | Buffer): void { private handleWebSocketOpen(ws: ServerWebSocket<undefined>): void {
const messageString = typeof message === 'string' ? message : message.toString(); this._logger.info("WebSocket opened");
const jsonMessage = JSON.parse(messageString); this._clients.add(ws);
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<undefined>): void { private handleWebSocketMessage(
this._logger.info('WebSocket closed'); ws: ServerWebSocket<undefined>,
this._clients.delete(ws); 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)}]`,
);
private handleWebSocketError(ws: ServerWebSocket<undefined>, error: Error): void { // Forward message to all other clients (following sequence diagram)
this._logger.error('WebSocket error', error); // 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 handleWebSocketDrain(ws: ServerWebSocket<undefined>): void { private handleWebSocketClose(ws: ServerWebSocket<undefined>): void {
this._logger.info('WebSocket drained'); this._logger.info("WebSocket closed");
} this._clients.delete(ws);
} }
private handleWebSocketError(
ws: ServerWebSocket<undefined>,
error: Error,
): void {
this._logger.error("WebSocket error", error);
}
private handleWebSocketDrain(ws: ServerWebSocket<undefined>): void {
this._logger.info("WebSocket drained");
}
}

View File

@@ -1,7 +1,9 @@
import SignalingServer from "./SignalingServer"; import SignalingServer from "./SignalingServer";
const signalingServer = new SignalingServer(3000, '0.0.0.0', true); const signalingServer = new SignalingServer(3000, "0.0.0.0", true);
Bun.serve(signalingServer); Bun.serve(signalingServer);
console.log(`Signaling server started on [${signalingServer.hostname}:${signalingServer.port}]`); console.log(
`Signaling server started on [${signalingServer.hostname}:${signalingServer.port}]`,
);