working poc

This commit is contained in:
2025-11-28 13:47:17 -05:00
parent 4dea48c1e2
commit 7b6b278e90
25 changed files with 1018 additions and 168 deletions

View File

@@ -14,14 +14,20 @@
<video id="local-video" autoplay playsinline muted controls></video>
<div class="controls-container">
<button id="start-local-media">Start Local Media</button>
<button id="create-and-send-offer" disabled>Create & Send Offer</button>
<button id="send-offer" disabled>Send Offer</button>
</div>
<div>
<p id="signaling-state">Signaling State: <span id="signaling-state-value">None</span></p>
<p id="ice-connection-state">ICE Connection State: <span id="ice-connection-state-value">None</span></p>
<p id="ice-gathering-state">ICE Gathering State: <span id="ice-gathering-state-value">None</span></p>
<p id="connection-state">Connection State: <span id="connection-state-value">None</span></p>
</div>
</div>
<div class="video-container">
<h3 class="centered-text">Remote</h3>
<video id="remote-video" autoplay playsinline muted></video>
<div class="controls-container">
<button id="create-and-send-answer" disabled>Create & Send Answer</button>
<button id="send-answer" disabled>Send Answer</button>
</div>
</div>
</div>

View File

@@ -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",

View File

@@ -26,6 +26,7 @@
"vite": "npm:rolldown-vite@7.2.7"
},
"dependencies": {
"@techniker-me/logger": "^0.0.15",
"@techniker-me/tools": "^2025.0.16"
}
}

View File

@@ -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<RTCSignalingState>;
private readonly _connectionState: Subject<RTCPeerConnectionState>;
private readonly _iceGatheringState: Subject<RTCIceGatheringState>;
private readonly _iceConnectionState: Subject<RTCIceConnectionState>;
private readonly _readOnlySignalingState: ReadOnlySubject<RTCSignalingState>;
private readonly _readOnlyConnectionState: ReadOnlySubject<RTCPeerConnectionState>;
private readonly _readOnlyIceGatheringState: ReadOnlySubject<RTCIceGatheringState>;
private readonly _readOnlyIceConnectionState: ReadOnlySubject<RTCIceConnectionState>;
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<RTCSignalingState> {
return this._readOnlySignalingState;
}
get connectionState(): ReadOnlySubject<RTCPeerConnectionState> {
return this._readOnlyConnectionState;
}
get iceGatheringState(): ReadOnlySubject<RTCIceGatheringState> {
return this._readOnlyIceGatheringState;
}
get iceConnectionState(): ReadOnlySubject<RTCIceConnectionState> {
return this._readOnlyIceConnectionState;
}
get currentRemoteDescription(): RTCSessionDescriptionInit | null {
return this._peerConnection.remoteDescription;
}
get currentLocalDescription(): RTCSessionDescriptionInit | null {
return this._peerConnection.localDescription;
}
public on<T>(event: string, callback: (event: IEvent<T>) => void | Promise<void>): IDisposable {
return this._eventEmitter.subscribe(event, callback);
}
public async createOffer(options?: RTCOfferOptions): Promise<RTCSessionDescriptionInit> {
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<RTCSessionDescriptionInit> {
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<void> {
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<void> {
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<void> {
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);
}
}

View File

@@ -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<RTCSignalingState> = new Subject('closed');
private readonly _connectionState: Subject<RTCPeerConnectionState> = new Subject('closed');
private readonly _iceGatheringState: Subject<RTCIceGathererState> = new Subject('new');
private readonly _iceConnectionState: Subject<RTCIceConnectionState> = new Subject('new');
private readonly _readOnlySignalingState: ReadOnlySubject<RTCSignalingState> = new ReadOnlySubject(this._signalingState);
private readonly _readOnlyConnectionState: ReadOnlySubject<RTCPeerConnectionState> = new ReadOnlySubject(this._connectionState);
private readonly _readOnlyIceGatheringState: ReadOnlySubject<RTCIceGathererState> = new ReadOnlySubject(this._iceGatheringState);
private readonly _readOnlyIceConnectionState: ReadOnlySubject<RTCIceConnectionState> = new ReadOnlySubject(this._iceConnectionState);
constructor(peerConnection: RTCPeerConnection) {
this._peerConnection = peerConnection;
}
public start(): void {
this.setPeerConnectionStateEventListeners(this._peerConnection);
this.setPeerConnectionEventListeners(this._peerConnection);
}
get signalingState(): ReadOnlySubject<RTCSignalingState> {
return this._readOnlySignalingState;
}
get connectionState(): ReadOnlySubject<RTCPeerConnectionState> {
return this._readOnlyConnectionState;
}
get iceGatheringState(): ReadOnlySubject<RTCIceGathererState> {
return this._readOnlyIceGatheringState;
}
get iceConnectionState(): ReadOnlySubject<RTCIceConnectionState> {
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<RTCSessionDescriptionInit> {
const offer = await this._peerConnection.createOffer(options);
await this._peerConnection.setLocalDescription(offer);
return offer;
}
public async createAnswer(options?: RTCOfferOptions): Promise<RTCSessionDescriptionInit> {
const answer = await this._peerConnection.createAnswer(options);
await this._peerConnection.setLocalDescription(answer);
return answer;
}
public async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
await this._peerConnection.setRemoteDescription(description);
}
public async setLocalDescription(description: RTCSessionDescriptionInit): Promise<void> {
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
}));
}
};
}
}

View File

@@ -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<T>(kind: MessageKind, callback: (message: IMessage<T>) => Promise<void>): void {
this._webSocket.addEventListener('message', (event) => {
const message = JSON.parse(event.data) as IMessage<T>;
if (message.type === kind) {
callback(message);
}
});
}
public async sendOffer(offer: RTCSessionDescriptionInit): Promise<void> {
const message: IOfferMessage = {
type: MessageKind.Offer,
payload: offer
};
this._webSocket.send(JSON.stringify(message));
}
public async sendAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
const message: IAnswerMessage = {
type: MessageKind.Answer,
payload: answer
};
this._webSocket.send(JSON.stringify(message));
}
public async sendCandidate(candidate: RTCIceCandidateInit): Promise<void> {
const message: ICandidateMessage = {
type: MessageKind.Candidate,
payload: candidate
};
this._webSocket.send(JSON.stringify(message));
}
}

View File

@@ -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<MediaStream | null> = new Subject(null);
private readonly _readOnlyMediaStream: ReadOnlySubject<MediaStream | null> = 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<MediaStream | null> {
return this._readOnlyMediaStream;
}
public async startLocalMedia(): Promise<void> {
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._mediaStream.value?.getTracks().forEach(track => {
track.stop();
});
this._mediaStream.value = null;
this._localVideoElement.srcObject = null;
}
private initialize(): void {
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;
}
console.log('Track event', event);
if (event.payload.streams?.length === 0) {
return;
}
this._remoteVideoElement.srcObject = event.payload.streams[0];
});
}
}

View File

@@ -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<void> {
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<void> {
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<RTCSessionDescriptionInit>(
MessageKind.Offer,
async (message: IMessage<RTCSessionDescriptionInit>) => {
// 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<RTCSessionDescriptionInit>(
MessageKind.Answer,
async (message: IMessage<RTCSessionDescriptionInit>) => {
// 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);
}
);
}
}

View File

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

View File

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

View File

@@ -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<RTCSessionDescriptionInit>;
/**
* Create a WebRTC answer
*/
createAnswer(options?: RTCAnswerOptions): Promise<RTCSessionDescriptionInit>;
/**
* Set the remote description
*/
setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void>;
/**
* Set the local description
*/
setLocalDescription(description: RTCSessionDescriptionInit): Promise<void>;
/**
* Get the current signaling state
*/
getSignalingState(): RTCSignalingState;
}

View File

@@ -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<T>(kind: MessageKind, callback: (message: IMessage<T>) => Promise<void>): void;
/**
* Send an offer to the signaling server
*/
sendOffer(offer: RTCSessionDescriptionInit): Promise<void>;
/**
* Send an answer to the signaling server
*/
sendAnswer(answer: RTCSessionDescriptionInit): Promise<void>;
/**
* Send an ICE candidate to the signaling server
*/
sendCandidate(candidate: RTCIceCandidateInit): Promise<void>;
}

View File

@@ -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<RTCSessionDescriptionInit> => connection.createOffer(),
createAnswer: (connection: RTCPeerConnection): Promise<RTCSessionDescriptionInit> => 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<RTCIceCandidateInit>(MessageKind.Candidate, async (message: IMessage<RTCIceCandidateInit>) => {
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);
}
});

View File

@@ -0,0 +1,24 @@
import type {MessageType} from './MessageKind';
export interface IMessage<T> {
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;

View File

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

View File

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

34
signaling/.gitignore vendored Normal file
View File

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

1
signaling/README.md Normal file
View File

@@ -0,0 +1 @@
# signaling

0
signaling/bunfig.toml Normal file
View File

75
signaling/package-lock.json generated Normal file
View File

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

15
signaling/package.json Normal file
View File

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

View File

@@ -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<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 {
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<undefined>) => {
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<undefined>): void {
this._logger.info('WebSocket opened');
this._clients.add(ws);
}
private handleWebSocketMessage(ws: ServerWebSocket<undefined>, 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<undefined>): void {
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');
}
}

7
signaling/src/index.ts Normal file
View File

@@ -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}]`);

1
signaling/src/messaging Symbolic link
View File

@@ -0,0 +1 @@
../../frontend-web-vanilla/src/messaging

30
signaling/tsconfig.json Normal file
View File

@@ -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"]
}