Files
PrivyDrop/frontend/lib/webrtcService.ts
T
david_bai 8ef43029d5 fix(deploy+docker+frontend): enforce same-origin via Nginx, disable Next Image optimization in Docker, allow Socket.IO polling fallback, and improve health checks and access info
- generate-config.sh: add --with-nginx flag handling; when enabled, set NEXT_PUBLIC_API_URL empty to use same-origin /api and /socket.io; add BACKEND_INTERNAL_URL for SSR/internal fetch; adjust lan-tls HTTPS (8443) and TLS generation policy
- deploy.sh: show only valid access URLs when Nginx is enabled (gateway URLs), avoid misleading :3002/:3001 entries
- frontend (env/webrtc): return mutable transports [websocket,polling]; use empty signaling server for same-origin; comments in English
- frontend (next.config): support NEXT_IMAGE_UNOPTIMIZED to turn off image optimization in Docker
- frontend (health): prefer BACKEND_INTERNAL_URL for internal health checks, fallback to public URL/localhost
- docker-compose + Dockerfile(frontend): pass NEXT_IMAGE_UNOPTIMIZED and BACKEND_INTERNAL_URL envs
2025-10-10 20:49:17 +08:00

336 lines
12 KiB
TypeScript

import WebRTC_Initiator from "@/lib/webrtc_Initiator";
import WebRTC_Recipient from "@/lib/webrtc_Recipient";
import FileSender from "@/lib/fileSender";
import FileReceiver from "@/lib/fileReceiver";
import {
getIceServers,
getSocketOptions,
config,
} from "@/app/config/environment";
import { useFileTransferStore } from "@/stores/fileTransferStore";
class WebRTCService {
public sender: WebRTC_Initiator;
public receiver: WebRTC_Recipient;
public fileSender: FileSender;
public fileReceiver: FileReceiver;
private static instance: WebRTCService;
private constructor() {
const apiUrl = (config.API_URL || "").trim();
// Use same-origin when API_URL is empty string — socket.io accepts empty string for same-origin
const signalingServer: string = apiUrl.length > 0 ? apiUrl : "";
const webRTCConfig = {
iceServers: getIceServers(),
socketOptions: getSocketOptions() || {},
signalingServer,
};
this.sender = new WebRTC_Initiator(webRTCConfig);
this.receiver = new WebRTC_Recipient(webRTCConfig);
this.fileSender = new FileSender(this.sender);
this.fileReceiver = new FileReceiver(this.receiver);
this.initializeEventHandlers();
}
public static getInstance(): WebRTCService {
if (!WebRTCService.instance) {
WebRTCService.instance = new WebRTCService();
}
return WebRTCService.instance;
}
private initializeEventHandlers(): void {
// Sender event handling
this.sender.onConnectionStateChange = (state, peerId) => {
console.log(`[WebRTC Service] Sender connection state: ${state} for peer ${peerId}`);
useFileTransferStore.getState().setShareConnectionState(state as any);
if (state === "connected") {
// update share peer count
useFileTransferStore.getState().setSharePeerCount(this.sender.peerConnections.size);
console.log(`[WebRTC Service] Sender connected, peer count: ${this.sender.peerConnections.size}`);
this.fileSender.setProgressCallback((fileId, progress, speed) => {
useFileTransferStore
.getState()
.updateSendProgress(fileId, peerId, { progress, speed });
}, peerId);
} else if (state === "failed" || state === "closed") {
this.handleConnectionDisconnect(peerId, true, `CONNECTION_${state.toUpperCase()}`);
}
};
this.sender.onDataChannelOpen = (_peerId) => {
useFileTransferStore.getState().setIsSenderInRoom(true);
// Automatically broadcast current content
this.broadcastDataToAllPeers();
};
this.sender.onPeerDisconnected = (peerId) => {
console.log(`[WebRTC Service] Sender peer disconnected: ${peerId}`);
this.handleConnectionDisconnect(peerId, true, "PEER_DISCONNECTED");
};
this.sender.onError = (error) => {
console.error("[WebRTC Service] Sender error:", error.message);
// Clear all states on error
this.clearAllTransferProgress();
};
// Receiver event handling
this.receiver.onConnectionStateChange = (state, peerId) => {
console.log(`[WebRTC Service] Receiver connection state: ${state} for peer ${peerId}`);
useFileTransferStore.getState().setRetrieveConnectionState(state as any);
if (state === "connected") {
// update retrieve peer count
useFileTransferStore.getState().setRetrievePeerCount(this.receiver.peerConnections.size);
console.log(`[WebRTC Service] Receiver connected, peer count: ${this.receiver.peerConnections.size}`);
this.fileReceiver.setProgressCallback((fileId, progress, speed) => {
useFileTransferStore
.getState()
.updateReceiveProgress(fileId, peerId, { progress, speed });
});
} else if (state === "failed" || state === "closed") {
this.handleConnectionDisconnect(peerId, false, `CONNECTION_${state.toUpperCase()}`);
}
};
this.receiver.onConnectionEstablished = (peerId) => {
this.fileSender.handlePeerReconnection(peerId);
useFileTransferStore.getState().setSenderDisconnected(false);
useFileTransferStore.getState().setIsReceiverInRoom(true);
};
this.receiver.onPeerDisconnected = (peerId) => {
console.log(`[WebRTC Service] Receiver peer disconnected: ${peerId}`);
this.handleConnectionDisconnect(peerId, false, "PEER_DISCONNECTED");
};
this.fileReceiver.onStringReceived = (data) => {
useFileTransferStore.getState().setRetrievedContent(data);
};
this.fileReceiver.onFileMetaReceived = (meta) => {
const { type, ...metaWithoutType } = meta;
const store = useFileTransferStore.getState();
const filteredMetas = store.retrievedFileMetas.filter(
(existingFile) => existingFile.fileId !== metaWithoutType.fileId
);
store.setRetrievedFileMetas([...filteredMetas, metaWithoutType]);
};
this.fileReceiver.onFileReceived = async (file) => {
// 🔧 Enhanced fix: Ensure Store state updates are fully synchronized with multiple verifications
const store = useFileTransferStore.getState();
// Check if file already exists to avoid duplicates
const existingFile = store.retrievedFiles.find(
(f) => f.name === file.name && f.size === file.size
);
if (!existingFile) {
store.addRetrievedFile(file);
}
};
}
// Business methods
public async joinRoom(roomId: string, isSender: boolean): Promise<void> {
// Ensure clean state before joining
if (!isSender) {
// Force reset FileReceiver state to prevent "already in progress" errors
this.fileReceiver.forceReset();
}
const peer = isSender ? this.sender : this.receiver;
await peer.joinRoom(roomId, isSender);
const setInRoom = isSender
? useFileTransferStore.getState().setIsSenderInRoom
: useFileTransferStore.getState().setIsReceiverInRoom;
setInRoom(true);
}
public async leaveRoom(isSender: boolean): Promise<void> {
if (isSender) {
// Clean up sender
this.fileSender.cleanup();
await this.sender.leaveRoomAndCleanup();
useFileTransferStore.getState().setIsSenderInRoom(false);
useFileTransferStore.getState().setSharePeerCount(0);
} else {
// Clean up receiver - force reset to ensure complete cleanup
this.fileReceiver.forceReset();
await this.receiver.leaveRoomAndCleanup();
useFileTransferStore.getState().setIsReceiverInRoom(false);
useFileTransferStore.getState().setRetrievePeerCount(0);
}
}
public async broadcastDataToAllPeers(): Promise<boolean> {
const { shareContent, sendFiles } = useFileTransferStore.getState();
const peerIds = Array.from(this.sender.peerConnections.keys());
if (peerIds.length === 0) {
console.warn("[WebRTC Service] No connected peers to broadcast to");
return false;
}
try {
await Promise.all(
peerIds.map(async (peerId) => {
if (shareContent) {
await this.fileSender.sendString(shareContent, peerId);
}
if (sendFiles.length > 0) {
this.fileSender.sendFileMeta(sendFiles, peerId);
}
})
);
return true;
} catch (error) {
console.error("[WebRTC Service] Broadcast failed:", error);
return false;
}
}
public requestFile(fileId: string): void {
this.fileReceiver.requestFile(fileId);
}
public requestFolder(folderName: string): void {
this.fileReceiver.requestFolder(folderName);
}
public async setReceiverDirectoryHandle(
directoryHandle: FileSystemDirectoryHandle
): Promise<void> {
return this.fileReceiver.setSaveDirectory(directoryHandle);
}
public getReceiverSaveType(): { [fileId: string]: boolean } | undefined {
return this.fileReceiver.saveType;
}
private handleConnectionDisconnect(peerId: string, isSender: boolean, reason: string): void {
console.log(`[WebRTC Service] Connection disconnect: ${reason}, peer: ${peerId}, sender: ${isSender}`);
// Immediately clean up the transfer status to avoid UI freezing
this.immediateTransferCleanup(peerId, isSender, reason);
// update connection state
this.updateConnectionState(peerId, isSender);
}
// Immediately clean up the transfer status
private immediateTransferCleanup(peerId: string, isSender: boolean, reason: string): void {
const store = useFileTransferStore.getState();
if (isSender) {
// Sender disconnected: clean up the sender related status
this.clearPeerTransferProgress(peerId, true);
} else {
// Receiver side: sender disconnected, need to clean up the receiver status
const { isAnyFileTransferring } = store;
if (isAnyFileTransferring) {
console.log(`[WebRTC Service] Force cleaning receiver due to sender disconnect: ${reason}`);
// Catch the error that gracefulShutdown may throw
try {
this.fileReceiver.gracefulShutdown(`SENDER_${reason}`);
} catch (error) {
console.log(`[WebRTC Service] Expected error during graceful shutdown:`, error instanceof Error ? error.message : String(error));
}
}
this.clearPeerTransferProgress(peerId, false);
}
}
// update connection state
private updateConnectionState(_peerId: string, isSender: boolean): void {
const store = useFileTransferStore.getState();
if (isSender) {
// Sender disconnected: clean up the sender related status
const currentShareCount = store.sharePeerCount;
store.setSharePeerCount(Math.max(0, currentShareCount - 1));
console.log(`[WebRTC Service] Sender peer count: ${currentShareCount}${Math.max(0, currentShareCount - 1)}`);
} else {
// Receiver side: sender disconnected, need to clean up the receiver status
store.setRetrievePeerCount(0);
store.setSenderDisconnected(true);
console.log(`[WebRTC Service] Receiver peer count set to 0`);
}
}
// Clear all transfer progress
private clearAllTransferProgress(): void {
const store = useFileTransferStore.getState();
store.setSendProgress({});
store.setReceiveProgress({});
store.setIsAnyFileTransferring(false);
console.log(`[WebRTC Service] Cleared all transfer progress`);
}
private clearPeerTransferProgress(peerId: string, isSender: boolean): void {
const store = useFileTransferStore.getState();
const progressState = isSender ? store.sendProgress : store.receiveProgress;
// Clear transfer progress for this peer
const newProgress = { ...progressState };
Object.keys(newProgress).forEach((fileId) => {
if (newProgress[fileId][peerId]) {
delete newProgress[fileId][peerId];
// If no other peers are transferring this file, remove the file record
if (Object.keys(newProgress[fileId]).length === 0) {
delete newProgress[fileId];
}
}
});
if (isSender) {
store.setSendProgress(newProgress);
} else {
store.setReceiveProgress(newProgress);
}
// Recalculate isAnyFileTransferring status
const allProgress = [
...Object.values(isSender ? newProgress : store.sendProgress),
...Object.values(isSender ? store.receiveProgress : newProgress),
];
const hasActiveTransfers = allProgress.some((fileProgress: any) => {
return Object.values(fileProgress).some((progress: any) => {
return progress.progress > 0 && progress.progress < 1;
});
});
if (!hasActiveTransfers) {
store.setIsAnyFileTransferring(false);
}
}
public async cleanup(): Promise<void> {
console.log("[WebRTC Service] Starting cleanup...");
try {
await Promise.all([
this.sender.cleanUpBeforeExit(),
this.receiver.cleanUpBeforeExit(),
]);
console.log("[WebRTC Service] Cleanup completed");
} catch (error) {
console.error("[WebRTC Service] Error during cleanup:", error);
}
}
}
export const webrtcService = WebRTCService.getInstance();