filepizza-client 0.1.0 → 2.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +64 -0
- package/package.json +18 -6
- package/src/download-helper.ts +84 -0
- package/src/event-emitter.ts +71 -0
- package/src/filepizza-downloader.ts +789 -0
- package/src/filepizza-uploader.ts +668 -0
- package/src/index.ts +7 -0
- package/src/types.ts +83 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import Peer, { DataConnection } from 'peerjs'
|
|
2
|
+
import { EventEmitter } from './event-emitter'
|
|
3
|
+
import { FileInfo, ProgressInfo, ConnectionInfo, ConnectionStatus, MessageType } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* FilePizza Uploader - connects to the FilePizza server and uploads files
|
|
7
|
+
*/
|
|
8
|
+
export class FilePizzaUploader extends EventEmitter {
|
|
9
|
+
private peer?: Peer;
|
|
10
|
+
private connections: Map<string, any> = new Map();
|
|
11
|
+
private connectionInfoMap = new Map<string, any>();
|
|
12
|
+
private files: File[] = [];
|
|
13
|
+
private password?: string;
|
|
14
|
+
private filePizzaServerUrl: string;
|
|
15
|
+
private channelInfo?: { longSlug: string; shortSlug: string; secret?: string };
|
|
16
|
+
private sharedSlug?: string;
|
|
17
|
+
private iceServers?: RTCIceServer[];
|
|
18
|
+
private renewalTimer?: NodeJS.Timeout;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a new FilePizza uploader
|
|
22
|
+
* @param options Configuration options
|
|
23
|
+
*/
|
|
24
|
+
constructor(options: {
|
|
25
|
+
filePizzaServerUrl?: string;
|
|
26
|
+
password?: string;
|
|
27
|
+
sharedSlug?: string;
|
|
28
|
+
} = {}) {
|
|
29
|
+
super();
|
|
30
|
+
this.filePizzaServerUrl = options.filePizzaServerUrl || 'http://localhost:8081';
|
|
31
|
+
this.password = options.password;
|
|
32
|
+
this.sharedSlug = options.sharedSlug;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Initialize the uploader
|
|
37
|
+
*/
|
|
38
|
+
async initialize(): Promise<void> {
|
|
39
|
+
if (this.peer) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Get ICE servers
|
|
44
|
+
await this.getIceServers();
|
|
45
|
+
|
|
46
|
+
// Initialize PeerJS
|
|
47
|
+
this.peer = new Peer({
|
|
48
|
+
config: {
|
|
49
|
+
iceServers: this.iceServers || [{ urls: 'stun:stun.l.google.com:19302' }],
|
|
50
|
+
},
|
|
51
|
+
debug: 2,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Wait for peer to be ready
|
|
55
|
+
if (!this.peer.id) {
|
|
56
|
+
await new Promise<void>((resolve) => {
|
|
57
|
+
const onOpen = () => {
|
|
58
|
+
this.peer?.off('open', onOpen);
|
|
59
|
+
resolve();
|
|
60
|
+
};
|
|
61
|
+
this.peer?.on('open', onOpen);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Set up connection handling
|
|
66
|
+
this.peer.on('connection', this.handleConnection.bind(this));
|
|
67
|
+
|
|
68
|
+
// Create channel
|
|
69
|
+
if (this.peer.id) {
|
|
70
|
+
await this.createChannel(this.peer.id, this.sharedSlug || undefined);
|
|
71
|
+
this.startChannelRenewal();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setPassword(password: string): void {
|
|
76
|
+
this.password = password
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Set files to be shared
|
|
81
|
+
*/
|
|
82
|
+
setFiles(files: File[]): void {
|
|
83
|
+
this.files = Array.from(files);
|
|
84
|
+
|
|
85
|
+
// Update file info for existing connections
|
|
86
|
+
if (this.files.length > 0) {
|
|
87
|
+
for (const [_, connection] of this.connections.entries()) {
|
|
88
|
+
if (connection.status === ConnectionStatus.Ready) {
|
|
89
|
+
connection.dataConnection.send({
|
|
90
|
+
type: MessageType.Info,
|
|
91
|
+
files: this.getFileInfo(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get shareable links for the current channel
|
|
100
|
+
*/
|
|
101
|
+
getShareableLinks(): { long: string; short: string } | null {
|
|
102
|
+
if (!this.channelInfo) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
long: `${this.filePizzaServerUrl}/download/${this.channelInfo.longSlug}`,
|
|
108
|
+
short: `${this.filePizzaServerUrl}/download/${this.channelInfo.shortSlug}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Stop sharing and clean up
|
|
114
|
+
*/
|
|
115
|
+
async stop(): Promise<void> {
|
|
116
|
+
// Stop channel renewal
|
|
117
|
+
if (this.renewalTimer) {
|
|
118
|
+
clearTimeout(this.renewalTimer);
|
|
119
|
+
this.renewalTimer = undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Destroy channel if we have one
|
|
123
|
+
if (this.channelInfo) {
|
|
124
|
+
try {
|
|
125
|
+
await this.destroyChannel(this.channelInfo.shortSlug);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error('Error destroying channel:', error);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Close all connections
|
|
132
|
+
for (const [_, connection] of this.connections.entries()) {
|
|
133
|
+
if (connection.dataConnection.open) {
|
|
134
|
+
connection.dataConnection.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Clear connections
|
|
139
|
+
this.connections.clear();
|
|
140
|
+
|
|
141
|
+
// Destroy peer
|
|
142
|
+
if (this.peer) {
|
|
143
|
+
this.peer.destroy();
|
|
144
|
+
this.peer = undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Reset state
|
|
148
|
+
this.channelInfo = undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get ICE servers from the FilePizza server
|
|
153
|
+
*/
|
|
154
|
+
private async getIceServers(): Promise<RTCIceServer[]> {
|
|
155
|
+
try {
|
|
156
|
+
const response = await fetch(`${this.filePizzaServerUrl}/api/ice`, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
throw new Error(`Failed to get ICE servers: ${response.status}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const data = await response.json();
|
|
165
|
+
this.iceServers = data.iceServers;
|
|
166
|
+
return data.iceServers;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error('Error getting ICE servers:', error);
|
|
169
|
+
return [{ urls: 'stun:stun.l.google.com:19302' }];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Create a new channel on the FilePizza server
|
|
175
|
+
*/
|
|
176
|
+
private async createChannel(uploaderPeerID: string, sharedSlug?: string): Promise<void> {
|
|
177
|
+
try {
|
|
178
|
+
const payload: { uploaderPeerID: string; sharedSlug?: string } = { uploaderPeerID };
|
|
179
|
+
|
|
180
|
+
if (sharedSlug) {
|
|
181
|
+
payload.sharedSlug = sharedSlug;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const response = await fetch(`${this.filePizzaServerUrl}/api/create`, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'Content-Type': 'application/json' },
|
|
187
|
+
body: JSON.stringify(payload),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
throw new Error(`Failed to create channel: ${response.status}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this.channelInfo = await response.json();
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error('Error creating channel:', error);
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Renew the channel to keep it alive
|
|
203
|
+
*/
|
|
204
|
+
private async renewChannel(): Promise<void> {
|
|
205
|
+
if (!this.channelInfo || !this.channelInfo.secret) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const response = await fetch(`${this.filePizzaServerUrl}/api/renew`, {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: { 'Content-Type': 'application/json' },
|
|
213
|
+
body: JSON.stringify({
|
|
214
|
+
slug: this.channelInfo.shortSlug,
|
|
215
|
+
secret: this.channelInfo.secret,
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
throw new Error(`Failed to renew channel: ${response.status}`);
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error('Error renewing channel:', error);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Destroy a channel
|
|
229
|
+
*/
|
|
230
|
+
private async destroyChannel(slug: string): Promise<void> {
|
|
231
|
+
try {
|
|
232
|
+
const response = await fetch(`${this.filePizzaServerUrl}/api/destroy`, {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'Content-Type': 'application/json' },
|
|
235
|
+
body: JSON.stringify({ slug }),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (!response.ok) {
|
|
239
|
+
throw new Error(`Failed to destroy channel: ${response.status}`);
|
|
240
|
+
}
|
|
241
|
+
} catch (error) {
|
|
242
|
+
console.error('Error destroying channel:', error);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Start channel renewal
|
|
248
|
+
*/
|
|
249
|
+
private startChannelRenewal(): void {
|
|
250
|
+
if (!this.channelInfo || !this.channelInfo.secret) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Renew every 30 minutes
|
|
255
|
+
const renewalInterval = 30 * 60 * 1000;
|
|
256
|
+
|
|
257
|
+
this.renewalTimer = setInterval(() => {
|
|
258
|
+
this.renewChannel();
|
|
259
|
+
}, renewalInterval);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Handle new connection
|
|
264
|
+
*/
|
|
265
|
+
private handleConnection(conn: DataConnection): void {
|
|
266
|
+
// Ignore connections for reporting (handled separately)
|
|
267
|
+
if (conn.metadata?.type === 'report') {
|
|
268
|
+
this.emit('report', conn.peer);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log(`[FilePizzaUploader] New connection from ${conn.peer}`);
|
|
273
|
+
|
|
274
|
+
const connectionContext = {
|
|
275
|
+
status: ConnectionStatus.Pending,
|
|
276
|
+
dataConnection: conn,
|
|
277
|
+
fileIndex: 0,
|
|
278
|
+
filesInfo: this.getFileInfo(),
|
|
279
|
+
totalFiles: this.files.length,
|
|
280
|
+
bytesTransferred: 0,
|
|
281
|
+
totalBytes: this.getTotalBytes(),
|
|
282
|
+
currentFileProgress: 0,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
this.connections.set(conn.peer, connectionContext);
|
|
286
|
+
|
|
287
|
+
// Set up event handlers
|
|
288
|
+
conn.on('data', (data) => this.handleData(conn, data));
|
|
289
|
+
conn.on('close', () => this.handleClose(conn));
|
|
290
|
+
conn.on('error', (error) => this.handleError(conn, error));
|
|
291
|
+
|
|
292
|
+
// Emit connection event
|
|
293
|
+
this.emit('connection', this.getConnectionInfo(conn.peer));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Handle data messages from connection
|
|
298
|
+
*/
|
|
299
|
+
private handleData(conn: DataConnection, data: unknown): void {
|
|
300
|
+
const context = this.connections.get(conn.peer);
|
|
301
|
+
if (!context) return;
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
// WebRTC messages follow a specific format with a type field
|
|
305
|
+
const message = data as any;
|
|
306
|
+
|
|
307
|
+
switch (message.type) {
|
|
308
|
+
case MessageType.RequestInfo:
|
|
309
|
+
this.handleRequestInfo(conn, context, message);
|
|
310
|
+
break;
|
|
311
|
+
|
|
312
|
+
case MessageType.UsePassword:
|
|
313
|
+
this.handleUsePassword(conn, context, message);
|
|
314
|
+
break;
|
|
315
|
+
|
|
316
|
+
case MessageType.Start:
|
|
317
|
+
this.handleStart(conn, context, message);
|
|
318
|
+
break;
|
|
319
|
+
|
|
320
|
+
case MessageType.Pause:
|
|
321
|
+
this.handlePause(conn, context);
|
|
322
|
+
break;
|
|
323
|
+
|
|
324
|
+
case MessageType.Resume:
|
|
325
|
+
this.handleResume(conn, context, message);
|
|
326
|
+
break;
|
|
327
|
+
|
|
328
|
+
case MessageType.Done:
|
|
329
|
+
this.handleDone(conn, context);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
} catch (error) {
|
|
333
|
+
console.error('[FilePizzaUploader] Error handling message:', error);
|
|
334
|
+
conn.send({
|
|
335
|
+
type: MessageType.Error,
|
|
336
|
+
error: 'Failed to process message',
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Handle connection close
|
|
343
|
+
*/
|
|
344
|
+
private handleClose(conn: DataConnection): void {
|
|
345
|
+
const context = this.connections.get(conn.peer);
|
|
346
|
+
if (!context) return;
|
|
347
|
+
|
|
348
|
+
// Update connection status
|
|
349
|
+
context.status = ConnectionStatus.Closed;
|
|
350
|
+
|
|
351
|
+
// Emit connection closed event
|
|
352
|
+
this.emit('disconnection', conn.peer);
|
|
353
|
+
|
|
354
|
+
// Remove connection
|
|
355
|
+
this.connections.delete(conn.peer);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Handle connection error
|
|
360
|
+
*/
|
|
361
|
+
private handleError(conn: DataConnection, error: Error): void {
|
|
362
|
+
const context = this.connections.get(conn.peer);
|
|
363
|
+
if (!context) return;
|
|
364
|
+
|
|
365
|
+
// Update connection status
|
|
366
|
+
context.status = ConnectionStatus.Error;
|
|
367
|
+
|
|
368
|
+
// Emit error event
|
|
369
|
+
this.emit('error', { connectionId: conn.peer, error });
|
|
370
|
+
|
|
371
|
+
// Close connection
|
|
372
|
+
if (conn.open) {
|
|
373
|
+
conn.close();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Handle RequestInfo message
|
|
379
|
+
*/
|
|
380
|
+
private handleRequestInfo(conn: DataConnection, context: any, message: any): void {
|
|
381
|
+
// Store browser info in connection metadata
|
|
382
|
+
this.connectionInfoMap.set(conn.connectionId, {
|
|
383
|
+
browserName: message.browserName,
|
|
384
|
+
browserVersion: message.browserVersion,
|
|
385
|
+
osName: message.osName,
|
|
386
|
+
osVersion: message.osVersion,
|
|
387
|
+
mobileVendor: message.mobileVendor,
|
|
388
|
+
mobileModel: message.mobileModel,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Check if password is required
|
|
392
|
+
if (this.password) {
|
|
393
|
+
conn.send({
|
|
394
|
+
type: MessageType.PasswordRequired,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
context.status = ConnectionStatus.Authenticating;
|
|
398
|
+
} else {
|
|
399
|
+
// Send file info
|
|
400
|
+
conn.send({
|
|
401
|
+
type: MessageType.Info,
|
|
402
|
+
files: context.filesInfo,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
context.status = ConnectionStatus.Ready;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Emit connection update
|
|
409
|
+
this.emit('connectionUpdate', this.getConnectionInfo(conn.peer));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Handle UsePassword message
|
|
414
|
+
*/
|
|
415
|
+
private handleUsePassword(conn: DataConnection, context: any, message: any): void {
|
|
416
|
+
// Check password
|
|
417
|
+
if (message.password === this.password) {
|
|
418
|
+
// Password correct, send file info
|
|
419
|
+
conn.send({
|
|
420
|
+
type: MessageType.Info,
|
|
421
|
+
files: context.filesInfo,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
context.status = ConnectionStatus.Ready;
|
|
425
|
+
} else {
|
|
426
|
+
// Password incorrect
|
|
427
|
+
conn.send({
|
|
428
|
+
type: MessageType.PasswordRequired,
|
|
429
|
+
errorMessage: 'Incorrect password',
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
context.status = ConnectionStatus.InvalidPassword;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Emit connection update
|
|
436
|
+
this.emit('connectionUpdate', this.getConnectionInfo(conn.peer));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Handle Start message
|
|
441
|
+
*/
|
|
442
|
+
private handleStart(conn: DataConnection, context: any, message: any): void {
|
|
443
|
+
// Find the requested file
|
|
444
|
+
const fileName = message.fileName;
|
|
445
|
+
const offset = message.offset;
|
|
446
|
+
|
|
447
|
+
const file = this.findFile(fileName);
|
|
448
|
+
if (!file) {
|
|
449
|
+
conn.send({
|
|
450
|
+
type: MessageType.Error,
|
|
451
|
+
error: `File not found: ${fileName}`,
|
|
452
|
+
});
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Update connection status
|
|
457
|
+
context.status = ConnectionStatus.Uploading;
|
|
458
|
+
context.uploadingFileName = fileName;
|
|
459
|
+
context.uploadingOffset = offset;
|
|
460
|
+
|
|
461
|
+
// Emit status update
|
|
462
|
+
this.emit('connectionUpdate', this.getConnectionInfo(conn.peer));
|
|
463
|
+
|
|
464
|
+
// Begin sending file chunks
|
|
465
|
+
this.sendFileChunks(conn, context, file, offset);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Handle Pause message
|
|
470
|
+
*/
|
|
471
|
+
private handlePause(conn: DataConnection, context: any): void {
|
|
472
|
+
context.status = ConnectionStatus.Paused;
|
|
473
|
+
this.emit('connectionUpdate', this.getConnectionInfo(conn.peer));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Handle Resume message
|
|
478
|
+
*/
|
|
479
|
+
private handleResume(conn: DataConnection, context: any, message: any): void {
|
|
480
|
+
const fileName = message.fileName;
|
|
481
|
+
const offset = message.offset;
|
|
482
|
+
|
|
483
|
+
const file = this.findFile(fileName);
|
|
484
|
+
if (!file) {
|
|
485
|
+
conn.send({
|
|
486
|
+
type: MessageType.Error,
|
|
487
|
+
error: `File not found: ${fileName}`,
|
|
488
|
+
});
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
context.status = ConnectionStatus.Uploading;
|
|
493
|
+
context.uploadingFileName = fileName;
|
|
494
|
+
context.uploadingOffset = offset;
|
|
495
|
+
|
|
496
|
+
this.emit('connectionUpdate', this.getConnectionInfo(conn.peer));
|
|
497
|
+
|
|
498
|
+
this.sendFileChunks(conn, context, file, offset);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Handle Done message
|
|
503
|
+
*/
|
|
504
|
+
private handleDone(conn: DataConnection, context: any): void {
|
|
505
|
+
context.status = ConnectionStatus.Done;
|
|
506
|
+
this.emit('connectionUpdate', this.getConnectionInfo(conn.peer));
|
|
507
|
+
conn.close();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Send file chunks to the downloader
|
|
512
|
+
*/
|
|
513
|
+
private sendFileChunks(
|
|
514
|
+
conn: DataConnection,
|
|
515
|
+
context: any,
|
|
516
|
+
file: File,
|
|
517
|
+
startOffset: number
|
|
518
|
+
): void {
|
|
519
|
+
let offset = startOffset;
|
|
520
|
+
const CHUNK_SIZE = 256 * 1024; // 256 KB
|
|
521
|
+
|
|
522
|
+
const sendNextChunk = () => {
|
|
523
|
+
// Check if connection is still open and in uploading state
|
|
524
|
+
if (!conn.open || context.status !== ConnectionStatus.Uploading) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const end = Math.min(file.size, offset + CHUNK_SIZE);
|
|
529
|
+
const chunkSize = end - offset;
|
|
530
|
+
const final = end >= file.size;
|
|
531
|
+
|
|
532
|
+
// Create chunk
|
|
533
|
+
const chunk = file.slice(offset, end);
|
|
534
|
+
|
|
535
|
+
// Send chunk
|
|
536
|
+
conn.send({
|
|
537
|
+
type: MessageType.Chunk,
|
|
538
|
+
fileName: file.name,
|
|
539
|
+
offset,
|
|
540
|
+
bytes: chunk,
|
|
541
|
+
final,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Update progress
|
|
545
|
+
offset = end;
|
|
546
|
+
context.uploadingOffset = offset;
|
|
547
|
+
context.currentFileProgress = offset / file.size;
|
|
548
|
+
context.bytesTransferred += chunkSize;
|
|
549
|
+
|
|
550
|
+
// Emit progress update
|
|
551
|
+
this.emit('progress', this.getProgressInfo(conn.peer));
|
|
552
|
+
|
|
553
|
+
// If this was the final chunk
|
|
554
|
+
if (final) {
|
|
555
|
+
if (context.fileIndex < context.totalFiles - 1) {
|
|
556
|
+
// Move to next file
|
|
557
|
+
context.fileIndex++;
|
|
558
|
+
context.currentFileProgress = 0;
|
|
559
|
+
context.status = ConnectionStatus.Ready;
|
|
560
|
+
|
|
561
|
+
// Emit update
|
|
562
|
+
this.emit('connectionUpdate', this.getConnectionInfo(conn.peer));
|
|
563
|
+
} else {
|
|
564
|
+
// All files completed
|
|
565
|
+
context.fileIndex = context.totalFiles;
|
|
566
|
+
context.currentFileProgress = 1;
|
|
567
|
+
context.status = ConnectionStatus.Done;
|
|
568
|
+
|
|
569
|
+
// Emit update
|
|
570
|
+
this.emit('connectionUpdate', this.getConnectionInfo(conn.peer));
|
|
571
|
+
}
|
|
572
|
+
} else {
|
|
573
|
+
// Schedule next chunk
|
|
574
|
+
setTimeout(sendNextChunk, 0);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
// Start sending chunks
|
|
579
|
+
sendNextChunk();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Find a file by name
|
|
584
|
+
*/
|
|
585
|
+
private findFile(fileName: string): File | undefined {
|
|
586
|
+
return this.files.find(file => file.name === fileName);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Get file info for all files
|
|
591
|
+
*/
|
|
592
|
+
private getFileInfo(): FileInfo[] {
|
|
593
|
+
return this.files.map(file => ({
|
|
594
|
+
fileName: file.name,
|
|
595
|
+
size: file.size,
|
|
596
|
+
type: file.type,
|
|
597
|
+
}));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Get connection info for a specific connection
|
|
602
|
+
*/
|
|
603
|
+
private getConnectionInfo(peerId: string): ConnectionInfo {
|
|
604
|
+
const context = this.connections.get(peerId);
|
|
605
|
+
if (!context) {
|
|
606
|
+
throw new Error(`Connection not found: ${peerId}`);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
id: peerId,
|
|
611
|
+
status: context.status,
|
|
612
|
+
browserName: context.dataConnection.metadata?.browserName,
|
|
613
|
+
browserVersion: context.dataConnection.metadata?.browserVersion,
|
|
614
|
+
osName: context.dataConnection.metadata?.osName,
|
|
615
|
+
osVersion: context.dataConnection.metadata?.osVersion,
|
|
616
|
+
mobileVendor: context.dataConnection.metadata?.mobileVendor,
|
|
617
|
+
mobileModel: context.dataConnection.metadata?.mobileModel,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Get connection info for all connections
|
|
623
|
+
*/
|
|
624
|
+
public getConnectionInfoAll(): ConnectionInfo[] {
|
|
625
|
+
const connectionInfos: ConnectionInfo[] = [];
|
|
626
|
+
|
|
627
|
+
for (const [peerId, context] of this.connections.entries()) {
|
|
628
|
+
const connectionInfo = this.getConnectionInfo(peerId);
|
|
629
|
+
connectionInfos.push(connectionInfo);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return connectionInfos;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Remove connection info
|
|
637
|
+
*/
|
|
638
|
+
public removeConnectionInfo(connectionId: string): void {
|
|
639
|
+
this.connectionInfoMap.delete(connectionId);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Get total bytes for all files
|
|
644
|
+
*/
|
|
645
|
+
private getTotalBytes(): number {
|
|
646
|
+
return this.files.reduce((total, file) => total + file.size, 0);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Get progress info for a specific connection
|
|
651
|
+
*/
|
|
652
|
+
private getProgressInfo(peerId: string): ProgressInfo {
|
|
653
|
+
const context = this.connections.get(peerId);
|
|
654
|
+
if (!context) {
|
|
655
|
+
throw new Error(`Connection not found: ${peerId}`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
fileIndex: context.fileIndex,
|
|
660
|
+
fileName: context.uploadingFileName || '',
|
|
661
|
+
totalFiles: context.totalFiles,
|
|
662
|
+
currentFileProgress: context.currentFileProgress,
|
|
663
|
+
overallProgress: context.bytesTransferred / context.totalBytes,
|
|
664
|
+
bytesTransferred: context.bytesTransferred,
|
|
665
|
+
totalBytes: context.totalBytes,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
}
|
package/src/index.ts
ADDED