working poc
This commit is contained in:
@@ -14,14 +14,20 @@
|
|||||||
<video id="local-video" autoplay playsinline muted controls></video>
|
<video id="local-video" autoplay playsinline muted controls></video>
|
||||||
<div class="controls-container">
|
<div class="controls-container">
|
||||||
<button id="start-local-media">Start Local Media</button>
|
<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>
|
</div>
|
||||||
<div class="video-container">
|
<div class="video-container">
|
||||||
<h3 class="centered-text">Remote</h3>
|
<h3 class="centered-text">Remote</h3>
|
||||||
<video id="remote-video" autoplay playsinline muted></video>
|
<video id="remote-video" autoplay playsinline muted></video>
|
||||||
<div class="controls-container">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
9
frontend-web-vanilla/package-lock.json
generated
9
frontend-web-vanilla/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "frontend-web-vanilla",
|
"name": "frontend-web-vanilla",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@techniker-me/logger": "^0.0.15",
|
||||||
"@techniker-me/tools": "^2025.0.16"
|
"@techniker-me/tools": "^2025.0.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -576,6 +577,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@techniker-me/tools": {
|
||||||
"version": "2025.0.16",
|
"version": "2025.0.16",
|
||||||
"resolved": "https://npm.techniker.me/@techniker-me/tools/-/tools-2025.0.16.tgz",
|
"resolved": "https://npm.techniker.me/@techniker-me/tools/-/tools-2025.0.16.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"vite": "npm:rolldown-vite@7.2.7"
|
"vite": "npm:rolldown-vite@7.2.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@techniker-me/logger": "^0.0.15",
|
||||||
"@techniker-me/tools": "^2025.0.16"
|
"@techniker-me/tools": "^2025.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
170
frontend-web-vanilla/src/PeerConnection.ts
Normal file
170
frontend-web-vanilla/src/PeerConnection.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
48
frontend-web-vanilla/src/SignalingServer.ts
Normal file
48
frontend-web-vanilla/src/SignalingServer.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
75
frontend-web-vanilla/src/User.ts
Normal file
75
frontend-web-vanilla/src/User.ts
Normal 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];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
120
frontend-web-vanilla/src/call/CallCoordinator.ts
Normal file
120
frontend-web-vanilla/src/call/CallCoordinator.ts
Normal 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
57
frontend-web-vanilla/src/call/Callee.ts
Normal file
57
frontend-web-vanilla/src/call/Callee.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
67
frontend-web-vanilla/src/call/Caller.ts
Normal file
67
frontend-web-vanilla/src/call/Caller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
29
frontend-web-vanilla/src/interfaces/ISignalingClient.ts
Normal file
29
frontend-web-vanilla/src/interfaces/ISignalingClient.ts
Normal 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>;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 = {
|
type Elements = {
|
||||||
localVideo: HTMLVideoElement;
|
localVideo: HTMLVideoElement;
|
||||||
remoteVideo: HTMLVideoElement;
|
remoteVideo: HTMLVideoElement;
|
||||||
startLocalMediaButton: HTMLButtonElement;
|
startLocalMediaButton: HTMLButtonElement;
|
||||||
createAndSendOfferButton: HTMLButtonElement;
|
sendOfferButton: HTMLButtonElement;
|
||||||
createAndSendAnswerButton: 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 = {
|
const elements: Elements = {
|
||||||
localVideo: document.getElementById('local-video') as HTMLVideoElement,
|
localVideo: document.getElementById('local-video') as HTMLVideoElement,
|
||||||
remoteVideo: document.getElementById('remote-video') as HTMLVideoElement,
|
remoteVideo: document.getElementById('remote-video') as HTMLVideoElement,
|
||||||
startLocalMediaButton: document.getElementById('start-local-media') as HTMLButtonElement,
|
startLocalMediaButton: document.getElementById('start-local-media') as HTMLButtonElement,
|
||||||
createAndSendOfferButton: document.getElementById('create-and-send-offer') as HTMLButtonElement,
|
sendOfferButton: document.getElementById('send-offer') as HTMLButtonElement,
|
||||||
createAndSendAnswerButton: document.getElementById('create-and-send-answer') 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 = {
|
// Initialize WebSocket and signaling server
|
||||||
localMediaStream: null,
|
const websocket = new WebSocket(`ws://${window.location.hostname}:3000/ws`);
|
||||||
remoteMediaStream: null,
|
const signalingServer = new SignalingServer(websocket);
|
||||||
peerConnection: new RTCPeerConnection(),
|
|
||||||
offer: null,
|
// Initialize peer connection configuration
|
||||||
answer: null,
|
const peerConnectionConfiguration: RTCConfiguration = {
|
||||||
|
iceServers: [
|
||||||
|
{
|
||||||
|
urls: 'stun:stun.l.google.com:19302'
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
const actions = {
|
// Initialize peer connection and user
|
||||||
getLocalMediaStream: (constraints = {audio: true, video: true}) => navigator.mediaDevices.getUserMedia(constraints),
|
const peerConnection = new PeerConnection(peerConnectionConfiguration);
|
||||||
setMediaStream: (target: HTMLVideoElement, stream: MediaStream): void => {target.srcObject = stream},
|
const user = new User(elements.localVideo, elements.remoteVideo, peerConnection, signalingServer as ISignalingClient);
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
elements.startLocalMediaButton.addEventListener('click', () => {
|
// Initialize call coordinator (handles caller/callee logic following SOLID principles)
|
||||||
actions.getLocalMediaStream()
|
const callCoordinator = new CallCoordinator(signalingServer, peerConnection);
|
||||||
.then(mediaStream => actions.setMediaStream(elements.localVideo, mediaStream))
|
|
||||||
.then(() => actions.enableButton(elements.createAndSendOfferButton))
|
// Setup ICE candidate handling
|
||||||
.then(() => actions.disableButton(elements.startLocalMediaButton))
|
signalingServer.on<RTCIceCandidateInit>(MessageKind.Candidate, async (message: IMessage<RTCIceCandidateInit>) => {
|
||||||
|
console.log('Candidate received', message);
|
||||||
|
await peerConnection.addIceCandidate(message.payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.createAndSendOfferButton.addEventListener('click', () => {
|
// Setup UI state subscriptions
|
||||||
actions.createOffer(state.peerConnection)
|
peerConnection.signalingState.subscribe((state) => {
|
||||||
.then(offer => state.offer = offer)
|
elements.signalingStateValue.textContent = state;
|
||||||
.then(() => {
|
|
||||||
if (!state.offer) {
|
// Enable send answer button when remote offer is received
|
||||||
throw new Error('Offer not created');
|
if (state === 'have-remote-offer' || state === 'have-remote-pranswer') {
|
||||||
}
|
elements.sendAnswerButton.disabled = false;
|
||||||
|
} else if (state === 'stable' || state === 'have-local-offer') {
|
||||||
return state.peerConnection.setLocalDescription(state.offer)
|
elements.sendAnswerButton.disabled = true;
|
||||||
})
|
}
|
||||||
.then(() => {
|
});
|
||||||
|
|
||||||
})
|
peerConnection.iceConnectionState.subscribe((state) => {
|
||||||
.then(() => actions.enableButton(elements.createAndSendAnswerButton))
|
elements.iceConnectionStateValue.textContent = state;
|
||||||
.then(() => actions.disableButton(elements.createAndSendOfferButton))
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
24
frontend-web-vanilla/src/messaging/IMessage.ts
Normal file
24
frontend-web-vanilla/src/messaging/IMessage.ts
Normal 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;
|
||||||
32
frontend-web-vanilla/src/messaging/MessageKind.ts
Normal file
32
frontend-web-vanilla/src/messaging/MessageKind.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": false,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
|
|||||||
34
signaling/.gitignore
vendored
Normal file
34
signaling/.gitignore
vendored
Normal 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
1
signaling/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# signaling
|
||||||
0
signaling/bunfig.toml
Normal file
0
signaling/bunfig.toml
Normal file
75
signaling/package-lock.json
generated
Normal file
75
signaling/package-lock.json
generated
Normal 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
15
signaling/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
89
signaling/src/SignalingServer.ts
Normal file
89
signaling/src/SignalingServer.ts
Normal 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
7
signaling/src/index.ts
Normal 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
1
signaling/src/messaging
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../frontend-web-vanilla/src/messaging
|
||||||
30
signaling/tsconfig.json
Normal file
30
signaling/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user