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.
@@ -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
@@ -0,0 +1,7 @@
1
+ // src/index.ts
2
+ export * from './types'
3
+
4
+ export { EventEmitter } from './event-emitter'
5
+ export { DownloadHelper } from './download-helper'
6
+ export { FilePizzaDownloader } from './filepizza-downloader'
7
+ export { FilePizzaUploader } from './filepizza-uploader'