// client.ts - Browser client for WebRTC file transfers import { io, Socket } from 'socket.io-client'; export class WebRTCFileClient { private socket: Socket; private peerConnection: RTCPeerConnection | null = null; private dataChannel: RTCDataChannel | null = null; private receivedSize: number = 0; private fileSize: number = 0; private fileName: string = ''; private fileChunks: Uint8Array[] = []; private onProgressCallback: ((progress: number) => void) | null = null; private onCompleteCallback: ((file: Blob, fileName: string) => void) | null = null; private onErrorCallback: ((error: string) => void) | null = null; private onFilesListCallback: ((files: any[]) => void) | null = null; constructor(serverUrl: string = window.location.origin) { this.socket = io(serverUrl); this.setupSocketListeners(); } /** * Set up Socket.IO event listeners */ private setupSocketListeners(): void { this.socket.on('connect', () => { console.log('Connected to server, socket ID:', this.socket.id); }); this.socket.on('offer', (data: { sdp: RTCSessionDescription, fileInfo: { name: string, size: number } }) => { console.log('Received offer from server'); this.fileName = data.fileInfo.name; this.fileSize = data.fileInfo.size; this.setupPeerConnection(); this.handleOffer(data.sdp); }); this.socket.on('ice-candidate', (candidate: RTCIceCandidate) => { if (this.peerConnection) { this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) .catch(err => console.error('Error adding received ice candidate', err)); } }); this.socket.on('error', (errorMessage: string) => { console.error('Server error:', errorMessage); if (this.onErrorCallback) { this.onErrorCallback(errorMessage); } }); this.socket.on('files-list', (files: any[]) => { console.log('Received files list:', files); if (this.onFilesListCallback) { this.onFilesListCallback(files); } }); this.socket.on('disconnect', () => { console.log('Disconnected from server'); this.cleanupFileTransfer(); }); } /** * Set up WebRTC peer connection */ private setupPeerConnection(): void { // Close any existing connection if (this.peerConnection) { this.peerConnection.close(); } // Configure ICE servers (STUN/TURN) const configuration = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ] }; this.peerConnection = new RTCPeerConnection(configuration); this.fileChunks = []; this.receivedSize = 0; // Handle ICE candidates this.peerConnection.onicecandidate = (event) => { if (event.candidate) { this.socket.emit('ice-candidate', event.candidate); } }; // Handle incoming data channels this.peerConnection.ondatachannel = (event) => { this.dataChannel = event.channel; this.setupDataChannel(); }; } /** * Set up the data channel for file transfer */ private setupDataChannel(): void { if (!this.dataChannel) return; this.dataChannel.binaryType = 'arraybuffer'; this.dataChannel.onopen = () => { console.log('Data channel is open'); }; this.dataChannel.onmessage = (event) => { const data = event.data; // Handle JSON messages if (typeof data === 'string') { try { const message = JSON.parse(data); if (message.type === 'file-info') { // Reset for new file this.fileName = message.name; this.fileSize = message.size; this.fileChunks = []; this.receivedSize = 0; console.log(`Receiving file: ${this.fileName}, Size: ${this.formatFileSize(this.fileSize)}`); } else if (message.type === 'file-complete') { this.completeFileTransfer(); } else if (message.type === 'error') { if (this.onErrorCallback) { this.onErrorCallback(message.message); } } } catch (e) { console.error('Error parsing message:', e); } } // Handle binary data (file chunks) else if (data instanceof ArrayBuffer) { this.fileChunks.push(new Uint8Array(data)); this.receivedSize += data.byteLength; // Update progress if (this.onProgressCallback && this.fileSize > 0) { const progress = Math.min((this.receivedSize / this.fileSize) * 100, 100); this.onProgressCallback(progress); } } }; this.dataChannel.onclose = () => { console.log('Data channel closed'); }; this.dataChannel.onerror = (error) => { console.error('Data channel error:', error); if (this.onErrorCallback) { this.onErrorCallback('Data channel error'); } }; } /** * Handle the WebRTC offer from the server */ private async handleOffer(sdp: RTCSessionDescription): Promise { if (!this.peerConnection) return; try { await this.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp)); const answer = await this.peerConnection.createAnswer(); await this.peerConnection.setLocalDescription(answer); this.socket.emit('answer', answer); } catch (error) { console.error('Error handling offer:', error); if (this.onErrorCallback) { this.onErrorCallback('Failed to establish connection'); } } } /** * Complete the file transfer process */ private completeFileTransfer(): void { // Combine file chunks if (this.fileChunks.length === 0) { console.error('No file data received'); if (this.onErrorCallback) { this.onErrorCallback('No file data received'); } return; } // Calculate total size let totalLength = 0; for (const chunk of this.fileChunks) { totalLength += chunk.length; } // Create a single Uint8Array with all data const completeFile = new Uint8Array(totalLength); let offset = 0; for (const chunk of this.fileChunks) { completeFile.set(chunk, offset); offset += chunk.length; } // Create Blob from the complete file data const fileBlob = new Blob([completeFile]); console.log(`File transfer complete: ${this.fileName}, Size: ${this.formatFileSize(fileBlob.size)}`); // Trigger completion callback if (this.onCompleteCallback) { this.onCompleteCallback(fileBlob, this.fileName); } // Clean up resources this.cleanupFileTransfer(); } /** * Clean up resources used for file transfer */ private cleanupFileTransfer(): void { this.fileChunks = []; this.receivedSize = 0; if (this.dataChannel) { this.dataChannel.close(); this.dataChannel = null; } if (this.peerConnection) { this.peerConnection.close(); this.peerConnection = null; } } /** * Format file size for display */ private formatFileSize(bytes: number): string { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } /** * Request the list of available files from the server */ public getFilesList(): void { this.socket.emit('get-files'); } /** * Request a specific file from the server */ public requestFile(fileName: string): void { this.socket.emit('request-file', fileName); } /** * Set callback for progress updates */ public onProgress(callback: (progress: number) => void): void { this.onProgressCallback = callback; } /** * Set callback for file transfer completion */ public onComplete(callback: (file: Blob, fileName: string) => void): void { this.onCompleteCallback = callback; } /** * Set callback for error handling */ public onError(callback: (error: string) => void): void { this.onErrorCallback = callback; } /** * Set callback for files list */ public onFilesList(callback: (files: any[]) => void): void { this.onFilesListCallback = callback; } /** * Save the received file */ public saveFile(blob: Blob, fileName: string): void { // Create a URL for the blob const url = URL.createObjectURL(blob); // Create an anchor element and trigger download const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); // Clean up window.setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 0); } /** * Disconnect from the server */ public disconnect(): void { this.socket.disconnect(); this.cleanupFileTransfer(); } }