filepizza-client 2.0.1 → 2.1.1

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.
@@ -1,807 +1,721 @@
1
1
  // src/filepizza-downloader.ts
2
2
  import Peer, { DataConnection } from 'peerjs'
3
3
  import { EventEmitter } from './event-emitter'
4
- import { DownloadHelper } from './download-helper';
5
- import { FileInfo, ProgressInfo, ConnectionStatus, CompletedFile, MessageType } from './types'
4
+ import { DownloadHelper } from './download-helper'
5
+ import {
6
+ FileInfo,
7
+ ProgressInfo,
8
+ ConnectionStatus,
9
+ CompletedFile,
10
+ MessageType,
11
+ PeerJSSignalingServer,
12
+ } from './types'
13
+ import { createPeer } from './peerjs'
14
+
15
+ type DownloaderOptions = {
16
+ filePizzaServerUrl?: string
17
+ peerJSSignalingServer?: PeerJSSignalingServer
18
+ discoverPeerJSSignalingServer?: boolean
19
+ }
20
+
21
+ type ChannelLookupResponse = {
22
+ uploaderPeerID: string
23
+ additionalUploaders?: string[]
24
+ }
6
25
 
7
26
  /**
8
- * FilePizza Downloader - connects to FilePizza uploads
27
+ * FilePizza Downloader - connects to FilePizza uploads.
9
28
  */
10
29
  export class FilePizzaDownloader extends EventEmitter {
11
- private peer?: Peer;
12
- private connection?: DataConnection;
13
- private filePizzaServerUrl: string;
14
- private filesInfo: FileInfo[] = [];
15
- private currentFileIndex = 0;
16
- private currentFileBytesReceived = 0;
17
- private totalBytesReceived = 0;
18
- private totalBytes = 0;
19
- private status = ConnectionStatus.Pending;
20
- private fileStreams: Map<string, {
21
- stream: ReadableStream<Uint8Array>;
22
- enqueue: (chunk: Uint8Array) => void;
23
- close: () => void;
24
- }> = new Map();
25
- private isPasswordRequired = false;
26
- private isPasswordInvalid = false;
27
- private errorMessage?: string;
28
- private iceServers?: RTCIceServer[];
29
- private completedFiles: CompletedFile[] = [];
30
-
31
- /**
32
- * Create a new FilePizza downloader
33
- * @param options Configuration options
34
- */
35
- constructor(options: {
36
- filePizzaServerUrl?: string;
37
- } = {}) {
38
- super();
39
- this.filePizzaServerUrl = options.filePizzaServerUrl || 'http://localhost:8081';
30
+ private peer?: Peer
31
+ private connection?: DataConnection
32
+ private filePizzaServerUrl: string
33
+ private filesInfo: FileInfo[] = []
34
+ private currentFileIndex = 0
35
+ private currentFileBytesReceived = 0
36
+ private totalBytesReceived = 0
37
+ private totalBytes = 0
38
+ private status = ConnectionStatus.Pending
39
+ private fileStreams: Map<
40
+ string,
41
+ {
42
+ stream: ReadableStream<Uint8Array>
43
+ enqueue: (chunk: Uint8Array) => void
44
+ close: () => void
45
+ }
46
+ > = new Map()
47
+ private isPasswordRequired = false
48
+ private isPasswordInvalid = false
49
+ private errorMessage?: string
50
+ private completedFiles: CompletedFile[] = []
51
+ private peerJSSignalingServer?: PeerJSSignalingServer
52
+ private discoverPeerJSSignalingServer: boolean
53
+
54
+ constructor(options: DownloaderOptions = {}) {
55
+ super()
56
+ this.filePizzaServerUrl = options.filePizzaServerUrl || 'http://localhost:8081'
57
+ this.peerJSSignalingServer = options.peerJSSignalingServer
58
+ this.discoverPeerJSSignalingServer =
59
+ options.discoverPeerJSSignalingServer ?? true
40
60
  }
41
61
 
42
- /**
43
- * Initialize the downloader
44
- */
45
62
  async initialize(): Promise<void> {
46
63
  if (this.peer) {
47
- return;
64
+ return
48
65
  }
49
66
 
50
- // Get ICE servers
51
- await this.getIceServers();
52
-
53
- // Initialize PeerJS
54
- this.peer = new Peer({
55
- config: {
56
- iceServers: this.iceServers || [{ urls: 'stun:stun.l.google.com:19302' }],
57
- },
58
- debug: 2,
59
- });
60
-
61
- // Wait for peer to be ready
62
- if (!this.peer.id) {
63
- await new Promise<void>((resolve) => {
64
- const onOpen = () => {
65
- this.peer?.off('open', onOpen);
66
- resolve();
67
- };
68
- this.peer?.on('open', onOpen);
69
- });
70
- }
67
+ this.peer = await createPeer({
68
+ filePizzaServerUrl: this.filePizzaServerUrl,
69
+ peerJSSignalingServer: this.peerJSSignalingServer,
70
+ discoverPeerJSSignalingServer: this.discoverPeerJSSignalingServer,
71
+ })
71
72
  }
72
73
 
73
- /**
74
- * Connect to an uploader using a FilePizza URL or slug
75
- */
76
74
  async connect(urlOrSlug: string): Promise<boolean> {
77
- this.resetConnectionState();
75
+ this.resetConnectionState()
78
76
 
79
- const slug = this.extractSlug(urlOrSlug);
77
+ const slug = this.extractSlug(urlOrSlug)
80
78
 
81
79
  try {
82
- const uploaderPeerID = await this.lookupUploaderPeerID(slug);
83
- return this.connectToPeer(uploaderPeerID);
80
+ const uploaderPeerID = await this.lookupUploaderPeerID(slug)
81
+ return this.connectToPeer(uploaderPeerID)
84
82
  } catch (error) {
85
- this.errorMessage = `Failed to connect: ${error instanceof Error ? error.message : String(error)}`;
86
- this.emit('error', this.errorMessage);
87
- return false;
83
+ this.errorMessage = `Failed to connect: ${error instanceof Error ? error.message : String(error)
84
+ }`
85
+ this.emit('error', this.errorMessage)
86
+ return false
88
87
  }
89
88
  }
90
89
 
91
- /**
92
- * Reset the connection state to begin new download
93
- */
94
90
  private resetConnectionState(): void {
95
- this.cleanupFileStreams();
96
-
97
- this.filesInfo = [];
98
- this.currentFileIndex = 0;
99
- this.currentFileBytesReceived = 0;
100
- this.totalBytesReceived = 0;
101
- this.totalBytes = 0;
102
- this.completedFiles = [];
103
- this.isPasswordRequired = false;
104
- this.isPasswordInvalid = false;
105
- this.errorMessage = undefined;
91
+ this.cleanupFileStreams()
92
+
93
+ this.filesInfo = []
94
+ this.currentFileIndex = 0
95
+ this.currentFileBytesReceived = 0
96
+ this.totalBytesReceived = 0
97
+ this.totalBytes = 0
98
+ this.completedFiles = []
99
+ this.isPasswordRequired = false
100
+ this.isPasswordInvalid = false
101
+ this.errorMessage = undefined
102
+ this.status = ConnectionStatus.Pending
106
103
 
107
104
  if (this.connection && this.connection.open) {
108
- this.connection.close();
105
+ this.connection.close()
109
106
  }
110
- this.connection = undefined;
107
+
108
+ this.connection = undefined
111
109
  }
112
110
 
113
- /**
114
- * Submit password for protected download
115
- */
116
111
  submitPassword(password: string): void {
117
112
  if (!this.connection || this.status !== ConnectionStatus.Authenticating) {
118
- throw new Error('Not in authentication state');
113
+ throw new Error('Not in authentication state')
119
114
  }
120
115
 
121
116
  this.connection.send({
122
117
  type: MessageType.UsePassword,
123
118
  password,
124
- });
119
+ })
125
120
  }
126
121
 
127
- /**
128
- * Start downloading the files
129
- */
130
122
  async startDownload(): Promise<void> {
131
123
  if (!this.connection) {
132
- throw new Error('Not connected');
124
+ throw new Error('Not connected')
133
125
  }
134
126
 
135
127
  if (this.filesInfo.length === 0) {
136
- throw new Error('No files available');
128
+ throw new Error('No files available')
137
129
  }
138
130
 
139
131
  if (this.status !== ConnectionStatus.Ready) {
140
- throw new Error(`Cannot start download in current state: ${this.status}`);
132
+ throw new Error(`Cannot start download in current state: ${this.status}`)
141
133
  }
142
134
 
143
- this.status = ConnectionStatus.Downloading;
144
- this.currentFileIndex = 0;
145
- this.currentFileBytesReceived = 0;
146
- this.totalBytesReceived = 0;
147
-
148
- // Initialize file streams
149
- this.initializeFileStreams();
135
+ this.status = ConnectionStatus.Downloading
136
+ this.currentFileIndex = 0
137
+ this.currentFileBytesReceived = 0
138
+ this.totalBytesReceived = 0
150
139
 
151
- // Request the first file
152
- this.requestNextFile();
140
+ this.initializeFileStreams()
141
+ this.requestNextFile()
153
142
  }
154
143
 
155
- /**
156
- * Pause the download
157
- */
158
144
  pauseDownload(): void {
159
145
  if (!this.connection || this.status !== ConnectionStatus.Downloading) {
160
- return;
146
+ return
161
147
  }
162
148
 
163
- this.connection.send({ type: MessageType.Pause });
164
- this.status = ConnectionStatus.Paused;
165
- this.emit('paused');
149
+ this.connection.send({ type: MessageType.Pause })
150
+ this.status = ConnectionStatus.Paused
151
+ this.emit('paused')
166
152
  }
167
153
 
168
154
  /**
169
- * Resume the download
155
+ * Kept for public API compatibility.
156
+ *
157
+ * The server frontend protocol currently supports pause/stop, not resume.
158
+ * A paused transfer should be restarted by creating a new connection.
170
159
  */
171
160
  resumeDownload(): void {
172
- if (!this.connection || this.status !== ConnectionStatus.Paused) {
173
- return;
161
+ if (this.status !== ConnectionStatus.Paused) {
162
+ return
174
163
  }
175
164
 
176
- const currentFile = this.filesInfo[this.currentFileIndex];
177
-
178
- this.connection.send({
179
- type: MessageType.Resume,
180
- fileName: currentFile.fileName,
181
- offset: this.currentFileBytesReceived,
182
- });
183
-
184
- this.status = ConnectionStatus.Downloading;
185
- this.emit('resumed');
165
+ this.errorMessage =
166
+ 'Resume is not supported by the current FilePizza server protocol. Please reconnect and restart the download.'
167
+ this.status = ConnectionStatus.Error
168
+ this.emit('error', this.errorMessage)
186
169
  }
187
170
 
188
- /**
189
- * Cancel the download
190
- */
191
171
  cancelDownload(): void {
192
- this.cleanupFileStreams();
172
+ this.cleanupFileStreams()
193
173
 
194
174
  if (this.connection) {
195
175
  if (this.connection.open) {
196
- this.connection.close();
176
+ this.connection.close()
197
177
  }
198
- this.connection = undefined;
178
+ this.connection = undefined
199
179
  }
200
180
 
201
- this.status = ConnectionStatus.Closed;
202
- this.emit('cancelled');
181
+ this.status = ConnectionStatus.Closed
182
+ this.emit('cancelled')
203
183
  }
204
184
 
205
- /**
206
- * Cleanup resources when done
207
- */
208
185
  private cleanupFileStreams(): void {
209
186
  for (const [fileName, fileStreamData] of this.fileStreams.entries()) {
210
187
  try {
211
188
  if (fileStreamData.stream.locked === false) {
212
- fileStreamData.close();
189
+ fileStreamData.close()
213
190
  }
214
191
  } catch (error) {
215
- console.warn(`Error closing stream for ${fileName}:`, error);
192
+ console.warn(`Error closing stream for ${fileName}:`, error)
216
193
  }
217
194
  }
218
- this.fileStreams.clear();
195
+
196
+ this.fileStreams.clear()
219
197
  }
220
198
 
221
- /**
222
- * Get file information
223
- */
224
199
  getFileInfo(): FileInfo[] {
225
- return this.filesInfo;
200
+ return this.filesInfo
226
201
  }
227
202
 
228
- /**
229
- * Get download status
230
- */
231
203
  getStatus(): {
232
- status: ConnectionStatus;
233
- isPasswordRequired: boolean;
234
- isPasswordInvalid: boolean;
235
- errorMessage?: string;
204
+ status: ConnectionStatus
205
+ isPasswordRequired: boolean
206
+ isPasswordInvalid: boolean
207
+ errorMessage?: string
236
208
  } {
237
209
  return {
238
210
  status: this.status,
239
211
  isPasswordRequired: this.isPasswordRequired,
240
212
  isPasswordInvalid: this.isPasswordInvalid,
241
213
  errorMessage: this.errorMessage,
242
- };
214
+ }
243
215
  }
244
216
 
245
- /**
246
- * Get progress information
247
- */
248
217
  getProgress(): ProgressInfo {
249
218
  return {
250
219
  fileIndex: this.currentFileIndex,
251
220
  fileName: this.filesInfo[this.currentFileIndex]?.fileName || '',
252
221
  totalFiles: this.filesInfo.length,
253
- currentFileProgress: this.currentFileBytesReceived /
222
+ currentFileProgress:
223
+ this.currentFileBytesReceived /
254
224
  (this.filesInfo[this.currentFileIndex]?.size || 1),
255
225
  overallProgress: this.totalBytesReceived / (this.totalBytes || 1),
256
226
  bytesTransferred: this.totalBytesReceived,
257
227
  totalBytes: this.totalBytes,
258
- };
228
+ }
259
229
  }
260
230
 
261
- /**
262
- * Extract slug from URL or use directly
263
- */
264
231
  private extractSlug(urlOrSlug: string): string {
265
- // Check if it's a URL
266
232
  if (urlOrSlug.startsWith('http')) {
267
- const url = new URL(urlOrSlug);
268
- const pathParts = url.pathname.split('/').filter(Boolean);
233
+ const url = new URL(urlOrSlug)
234
+ const pathParts = url.pathname.split('/').filter(Boolean)
269
235
 
270
- // Extract the download slug
271
236
  if (pathParts[0] === 'download' && pathParts.length > 1) {
272
- return pathParts.slice(1).join('/');
273
- } else {
274
- throw new Error('Invalid FilePizza URL');
237
+ return pathParts.slice(1).join('/')
275
238
  }
239
+
240
+ throw new Error('Invalid FilePizza URL')
241
+ }
242
+
243
+ return urlOrSlug
244
+ }
245
+
246
+ private async fetchChannel(slug: string): Promise<ChannelLookupResponse> {
247
+ const response = await fetch(`${this.filePizzaServerUrl}/api/channel/${slug}`)
248
+
249
+ if (!response.ok) {
250
+ throw new Error(`FilePizza server returned ${response.status}`)
276
251
  }
277
252
 
278
- // If it's not a URL, assume it's already a slug
279
- return urlOrSlug;
253
+ return response.json()
280
254
  }
281
255
 
282
256
  /**
283
- * Extract uploader peer IDs from the FilePizza server when multiple uploaders are present
257
+ * Fallback for older FilePizza servers that do not expose /api/channel/:slug.
284
258
  */
285
- private async extractUploaderPeerIDs(slug: string): Promise<string[]> {
286
- try {
287
- const response = await fetch(`${this.filePizzaServerUrl}/download/${slug}`);
259
+ private async extractUploaderPeerIDsFromHtml(slug: string): Promise<string[]> {
260
+ const response = await fetch(`${this.filePizzaServerUrl}/download/${slug}`)
288
261
 
289
- if (!response.ok) {
290
- throw new Error(`FilePizza server returned ${response.status}`);
291
- }
262
+ if (!response.ok) {
263
+ throw new Error(`FilePizza server returned ${response.status}`)
264
+ }
292
265
 
293
- const html = await response.text();
266
+ const html = await response.text()
294
267
 
295
- if (!html || html.trim() === '') {
296
- throw new Error('Received empty response from server');
297
- }
298
-
299
- // Match either "uploaderPeerID" (for single uploader) or "primaryUploaderID" and "additionalUploaders" (for multiple)
300
- const primaryMatch = html.match(/\\"primaryUploaderID\\":\\"([^\\]+)\\"/);
301
- const singleMatch = html.match(/\\"uploaderPeerID\\":\\"([^\\]+)\\"/);
268
+ if (!html || html.trim() === '') {
269
+ throw new Error('Received empty response from server')
270
+ }
302
271
 
303
- if (primaryMatch && primaryMatch[1]) {
304
- // Handle multiple uploader case
305
- const uploaderIDs = [primaryMatch[1]];
272
+ const primaryMatch = html.match(/\\"primaryUploaderID\\":\\"([^\\]+)\\"/)
273
+ const singleMatch = html.match(/\\"uploaderPeerID\\":\\"([^\\]+)\\"/)
306
274
 
307
- // Look for additional uploaders
308
- const additionalMatch = html.match(/\\"additionalUploaders\\":\[([^\]]+)\]/);
309
- if (additionalMatch && additionalMatch[1]) {
310
- // Parse additional uploader IDs from JSON string
311
- const additionalIDs = additionalMatch[1].split(',')
312
- .map(id => id.trim().replace(/\\"|"/g, ''))
313
- .filter(id => id.length > 0);
275
+ if (primaryMatch && primaryMatch[1]) {
276
+ const uploaderIDs = [primaryMatch[1]]
314
277
 
315
- uploaderIDs.push(...additionalIDs);
316
- }
278
+ const additionalMatch = html.match(/\\"additionalUploaders\\":\[([^\]]+)\]/)
279
+ if (additionalMatch && additionalMatch[1]) {
280
+ const additionalIDs = additionalMatch[1]
281
+ .split(',')
282
+ .map((id) => id.trim().replace(/\\"|"/g, ''))
283
+ .filter((id) => id.length > 0)
317
284
 
318
- return uploaderIDs;
319
- } else if (singleMatch && singleMatch[1]) {
320
- // Handle single uploader case
321
- return [singleMatch[1]];
285
+ uploaderIDs.push(...additionalIDs)
322
286
  }
323
287
 
324
- throw new Error('Could not find uploader peer ID');
325
- } catch (error) {
326
- console.error('Error extracting uploader peer IDs:', error);
327
- throw new Error('Failed to look up uploader information');
288
+ return uploaderIDs
328
289
  }
290
+
291
+ if (singleMatch && singleMatch[1]) {
292
+ return [singleMatch[1]]
293
+ }
294
+
295
+ throw new Error('Could not find uploader peer ID')
329
296
  }
330
297
 
331
- /**
332
- * Look up the uploader's peer ID from the FilePizza server
333
- */
334
- private async lookupUploaderPeerID(slug: string): Promise<string> {
298
+ private async extractUploaderPeerIDs(slug: string): Promise<string[]> {
335
299
  try {
336
- const uploaderIDs = await this.extractUploaderPeerIDs(slug);
300
+ const channel = await this.fetchChannel(slug)
301
+ return [
302
+ channel.uploaderPeerID,
303
+ ...(channel.additionalUploaders || []),
304
+ ].filter(Boolean)
305
+ } catch (error) {
306
+ console.warn(
307
+ 'Falling back to HTML parsing because /api/channel lookup failed:',
308
+ error,
309
+ )
337
310
 
338
- if (uploaderIDs.length === 0) {
339
- throw new Error('No uploader peer IDs found');
340
- }
311
+ return this.extractUploaderPeerIDsFromHtml(slug)
312
+ }
313
+ }
341
314
 
342
- // Return the first uploader ID by default
343
- return uploaderIDs[0];
344
- } catch (error) {
345
- console.error('Error looking up uploader peer ID:', error);
346
- throw new Error('Failed to look up uploader information');
315
+ private async lookupUploaderPeerID(slug: string): Promise<string> {
316
+ const uploaderIDs = await this.extractUploaderPeerIDs(slug)
317
+
318
+ if (uploaderIDs.length === 0) {
319
+ throw new Error('No uploader peer IDs found')
347
320
  }
321
+
322
+ return uploaderIDs[0]
348
323
  }
349
324
 
350
- /**
351
- * Connect to a specific uploader by ID
352
- */
353
325
  public async getAvailableUploaders(urlOrSlug: string): Promise<string[]> {
354
- const slug = this.extractSlug(urlOrSlug);
355
- return this.extractUploaderPeerIDs(slug);
326
+ const slug = this.extractSlug(urlOrSlug)
327
+ return this.extractUploaderPeerIDs(slug)
356
328
  }
357
329
 
358
- /**
359
- * Connect to a specific uploader by ID
360
- */
361
330
  public async connectToUploader(uploaderId: string): Promise<boolean> {
362
331
  try {
363
- return this.connectToPeer(uploaderId);
332
+ return this.connectToPeer(uploaderId)
364
333
  } catch (error) {
365
- this.errorMessage = `Failed to connect to uploader: ${error instanceof Error ? error.message : String(error)}`;
366
- this.emit('error', this.errorMessage);
367
- return false;
334
+ this.errorMessage = `Failed to connect to uploader: ${error instanceof Error ? error.message : String(error)
335
+ }`
336
+ this.emit('error', this.errorMessage)
337
+ return false
368
338
  }
369
339
  }
370
340
 
371
- /**
372
- * Get ICE servers from the FilePizza server
373
- */
374
- private async getIceServers(): Promise<RTCIceServer[]> {
375
- try {
376
- const response = await fetch(`${this.filePizzaServerUrl}/api/ice`, {
377
- method: 'POST',
378
- });
379
-
380
- if (!response.ok) {
381
- throw new Error(`Failed to get ICE servers: ${response.status}`);
382
- }
383
-
384
- const data = await response.json();
385
- this.iceServers = data.iceServers;
386
- return data.iceServers;
387
- } catch (error) {
388
- console.error('Error getting ICE servers:', error);
389
- return [{ urls: 'stun:stun.l.google.com:19302' }];
390
- }
391
- }
392
-
393
- /**
394
- * Connect directly to a peer
395
- */
396
341
  private async connectToPeer(peerId: string): Promise<boolean> {
397
- // Make sure we're initialized
398
- await this.initialize();
342
+ await this.initialize()
399
343
 
400
344
  if (!this.peer) {
401
- throw new Error('Peer not initialized');
345
+ throw new Error('Peer not initialized')
402
346
  }
403
347
 
404
348
  try {
405
- // Close existing connection if any
406
349
  if (this.connection) {
407
- this.connection.close();
350
+ this.connection.close()
408
351
  }
409
352
 
410
- // Connect to the uploader
411
- this.connection = this.peer.connect(peerId, { reliable: true });
412
- this.status = ConnectionStatus.Pending;
353
+ this.connection = this.peer.connect(peerId, { reliable: true })
354
+ this.status = ConnectionStatus.Pending
413
355
 
414
- // Set up connection event handlers
415
356
  return new Promise<boolean>((resolve) => {
416
357
  if (!this.connection) {
417
- resolve(false);
418
- return;
358
+ resolve(false)
359
+ return
419
360
  }
420
361
 
421
362
  this.connection.on('open', () => {
422
- this.status = ConnectionStatus.Ready;
423
-
424
- // Send request for file info
425
- if (this.connection) {
426
- this.connection.send({
427
- type: MessageType.RequestInfo,
428
- browserName: this.getBrowserName(),
429
- browserVersion: this.getBrowserVersion(),
430
- osName: this.getOSName(),
431
- osVersion: this.getOSVersion(),
432
- mobileVendor: this.getMobileVendor(),
433
- mobileModel: this.getMobileModel(),
434
- });
435
- }
363
+ this.status = ConnectionStatus.Ready
364
+
365
+ this.connection?.send({
366
+ type: MessageType.RequestInfo,
367
+ browserName: this.getBrowserName(),
368
+ browserVersion: this.getBrowserVersion(),
369
+ osName: this.getOSName(),
370
+ osVersion: this.getOSVersion(),
371
+ mobileVendor: this.getMobileVendor(),
372
+ mobileModel: this.getMobileModel(),
373
+ })
436
374
 
437
- this.emit('connected');
438
- resolve(true);
439
- });
375
+ this.emit('connected')
376
+ resolve(true)
377
+ })
440
378
 
441
- this.connection.on('data', this.handleData.bind(this));
379
+ this.connection.on('data', this.handleData.bind(this))
442
380
 
443
381
  this.connection.on('close', () => {
444
- this.status = ConnectionStatus.Closed;
445
- this.emit('disconnected');
446
- });
382
+ this.status = ConnectionStatus.Closed
383
+ this.emit('disconnected')
384
+ })
447
385
 
448
386
  this.connection.on('error', (error) => {
449
- this.errorMessage = `Connection error: ${error.message}`;
450
- this.status = ConnectionStatus.Error;
451
- this.emit('error', this.errorMessage);
452
- });
453
- });
387
+ this.errorMessage = `Connection error: ${error.message}`
388
+ this.status = ConnectionStatus.Error
389
+ this.emit('error', this.errorMessage)
390
+ })
391
+ })
454
392
  } catch (error) {
455
- this.errorMessage = `Failed to connect: ${error instanceof Error ? error.message : String(error)}`;
456
- this.emit('error', this.errorMessage);
457
- return false;
393
+ this.errorMessage = `Failed to connect: ${error instanceof Error ? error.message : String(error)
394
+ }`
395
+ this.emit('error', this.errorMessage)
396
+ return false
458
397
  }
459
398
  }
460
399
 
461
- /**
462
- * Handle incoming data from uploader
463
- */
464
400
  private handleData(data: unknown): void {
465
401
  try {
466
- // WebRTC messages follow a specific format with a type field
467
- const message = data as any;
402
+ const message = data as any
468
403
 
469
404
  switch (message.type) {
470
405
  case MessageType.PasswordRequired:
471
- this.handlePasswordRequired(message);
472
- break;
406
+ this.handlePasswordRequired(message)
407
+ break
473
408
 
474
409
  case MessageType.Info:
475
- this.handleInfo(message);
476
- break;
410
+ this.handleInfo(message)
411
+ break
477
412
 
478
413
  case MessageType.Chunk:
479
- this.handleChunk(message);
480
- break;
414
+ this.handleChunk(message)
415
+ break
481
416
 
482
417
  case MessageType.Error:
483
- this.handleError(message);
484
- break;
418
+ this.handleError(message)
419
+ break
485
420
 
486
421
  case MessageType.Report:
487
- this.handleReport();
488
- break;
422
+ this.handleReport()
423
+ break
489
424
  }
490
425
  } catch (error) {
491
- console.error('Error handling message:', error);
492
- this.errorMessage = `Error processing data: ${error instanceof Error ? error.message : String(error)}`;
493
- this.emit('error', this.errorMessage);
426
+ console.error('Error handling message:', error)
427
+ this.errorMessage = `Error processing data: ${error instanceof Error ? error.message : String(error)
428
+ }`
429
+ this.emit('error', this.errorMessage)
494
430
  }
495
431
  }
496
432
 
497
- /**
498
- * Handle password required message
499
- */
500
433
  private handlePasswordRequired(message: any): void {
501
- this.isPasswordRequired = true;
502
- this.status = ConnectionStatus.Authenticating;
434
+ this.isPasswordRequired = true
435
+ this.status = ConnectionStatus.Authenticating
503
436
 
504
437
  if (message.errorMessage) {
505
- this.errorMessage = message.errorMessage;
506
- this.isPasswordInvalid = true;
507
- this.emit('passwordInvalid', message.errorMessage);
438
+ this.errorMessage = message.errorMessage
439
+ this.isPasswordInvalid = true
440
+ this.emit('passwordInvalid', message.errorMessage)
508
441
  } else {
509
- this.emit('passwordRequired');
442
+ this.emit('passwordRequired')
510
443
  }
511
444
  }
512
445
 
513
- /**
514
- * Handle file info message
515
- */
516
446
  private handleInfo(message: any): void {
517
- this.filesInfo = message.files;
518
- this.totalBytes = this.filesInfo.reduce((sum: number, file: FileInfo) => sum + file.size, 0);
519
- this.isPasswordRequired = false;
520
- this.isPasswordInvalid = false;
521
- this.status = ConnectionStatus.Ready;
447
+ this.filesInfo = message.files
448
+ this.totalBytes = this.filesInfo.reduce(
449
+ (sum: number, file: FileInfo) => sum + file.size,
450
+ 0,
451
+ )
452
+ this.isPasswordRequired = false
453
+ this.isPasswordInvalid = false
454
+ this.errorMessage = undefined
455
+ this.status = ConnectionStatus.Ready
522
456
 
523
- this.emit('info', this.filesInfo);
457
+ this.emit('info', this.filesInfo)
524
458
  }
525
459
 
526
- /**
527
- * Handle error message
528
- */
529
460
  private handleError(message: any): void {
530
- this.errorMessage = message.error;
531
- this.status = ConnectionStatus.Error;
532
- this.emit('error', this.errorMessage);
461
+ this.errorMessage = message.error
462
+ this.status = ConnectionStatus.Error
463
+ this.emit('error', this.errorMessage)
533
464
  }
534
465
 
535
- /**
536
- * Handle report message (channel reported for violation)
537
- */
538
466
  private handleReport(): void {
539
- this.emit('reported');
467
+ this.emit('reported')
540
468
 
541
- // Redirect to reported page if in browser
542
469
  if (typeof window !== 'undefined') {
543
- window.location.href = `${this.filePizzaServerUrl}/reported`;
470
+ window.location.href = `${this.filePizzaServerUrl}/reported`
544
471
  }
545
472
  }
546
473
 
547
- /**
548
- * Handle chunk message
549
- */
550
474
  private handleChunk(message: any): void {
551
- const { fileName, bytes, final } = message;
552
- const fileStream = this.fileStreams.get(fileName);
475
+ const { fileName, bytes, final, offset } = message
476
+ const fileStream = this.fileStreams.get(fileName)
553
477
 
554
478
  if (!fileStream) {
555
- console.error(`No stream found for file: ${fileName}`);
556
- return;
479
+ console.error(`No stream found for file: ${fileName}`)
480
+ return
557
481
  }
558
482
 
559
- const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
483
+ const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
560
484
 
561
- this.currentFileBytesReceived += data.byteLength;
562
- this.totalBytesReceived += data.byteLength;
485
+ this.currentFileBytesReceived += data.byteLength
486
+ this.totalBytesReceived += data.byteLength
563
487
 
564
488
  try {
565
- fileStream.enqueue(data);
489
+ fileStream.enqueue(data)
566
490
  } catch (error) {
567
- console.error(`Error enqueueing data for ${fileName}:`, error);
568
- return;
491
+ console.error(`Error enqueueing data for ${fileName}:`, error)
492
+ return
569
493
  }
570
494
 
571
- this.emit('progress', this.getProgress());
495
+ this.connection?.send({
496
+ type: MessageType.ChunkAck,
497
+ fileName,
498
+ offset,
499
+ bytesReceived: data.byteLength,
500
+ })
501
+
502
+ this.emit('progress', this.getProgress())
572
503
 
573
504
  if (final) {
574
505
  try {
575
- fileStream.close();
506
+ fileStream.close()
576
507
  } catch (error) {
577
- console.warn(`Error closing stream for ${fileName}:`, error);
508
+ console.warn(`Error closing stream for ${fileName}:`, error)
578
509
  }
579
510
 
580
- this.storeCompletedFile(fileName);
511
+ this.storeCompletedFile(fileName)
581
512
 
582
- this.currentFileIndex++;
583
- this.currentFileBytesReceived = 0;
513
+ this.currentFileIndex++
514
+ this.currentFileBytesReceived = 0
584
515
 
585
516
  if (this.currentFileIndex < this.filesInfo.length) {
586
- this.requestNextFile();
517
+ this.requestNextFile()
587
518
  } else {
588
- this.status = ConnectionStatus.Done;
589
- this.emit('complete', this.completedFiles);
519
+ this.status = ConnectionStatus.Done
520
+ this.connection?.send({ type: MessageType.Done })
521
+ this.emit('complete', this.completedFiles)
590
522
  }
591
523
  }
592
524
  }
593
525
 
594
- /**
595
- * Initialize streams for all files
596
- */
597
526
  private initializeFileStreams(): void {
598
- this.cleanupFileStreams();
527
+ this.cleanupFileStreams()
599
528
 
600
529
  for (const fileInfo of this.filesInfo) {
601
- let enqueue: ((chunk: Uint8Array) => void) | null = null;
602
- let close: (() => void) | null = null;
530
+ let enqueue: ((chunk: Uint8Array) => void) | null = null
531
+ let close: (() => void) | null = null
603
532
 
604
533
  const stream = new ReadableStream<Uint8Array>({
605
534
  start(controller) {
606
- enqueue = (chunk: Uint8Array) => controller.enqueue(chunk);
535
+ enqueue = (chunk: Uint8Array) => controller.enqueue(chunk)
607
536
  close = () => {
608
537
  try {
609
- controller.close();
538
+ controller.close()
610
539
  } catch (error) {
611
- console.warn('Controller already closed:', error);
540
+ console.warn('Controller already closed:', error)
612
541
  }
613
- };
542
+ }
614
543
  },
615
- });
544
+ })
616
545
 
617
546
  if (!enqueue || !close) {
618
- throw new Error('Failed to initialize stream controllers');
547
+ throw new Error('Failed to initialize stream controllers')
619
548
  }
620
549
 
621
550
  this.fileStreams.set(fileInfo.fileName, {
622
551
  stream,
623
552
  enqueue,
624
553
  close,
625
- });
554
+ })
626
555
  }
627
556
  }
628
557
 
629
- /**
630
- * Store a completed file
631
- */
632
558
  private async storeCompletedFile(fileName: string): Promise<void> {
633
- const fileStream = this.fileStreams.get(fileName);
634
- const fileInfo = this.filesInfo.find(info => info.fileName === fileName);
559
+ const fileStream = this.fileStreams.get(fileName)
560
+ const fileInfo = this.filesInfo.find((info) => info.fileName === fileName)
635
561
 
636
562
  if (!fileStream || !fileInfo) {
637
- console.error(`No stream or file info found for file: ${fileName}`);
638
- return;
563
+ console.error(`No stream or file info found for file: ${fileName}`)
564
+ return
639
565
  }
640
566
 
641
567
  try {
642
- const fileData = await DownloadHelper.streamToUint8Array(fileStream.stream);
568
+ const fileData = await DownloadHelper.streamToUint8Array(fileStream.stream)
643
569
 
644
570
  const completedFile: CompletedFile = {
645
571
  ...fileInfo,
646
572
  data: fileData,
647
- };
573
+ }
648
574
 
649
- this.completedFiles.push(completedFile);
650
- this.emit('fileComplete', completedFile);
651
- } catch (error) {
652
- console.error(`Error storing file ${fileName}:`, error);
653
- this.emit('error', `Failed to store file: ${error.message}`);
575
+ this.completedFiles.push(completedFile)
576
+ this.emit('fileComplete', completedFile)
577
+ } catch (error: any) {
578
+ console.error(`Error storing file ${fileName}:`, error)
579
+ this.emit('error', `Failed to store file: ${error.message}`)
654
580
  }
655
581
  }
656
582
 
657
- /**
658
- * Download a completed file
659
- */
660
583
  public async downloadFile(fileName: string): Promise<void> {
661
- const completedFile = this.completedFiles.find(file => file.fileName === fileName);
584
+ const completedFile = this.completedFiles.find(
585
+ (file) => file.fileName === fileName,
586
+ )
662
587
 
663
588
  if (!completedFile) {
664
- throw new Error(`File not found: ${fileName}`);
589
+ throw new Error(`File not found: ${fileName}`)
665
590
  }
666
591
 
667
592
  try {
668
- await DownloadHelper.downloadFile(fileName, completedFile.data);
669
- } catch (error) {
670
- console.error(`Error downloading file ${fileName}:`, error);
671
- throw new Error(`Failed to download file: ${error.message}`);
593
+ await DownloadHelper.downloadFile(fileName, completedFile.data)
594
+ } catch (error: any) {
595
+ console.error(`Error downloading file ${fileName}:`, error)
596
+ throw new Error(`Failed to download file: ${error.message}`)
672
597
  }
673
598
  }
674
599
 
675
- /**
676
- * Get completed files
677
- */
678
600
  public getCompletedFiles(): CompletedFile[] {
679
- return [...this.completedFiles];
601
+ return [...this.completedFiles]
680
602
  }
681
603
 
682
- /**
683
- * Download all completed files
684
- */
685
604
  public async downloadAllFiles(): Promise<void> {
686
605
  for (const file of this.completedFiles) {
687
606
  try {
688
- await this.downloadFile(file.fileName);
607
+ await this.downloadFile(file.fileName)
689
608
  } catch (error) {
690
- console.error(`Error downloading file ${file.fileName}:`, error);
691
- // Continue with other files even if one fails
609
+ console.error(`Error downloading file ${file.fileName}:`, error)
692
610
  }
693
611
  }
694
612
  }
695
613
 
696
- /**
697
- * Request the next file
698
- */
699
614
  private requestNextFile(): void {
700
615
  if (!this.connection || this.currentFileIndex >= this.filesInfo.length) {
701
- return;
616
+ return
702
617
  }
703
618
 
704
- const nextFile = this.filesInfo[this.currentFileIndex];
619
+ const nextFile = this.filesInfo[this.currentFileIndex]
705
620
 
706
621
  this.connection.send({
707
622
  type: MessageType.Start,
708
623
  fileName: nextFile.fileName,
709
624
  offset: 0,
710
- });
625
+ })
711
626
  }
712
627
 
713
- // Browser detection helper methods
714
628
  private getBrowserName(): string {
715
- if (typeof navigator === 'undefined') return 'unknown';
716
- const ua = navigator.userAgent;
717
- if (ua.includes('Firefox')) return 'Firefox';
718
- if (ua.includes('Chrome')) return 'Chrome';
719
- if (ua.includes('Safari')) return 'Safari';
720
- if (ua.includes('Edge')) return 'Edge';
721
- if (ua.includes('MSIE') || ua.includes('Trident/')) return 'IE';
722
- return 'unknown';
629
+ if (typeof navigator === 'undefined') return 'unknown'
630
+ const ua = navigator.userAgent
631
+ if (ua.includes('Firefox')) return 'Firefox'
632
+ if (ua.includes('Chrome')) return 'Chrome'
633
+ if (ua.includes('Safari')) return 'Safari'
634
+ if (ua.includes('Edge')) return 'Edge'
635
+ if (ua.includes('MSIE') || ua.includes('Trident/')) return 'IE'
636
+ return 'unknown'
723
637
  }
724
638
 
725
639
  private getBrowserVersion(): string {
726
- if (typeof navigator === 'undefined') return 'unknown';
727
- const ua = navigator.userAgent;
640
+ if (typeof navigator === 'undefined') return 'unknown'
641
+ const ua = navigator.userAgent
728
642
 
729
- let match;
643
+ let match
730
644
  if ((match = ua.match(/(Firefox|Chrome|Safari|Edge|MSIE)\/(\d+\.\d+)/))) {
731
- return match[2];
645
+ return match[2]
732
646
  }
733
647
  if ((match = ua.match(/rv:(\d+\.\d+)/))) {
734
- return match[1];
648
+ return match[1]
735
649
  }
736
650
 
737
- return 'unknown';
651
+ return 'unknown'
738
652
  }
739
653
 
740
654
  private getOSName(): string {
741
- if (typeof navigator === 'undefined') return 'unknown';
742
- const ua = navigator.userAgent;
655
+ if (typeof navigator === 'undefined') return 'unknown'
656
+ const ua = navigator.userAgent
743
657
 
744
- if (ua.includes('Windows')) return 'Windows';
745
- if (ua.includes('Mac OS X')) return 'macOS';
746
- if (ua.includes('Linux')) return 'Linux';
747
- if (ua.includes('Android')) return 'Android';
748
- if (ua.includes('iOS')) return 'iOS';
658
+ if (ua.includes('Windows')) return 'Windows'
659
+ if (ua.includes('Mac OS X')) return 'macOS'
660
+ if (ua.includes('Linux')) return 'Linux'
661
+ if (ua.includes('Android')) return 'Android'
662
+ if (ua.includes('iOS')) return 'iOS'
749
663
 
750
- return 'unknown';
664
+ return 'unknown'
751
665
  }
752
666
 
753
667
  private getOSVersion(): string {
754
- if (typeof navigator === 'undefined') return 'unknown';
755
- const ua = navigator.userAgent;
668
+ if (typeof navigator === 'undefined') return 'unknown'
669
+ const ua = navigator.userAgent
756
670
 
757
- let match;
671
+ let match
758
672
  if ((match = ua.match(/Windows NT (\d+\.\d+)/))) {
759
- return match[1];
673
+ return match[1]
760
674
  }
761
675
  if ((match = ua.match(/Mac OS X (\d+[._]\d+)/))) {
762
- return match[1].replace('_', '.');
676
+ return match[1].replace('_', '.')
763
677
  }
764
678
  if ((match = ua.match(/Android (\d+\.\d+)/))) {
765
- return match[1];
679
+ return match[1]
766
680
  }
767
681
  if ((match = ua.match(/iPhone OS (\d+_\d+)/))) {
768
- return match[1].replace('_', '.');
682
+ return match[1].replace('_', '.')
769
683
  }
770
684
 
771
- return 'unknown';
685
+ return 'unknown'
772
686
  }
773
687
 
774
688
  private getMobileVendor(): string {
775
- if (typeof navigator === 'undefined') return '';
689
+ if (typeof navigator === 'undefined') return ''
776
690
 
777
- const ua = navigator.userAgent;
778
- if (ua.includes('iPhone') || ua.includes('iPad')) return 'Apple';
779
- if (ua.includes('Samsung')) return 'Samsung';
780
- if (ua.includes('Pixel')) return 'Google';
781
- if (ua.includes('Huawei')) return 'Huawei';
691
+ const ua = navigator.userAgent
692
+ if (ua.includes('iPhone') || ua.includes('iPad')) return 'Apple'
693
+ if (ua.includes('Samsung')) return 'Samsung'
694
+ if (ua.includes('Pixel')) return 'Google'
695
+ if (ua.includes('Huawei')) return 'Huawei'
782
696
 
783
- return '';
697
+ return ''
784
698
  }
785
699
 
786
700
  private getMobileModel(): string {
787
- if (typeof navigator === 'undefined') return '';
701
+ if (typeof navigator === 'undefined') return ''
788
702
 
789
- const ua = navigator.userAgent;
790
- let match;
703
+ const ua = navigator.userAgent
704
+ let match
791
705
 
792
706
  if ((match = ua.match(/iPhone(\d+),(\d+)/))) {
793
- return `iPhone ${match[1]}`;
707
+ return `iPhone ${match[1]}`
794
708
  }
795
709
  if ((match = ua.match(/iPad(\d+),(\d+)/))) {
796
- return `iPad ${match[1]}`;
710
+ return `iPad ${match[1]}`
797
711
  }
798
712
  if ((match = ua.match(/SM-\w+/))) {
799
- return match[0];
713
+ return match[0]
800
714
  }
801
715
  if ((match = ua.match(/Pixel \d+/))) {
802
- return match[0];
716
+ return match[0]
803
717
  }
804
718
 
805
- return '';
719
+ return ''
806
720
  }
807
721
  }