icebox/client/index.ts

323 lines
8.8 KiB
TypeScript

// 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<void> {
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();
}
}