Files
PrivyDrop/frontend/lib/transfer/FileTransferOrchestrator.ts
T

462 lines
13 KiB
TypeScript

import { generateFileId } from "@/lib/fileUtils";
import {
CustomFile,
fileMetadata,
WebRTCMessage,
FileRequest,
EmbeddedChunkMeta,
} from "@/types/webrtc";
import { StateManager } from "./StateManager";
import { MessageHandler, MessageHandlerDelegate } from "./MessageHandler";
import { NetworkTransmitter } from "./NetworkTransmitter";
import { ProgressTracker, ProgressCallback } from "./ProgressTracker";
import { StreamingFileReader } from "./StreamingFileReader";
import { TransferConfig } from "./TransferConfig";
import WebRTC_Initiator from "../webrtc_Initiator";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NODE_ENV;
/**
* 🚀 File transfer orchestrator
* Integrates all components to provide unified file transfer services
*/
export class FileTransferOrchestrator implements MessageHandlerDelegate {
private stateManager: StateManager;
private messageHandler: MessageHandler;
private networkTransmitter: NetworkTransmitter;
private progressTracker: ProgressTracker;
constructor(private webrtcConnection: WebRTC_Initiator) {
// Initialize all components
this.stateManager = new StateManager();
this.networkTransmitter = new NetworkTransmitter(
webrtcConnection,
this.stateManager
);
this.progressTracker = new ProgressTracker(this.stateManager);
this.messageHandler = new MessageHandler(this.stateManager, this);
// Set up data handler
this.setupDataHandler();
this.log("log", "FileTransferOrchestrator initialized");
}
/**
* 🎯 Send file metadata
*/
public sendFileMeta(files: CustomFile[], peerId?: string): void {
// Record file sizes belonging to folders for progress calculation
files.forEach((file) => {
if (file.folderName) {
const fileId = generateFileId(file);
this.stateManager.addFileToFolder(file.folderName, fileId, file.size);
}
});
// Loop to send metadata for all files
const peers = peerId
? [peerId]
: Array.from(this.webrtcConnection.peerConnections.keys());
peers.forEach((pId) => {
files.forEach((file) => {
const fileId = generateFileId(file);
this.stateManager.addPendingFile(fileId, file);
const fileMeta = this.getFileMeta(file);
const metaDataString = JSON.stringify(fileMeta);
const sendResult = this.webrtcConnection.sendData(metaDataString, pId);
if (!sendResult) {
this.fireError("Failed to send file metadata", {
fileMeta,
peerId: pId,
});
}
});
});
}
/**
* 🎯 Send string content
*/
public async sendString(content: string, peerId: string): Promise<void> {
const chunkSize = TransferConfig.FILE_CONFIG.CHUNK_SIZE;
const chunks: string[] = [];
for (let i = 0; i < content.length; i += chunkSize) {
chunks.push(content.slice(i, i + chunkSize));
}
// First send metadata
await this.networkTransmitter.sendWithBackpressure(
JSON.stringify({
type: "stringMetadata",
length: content.length,
}),
peerId
);
// Send chunks one by one using backpressure control
for (let i = 0; i < chunks.length; i++) {
const data = JSON.stringify({
type: "string",
chunk: chunks[i],
index: i,
total: chunks.length,
});
await this.networkTransmitter.sendWithBackpressure(data, peerId);
}
this.log(
"log",
`String sent successfully - length: ${content.length}, chunks: ${chunks.length}`,
{ peerId }
);
}
/**
* 🎯 Set progress callback
*/
public setProgressCallback(callback: ProgressCallback, peerId: string): void {
this.progressTracker.setProgressCallback(callback, peerId);
}
// ===== MessageHandlerDelegate Implementation =====
/**
* 📄 Handle file request (delegated from MessageHandler)
*/
async handleFileRequest(request: FileRequest, peerId: string): Promise<void> {
const file = this.stateManager.getPendingFile(request.fileId);
const offset = request.offset || 0;
if (!file) {
this.fireError(`File not found for request`, {
fileId: request.fileId,
peerId,
});
return;
}
await this.sendSingleFile(file, peerId, offset);
}
/**
* 📝 Logging (delegated from MessageHandler)
*/
public log(
level: "log" | "warn" | "error",
message: string,
context?: Record<string, any>
): void {
const prefix = `[FileTransferOrchestrator]`;
console[level](prefix, message, context || "");
}
// ===== Internal Orchestration Methods =====
/**
* 🎯 Send single file
*/
private async sendSingleFile(
file: CustomFile,
peerId: string,
offset: number = 0
): Promise<void> {
const fileId = generateFileId(file);
const peerState = this.stateManager.getPeerState(peerId);
if (peerState.isSending) {
this.log("warn", `Already sending file to peer ${peerId}`, { fileId });
return;
}
// Initialize sending state
this.stateManager.updatePeerState(peerId, {
isSending: true,
currentFolderName: file.folderName,
readOffset: offset,
bufferQueue: [],
isReading: false,
});
// Initialize progress statistics
const currentSent = this.stateManager.getFileBytesSent(peerId, fileId);
this.stateManager.updateFileBytesSent(peerId, fileId, offset - currentSent);
try {
await this.processSendQueue(file, peerId);
await this.waitForTransferComplete(peerId);
} catch (error: any) {
this.fireError(`Error sending file ${file.name}: ${error.message}`, {
fileId,
peerId,
});
this.abortFileSend(fileId, peerId);
}
}
/**
* 🚀 Process send queue - Using StreamingFileReader
*/
private async processSendQueue(
file: CustomFile,
peerId: string
): Promise<void> {
const fileId = generateFileId(file);
const peerState = this.stateManager.getPeerState(peerId);
const transferStartTime = performance.now();
// 1. Initialize streaming file reader
const streamReader = new StreamingFileReader(
file,
peerState.readOffset || 0
);
if (developmentEnv === "development") {
postLogToBackend(
`[DEBUG] 🚀 Starting transfer - file: ${file.name}, size: ${(
file.size /
1024 /
1024
).toFixed(1)}MB`
);
}
try {
let totalBytesSent = 0;
let networkChunkIndex = 0;
let totalReadTime = 0;
let totalSendTime = 0;
let totalProgressTime = 0;
let lastProgressTime = performance.now();
// 2. Stream processing: Get 64KB network chunks one by one and send
while (peerState.isSending) {
// Get next network chunk
const readStartTime = performance.now();
const chunkInfo = await streamReader.getNextNetworkChunk();
const readTime = performance.now() - readStartTime;
totalReadTime += readTime;
// Check if completed
if (chunkInfo.chunk === null) {
break;
}
// Build embedded metadata
const embeddedMeta: EmbeddedChunkMeta = {
chunkIndex: chunkInfo.chunkIndex,
totalChunks: chunkInfo.totalChunks,
chunkSize: chunkInfo.chunk.byteLength,
isLastChunk: chunkInfo.isLastChunk,
fileOffset: chunkInfo.fileOffset,
fileId,
};
// Send network chunk with embedded metadata
let sendSuccessful = false;
const sendStartTime = performance.now();
try {
sendSuccessful = await this.networkTransmitter.sendEmbeddedChunk(
chunkInfo.chunk,
embeddedMeta,
peerId
);
if (sendSuccessful) {
totalBytesSent += chunkInfo.chunk.byteLength;
}
} catch (error) {
this.log(
"warn",
`Chunk send failed #${chunkInfo.chunkIndex}: ${error}`
);
sendSuccessful = false;
}
const sendTime = performance.now() - sendStartTime;
totalSendTime += sendTime;
// Update state and progress
if (sendSuccessful) {
this.stateManager.updatePeerState(peerId, {
readOffset: chunkInfo.fileOffset + chunkInfo.chunk.byteLength,
});
const progressStartTime = performance.now();
await this.progressTracker.updateFileProgress(
chunkInfo.chunk.byteLength,
fileId,
file.size,
peerId,
true
);
const progressTime = performance.now() - progressStartTime;
totalProgressTime += progressTime;
}
networkChunkIndex++;
// Check if it's the last chunk
if (chunkInfo.isLastChunk) {
break;
}
}
if (developmentEnv === "development") {
const totalTime = performance.now() - transferStartTime;
const avgSpeedMBps = totalBytesSent / 1024 / 1024 / (totalTime / 1000);
const expectedTotalChunks = Math.ceil(file.size / 65536);
const startOffset = peerState.readOffset || 0;
const startChunkIndex = Math.floor(startOffset / 65536);
const expectedChunksSent = expectedTotalChunks - startChunkIndex;
postLogToBackend(
`[DEBUG-CHUNKS] ✅ Transfer complete - file: ${file.name}, time: ${(
totalTime / 1000
).toFixed(1)}s, speed: ${avgSpeedMBps.toFixed(1)}MB/s`
);
postLogToBackend(
`[DEBUG-CHUNKS] Chunks sent: ${networkChunkIndex}, expected: ${expectedChunksSent}, startChunk: ${startChunkIndex}, totalFileChunks: ${expectedTotalChunks}`
);
if (networkChunkIndex !== expectedChunksSent) {
postLogToBackend(
`[DEBUG-CHUNKS] ⚠️ CHUNK MISMATCH: sent ${networkChunkIndex} but expected ${expectedChunksSent}`
);
}
}
} catch (error: any) {
const errorMessage = `Streaming send error: ${error.message}`;
if (developmentEnv === "development") {
postLogToBackend(`[DEBUG] ❌ Transfer error: ${errorMessage}`);
}
this.fireError(errorMessage, {
fileId,
peerId,
offset: peerState.readOffset,
});
throw error;
} finally {
// Clean up resources
streamReader.cleanup();
}
}
/**
* ⏳ Wait for transfer completion confirmation
*/
private async waitForTransferComplete(peerId: string): Promise<void> {
while (true) {
const currentPeerState = this.stateManager.getPeerState(peerId);
// Check if it has been cleaned up or does not exist
if (!currentPeerState || !currentPeerState.isSending) {
this.log("log", `Transfer completed or peer disconnected: ${peerId}`);
break;
}
// Check if the WebRTC connection is still valid
if (!this.webrtcConnection.peerConnections.has(peerId)) {
this.log("log", `Peer connection lost: ${peerId}, stopping transfer`);
this.stateManager.updatePeerState(peerId, { isSending: false });
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/**
* 📋 Get file metadata
*/
private getFileMeta(file: CustomFile): fileMetadata {
const fileId = generateFileId(file);
return {
type: "fileMeta",
fileId,
name: file.name,
size: file.size,
fileType: file.type,
fullName: file.fullName,
folderName: file.folderName,
};
}
/**
* ❌ Abort file sending
*/
private abortFileSend(fileId: string, peerId: string): void {
this.log("warn", `Aborting file send for ${fileId} to ${peerId}`);
this.stateManager.resetPeerState(peerId);
}
/**
* 🔧 Set up data handler
*/
private setupDataHandler(): void {
this.webrtcConnection.onDataReceived = (data, peerId) => {
if (typeof data === "string") {
try {
const parsedData = JSON.parse(data) as WebRTCMessage;
this.messageHandler.handleSignalingMessage(parsedData, peerId);
} catch (error) {
this.fireError("Error parsing received JSON data", { error, peerId });
}
}
};
}
/**
* 🔥 Error handling
*/
private fireError(message: string, context?: Record<string, any>) {
this.webrtcConnection.fireError(message, {
...context,
component: "FileTransferOrchestrator",
});
}
// ===== State Query and Debugging =====
/**
* 📊 Get transfer statistics
*/
public getTransferStats(peerId?: string) {
const stats = {
stateManager: this.stateManager.getStateStats(),
progressTracker: peerId
? this.progressTracker.getProgressStats(peerId)
: null,
networkTransmitter: peerId
? this.networkTransmitter.getTransmissionStats(peerId)
: null,
};
return stats;
}
/**
* 🔄 Handle peer reconnection
*/
public handlePeerReconnection(peerId: string): void {
// Clear all transfer states for this peer
this.stateManager.clearPeerState(peerId);
if (developmentEnv === "development")
this.log(
"log",
`Successfully reset transfer state for reconnected peer ${peerId}`
);
}
/**
* 🧹 Clean up all resources
*/
public cleanup(): void {
this.stateManager.cleanup();
this.networkTransmitter.cleanup();
this.progressTracker.cleanup();
this.messageHandler.cleanup();
if (developmentEnv === "development")
this.log("log", "FileTransferOrchestrator cleaned up");
}
}