@uploadista/client-core 0.0.13 → 0.0.14

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,629 @@
1
+ import type {
2
+ AbortControllerFactory,
3
+ AbortControllerLike,
4
+ AbortSignalLike,
5
+ } from "../services/abort-controller-service";
6
+ import type { ChecksumService } from "../services/checksum-service";
7
+ import type {
8
+ Base64Service,
9
+ FileReaderService,
10
+ FileSource,
11
+ SliceResult,
12
+ } from "../services/file-reader-service";
13
+ import type { FingerprintService } from "../services/fingerprint-service";
14
+ import type {
15
+ ConnectionMetrics,
16
+ DetailedConnectionMetrics,
17
+ HeadersLike,
18
+ HttpClient,
19
+ HttpRequestOptions,
20
+ HttpResponse,
21
+ } from "../services/http-client";
22
+ import type { IdGenerationService } from "../services/id-generation-service";
23
+ import type {
24
+ PlatformService,
25
+ Timeout,
26
+ } from "../services/platform-service";
27
+ import type { ServiceContainer } from "../services/service-container";
28
+ import type { StorageService } from "../services/storage-service";
29
+ import type {
30
+ WebSocketFactory,
31
+ WebSocketLike,
32
+ } from "../services/websocket-service";
33
+
34
+ // Platform globals polyfill for testing environments
35
+ declare function setTimeout(callback: () => void, ms: number): number;
36
+ declare function clearTimeout(id: number): void;
37
+ declare class TextEncoder {
38
+ encode(input?: string): Uint8Array;
39
+ }
40
+ declare class Blob {
41
+ readonly size: number;
42
+ readonly type: string;
43
+ slice(start?: number, end?: number, contentType?: string): Blob;
44
+ arrayBuffer(): Promise<ArrayBuffer>;
45
+ }
46
+ declare function btoa(data: string): string;
47
+ declare function atob(data: string): string;
48
+
49
+ /**
50
+ * Mock HTTP response configuration for testing
51
+ */
52
+ export interface MockHttpResponseConfig {
53
+ status?: number;
54
+ statusText?: string;
55
+ headers?: Record<string, string>;
56
+ body?: unknown;
57
+ delay?: number;
58
+ }
59
+
60
+ /**
61
+ * Mock HTTP client for testing upload logic without actual network calls
62
+ *
63
+ * Allows configuring responses for specific URLs and methods, with support
64
+ * for delays to simulate network latency.
65
+ *
66
+ * @example Basic usage
67
+ * ```typescript
68
+ * const httpClient = new MockHttpClient();
69
+ * httpClient.mockResponse('https://api.example.com/upload', {
70
+ * status: 200,
71
+ * body: { uploadId: 'abc123' }
72
+ * });
73
+ * ```
74
+ */
75
+ export class MockHttpClient implements HttpClient {
76
+ private responses = new Map<string, MockHttpResponseConfig>();
77
+ private defaultResponse: MockHttpResponseConfig = {
78
+ status: 200,
79
+ statusText: "OK",
80
+ body: {},
81
+ };
82
+ private requestLog: Array<{ url: string; options?: HttpRequestOptions }> = [];
83
+
84
+ /**
85
+ * Configure a mock response for a specific URL
86
+ */
87
+ mockResponse(url: string, config: MockHttpResponseConfig): void {
88
+ this.responses.set(url, config);
89
+ }
90
+
91
+ /**
92
+ * Set the default response for unmocked URLs
93
+ */
94
+ setDefaultResponse(config: MockHttpResponseConfig): void {
95
+ this.defaultResponse = config;
96
+ }
97
+
98
+ /**
99
+ * Get the log of all requests made
100
+ */
101
+ getRequestLog(): Array<{ url: string; options?: HttpRequestOptions }> {
102
+ return [...this.requestLog];
103
+ }
104
+
105
+ /**
106
+ * Clear the request log
107
+ */
108
+ clearRequestLog(): void {
109
+ this.requestLog = [];
110
+ }
111
+
112
+ async request(
113
+ url: string,
114
+ options?: HttpRequestOptions,
115
+ ): Promise<HttpResponse> {
116
+ // Log the request
117
+ this.requestLog.push({ url, options });
118
+
119
+ // Get the configured response or use default
120
+ const config = this.responses.get(url) || this.defaultResponse;
121
+
122
+ // Simulate network delay if configured
123
+ if (config.delay) {
124
+ await new Promise<void>((resolve) => setTimeout(() => resolve(), config.delay ?? 0));
125
+ }
126
+
127
+ // Create mock headers
128
+ const headers: HeadersLike = {
129
+ get: (name: string) => config.headers?.[name] ?? null,
130
+ has: (name: string) => config.headers?.[name] !== undefined,
131
+ forEach: (callback: (value: string, name: string) => void) => {
132
+ if (config.headers) {
133
+ for (const [key, value] of Object.entries(config.headers)) {
134
+ // Call the callback directly with value and name
135
+ (callback as (value: string, name: string) => void)(value, key);
136
+ }
137
+ }
138
+ },
139
+ };
140
+
141
+ // Create mock response
142
+ const status = config.status ?? 200;
143
+ const response: HttpResponse = {
144
+ status,
145
+ statusText: config.statusText ?? "OK",
146
+ headers,
147
+ ok: status >= 200 && status < 300,
148
+ json: async () => config.body,
149
+ text: async () =>
150
+ typeof config.body === "string"
151
+ ? config.body
152
+ : JSON.stringify(config.body),
153
+ arrayBuffer: async (): Promise<ArrayBuffer> => {
154
+ const text =
155
+ typeof config.body === "string"
156
+ ? config.body
157
+ : JSON.stringify(config.body);
158
+ return new TextEncoder().encode(text).buffer as ArrayBuffer;
159
+ },
160
+ };
161
+
162
+ return response;
163
+ }
164
+
165
+ getMetrics(): ConnectionMetrics {
166
+ return {
167
+ activeConnections: 0,
168
+ totalConnections: this.requestLog.length,
169
+ reuseRate: 0,
170
+ averageConnectionTime: 0,
171
+ };
172
+ }
173
+
174
+ getDetailedMetrics(): DetailedConnectionMetrics {
175
+ return {
176
+ activeConnections: 0,
177
+ totalConnections: this.requestLog.length,
178
+ reuseRate: 0,
179
+ averageConnectionTime: 0,
180
+ health: {
181
+ status: "healthy",
182
+ score: 100,
183
+ issues: [],
184
+ recommendations: [],
185
+ },
186
+ requestsPerSecond: 0,
187
+ errorRate: 0,
188
+ timeouts: 0,
189
+ retries: 0,
190
+ fastConnections: this.requestLog.length,
191
+ slowConnections: 0,
192
+ http2Info: {
193
+ supported: false,
194
+ detected: false,
195
+ version: "1.1",
196
+ multiplexingActive: false,
197
+ },
198
+ };
199
+ }
200
+
201
+ reset(): void {
202
+ this.requestLog = [];
203
+ this.responses.clear();
204
+ }
205
+
206
+ async close(): Promise<void> {
207
+ // No-op for mock
208
+ }
209
+
210
+ async warmupConnections(_urls: string[]): Promise<void> {
211
+ // No-op for mock
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Mock storage service for testing without actual persistent storage
217
+ *
218
+ * Uses in-memory storage that can be inspected and manipulated for testing.
219
+ */
220
+ export class MockStorageService implements StorageService {
221
+ private storage = new Map<string, string>();
222
+
223
+ async getItem(key: string): Promise<string | null> {
224
+ return this.storage.get(key) ?? null;
225
+ }
226
+
227
+ async setItem(key: string, value: string): Promise<void> {
228
+ this.storage.set(key, value);
229
+ }
230
+
231
+ async removeItem(key: string): Promise<void> {
232
+ this.storage.delete(key);
233
+ }
234
+
235
+ async findAll(): Promise<Record<string, string>> {
236
+ const result: Record<string, string> = {};
237
+ for (const [key, value] of this.storage.entries()) {
238
+ result[key] = value;
239
+ }
240
+ return result;
241
+ }
242
+
243
+ async find(prefix: string): Promise<Record<string, string>> {
244
+ const result: Record<string, string> = {};
245
+ for (const [key, value] of this.storage.entries()) {
246
+ if (key.startsWith(prefix)) {
247
+ result[key] = value;
248
+ }
249
+ }
250
+ return result;
251
+ }
252
+
253
+ /**
254
+ * Get the current storage state for inspection
255
+ */
256
+ getStorageState(): Map<string, string> {
257
+ return new Map(this.storage);
258
+ }
259
+
260
+ /**
261
+ * Clear all storage
262
+ */
263
+ clear(): void {
264
+ this.storage.clear();
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Mock file reader service for testing file operations
270
+ *
271
+ * Accepts either File/Blob objects or mock file data (Uint8Array).
272
+ */
273
+ export class MockFileReaderService<UploadInput = unknown>
274
+ implements FileReaderService<UploadInput>
275
+ {
276
+ async openFile(input: UploadInput, _chunkSize: number): Promise<FileSource> {
277
+ // Handle File/Blob objects
278
+ if (
279
+ input instanceof Blob ||
280
+ (input && typeof input === "object" && "size" in input)
281
+ ) {
282
+ const file = input as Blob & { name?: string; lastModified?: number };
283
+ return {
284
+ input,
285
+ size: file.size,
286
+ name:
287
+ "name" in file && typeof file.name === "string" ? file.name : null,
288
+ type: file.type || null,
289
+ lastModified:
290
+ "lastModified" in file && typeof file.lastModified === "number"
291
+ ? file.lastModified
292
+ : null,
293
+ slice: async (start: number, end: number): Promise<SliceResult> => {
294
+ if (start >= file.size) {
295
+ return { done: true, value: null, size: null };
296
+ }
297
+ const blob = file.slice(start, end);
298
+ const arrayBuffer = await blob.arrayBuffer();
299
+ const chunk = new Uint8Array(arrayBuffer);
300
+ return { done: false, value: chunk, size: chunk.length };
301
+ },
302
+ close: () => {
303
+ // No-op for Blob
304
+ },
305
+ };
306
+ }
307
+
308
+ // Handle Uint8Array for testing
309
+ if (input instanceof Uint8Array) {
310
+ const data = input;
311
+ return {
312
+ input,
313
+ size: data.length,
314
+ name: "test-file.bin",
315
+ type: "application/octet-stream",
316
+ lastModified: Date.now(),
317
+ slice: async (start: number, end: number): Promise<SliceResult> => {
318
+ if (start >= data.length) {
319
+ return { done: true, value: null, size: null };
320
+ }
321
+ const chunk = data.slice(start, end);
322
+ return { done: false, value: chunk, size: chunk.length };
323
+ },
324
+ close: () => {
325
+ // No-op for Uint8Array
326
+ },
327
+ };
328
+ }
329
+
330
+ // Fallback for unknown types
331
+ throw new Error(
332
+ `MockFileReaderService: Unsupported input type: ${typeof input}`,
333
+ );
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Mock abort controller for testing cancellation
339
+ */
340
+ export class MockAbortController implements AbortControllerLike {
341
+ private _aborted = false;
342
+ private listeners: Array<() => void> = [];
343
+
344
+ get signal(): AbortSignalLike {
345
+ return {
346
+ aborted: this._aborted,
347
+ addEventListener: (_event: string, listener: () => void) => {
348
+ this.listeners.push(listener);
349
+ },
350
+ removeEventListener: (_event: string, listener: () => void) => {
351
+ const index = this.listeners.indexOf(listener);
352
+ if (index !== -1) {
353
+ this.listeners.splice(index, 1);
354
+ }
355
+ },
356
+ };
357
+ }
358
+
359
+ abort(): void {
360
+ this._aborted = true;
361
+ for (const listener of this.listeners) {
362
+ listener();
363
+ }
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Mock abort controller factory
369
+ */
370
+ export class MockAbortControllerFactory implements AbortControllerFactory {
371
+ create(): AbortControllerLike {
372
+ return new MockAbortController();
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Mock WebSocket for testing real-time events
378
+ */
379
+ export class MockWebSocket implements WebSocketLike {
380
+ readonly CONNECTING = 0;
381
+ readonly OPEN = 1;
382
+ readonly CLOSING = 2;
383
+ readonly CLOSED = 3;
384
+
385
+ readyState = 0; // CONNECTING
386
+
387
+ onopen: (() => void) | null = null;
388
+ onclose: ((event: { code: number; reason: string }) => void) | null = null;
389
+ onerror: ((event: { message: string }) => void) | null = null;
390
+ onmessage: ((event: { data: string }) => void) | null = null;
391
+
392
+ constructor(public url: string) {
393
+ // Simulate connection opening after a short delay
394
+ setTimeout(() => {
395
+ this.readyState = 1; // OPEN
396
+ if (this.onopen) {
397
+ this.onopen();
398
+ }
399
+ }, 10);
400
+ }
401
+
402
+ send(_data: string | Uint8Array): void {
403
+ if (this.readyState !== 1) {
404
+ throw new Error("WebSocket is not open");
405
+ }
406
+ // Mock implementation - in tests, you can override this
407
+ }
408
+
409
+ close(code = 1000, reason = ""): void {
410
+ this.readyState = 3; // CLOSED
411
+ if (this.onclose) {
412
+ this.onclose({ code, reason });
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Simulate receiving a message (for testing)
418
+ */
419
+ simulateMessage(data: string): void {
420
+ if (this.readyState === 1 && this.onmessage) {
421
+ this.onmessage({ data });
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Simulate an error (for testing)
427
+ */
428
+ simulateError(message: string): void {
429
+ if (this.onerror) {
430
+ this.onerror({ message });
431
+ }
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Mock WebSocket factory
437
+ */
438
+ export class MockWebSocketFactory implements WebSocketFactory {
439
+ create(url: string): WebSocketLike {
440
+ return new MockWebSocket(url);
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Mock platform service
446
+ */
447
+ export class MockPlatformService implements PlatformService {
448
+ private timers = new Map<Timeout, ReturnType<typeof setTimeout>>();
449
+ private timerId = 0;
450
+
451
+ constructor(
452
+ private browser = true,
453
+ private online = true,
454
+ ) {}
455
+
456
+ setTimeout(callback: () => void, ms: number | undefined): Timeout {
457
+ const id = ++this.timerId;
458
+ const timer = setTimeout(callback, ms ?? 0);
459
+ this.timers.set(id, timer);
460
+ return id;
461
+ }
462
+
463
+ clearTimeout(id: Timeout): void {
464
+ const timer = this.timers.get(id);
465
+ if (timer !== undefined) {
466
+ clearTimeout(timer);
467
+ this.timers.delete(id);
468
+ }
469
+ }
470
+
471
+ isBrowser(): boolean {
472
+ return this.browser;
473
+ }
474
+
475
+ isOnline(): boolean {
476
+ return this.online;
477
+ }
478
+
479
+ isFileLike(value: unknown): boolean {
480
+ if (typeof value !== "object" || value === null) return false;
481
+ // Check for File/Blob-like properties
482
+ return (
483
+ "size" in value &&
484
+ typeof (value as { size: unknown }).size === "number" &&
485
+ ("slice" in value || "type" in value)
486
+ );
487
+ }
488
+
489
+ getFileName(file: unknown): string | undefined {
490
+ if (
491
+ typeof file === "object" &&
492
+ file !== null &&
493
+ "name" in file &&
494
+ typeof (file as { name: unknown }).name === "string"
495
+ ) {
496
+ return (file as { name: string }).name;
497
+ }
498
+ return undefined;
499
+ }
500
+
501
+ getFileType(file: unknown): string | undefined {
502
+ if (
503
+ typeof file === "object" &&
504
+ file !== null &&
505
+ "type" in file &&
506
+ typeof (file as { type: unknown }).type === "string"
507
+ ) {
508
+ return (file as { type: string }).type;
509
+ }
510
+ return undefined;
511
+ }
512
+
513
+ getFileSize(file: unknown): number | undefined {
514
+ if (
515
+ typeof file === "object" &&
516
+ file !== null &&
517
+ "size" in file &&
518
+ typeof (file as { size: unknown }).size === "number"
519
+ ) {
520
+ return (file as { size: number }).size;
521
+ }
522
+ return undefined;
523
+ }
524
+
525
+ getFileLastModified(file: unknown): number | undefined {
526
+ if (
527
+ typeof file === "object" &&
528
+ file !== null &&
529
+ "lastModified" in file &&
530
+ typeof (file as { lastModified: unknown }).lastModified === "number"
531
+ ) {
532
+ return (file as { lastModified: number }).lastModified;
533
+ }
534
+ return undefined;
535
+ }
536
+
537
+ /**
538
+ * Set online status for testing
539
+ */
540
+ setOnline(online: boolean): void {
541
+ this.online = online;
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Mock checksum service
547
+ */
548
+ export class MockChecksumService implements ChecksumService {
549
+ async computeChecksum(_data: Uint8Array): Promise<string> {
550
+ // Return a mock checksum
551
+ return `mock-checksum-${Math.random().toString(36).substring(7)}`;
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Mock fingerprint service
557
+ */
558
+ export class MockFingerprintService<UploadInput>
559
+ implements FingerprintService<UploadInput>
560
+ {
561
+ async computeFingerprint(_input: UploadInput): Promise<string> {
562
+ // Return a mock fingerprint
563
+ return `mock-fingerprint-${Math.random().toString(36).substring(7)}`;
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Mock ID generation service
569
+ */
570
+ export class MockIdGenerationService implements IdGenerationService {
571
+ private counter = 0;
572
+
573
+ generate(): string {
574
+ return `mock-id-${++this.counter}`;
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Mock base64 service
580
+ */
581
+ export class MockBase64Service implements Base64Service {
582
+ toBase64(data: ArrayBuffer): string {
583
+ // Simple mock implementation
584
+ const bytes = new Uint8Array(data);
585
+ let binary = "";
586
+ for (let i = 0; i < bytes.length; i++) {
587
+ binary += String.fromCharCode(bytes[i] ?? 0);
588
+ }
589
+ return btoa(binary);
590
+ }
591
+
592
+ fromBase64(data: string): ArrayBuffer {
593
+ const binary = atob(data);
594
+ const bytes = new Uint8Array(binary.length);
595
+ for (let i = 0; i < binary.length; i++) {
596
+ bytes[i] = binary.charCodeAt(i);
597
+ }
598
+ return bytes.buffer;
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Create a complete mock service container for testing
604
+ *
605
+ * @example
606
+ * ```typescript
607
+ * const services = createMockServiceContainer();
608
+ * const client = createUploadistaClient({
609
+ * apiUrl: 'https://api.example.com',
610
+ * services,
611
+ * });
612
+ * ```
613
+ */
614
+ export function createMockServiceContainer<
615
+ UploadInput = unknown,
616
+ >(): ServiceContainer<UploadInput> {
617
+ return {
618
+ storage: new MockStorageService(),
619
+ idGeneration: new MockIdGenerationService(),
620
+ httpClient: new MockHttpClient(),
621
+ fileReader: new MockFileReaderService<UploadInput>(),
622
+ base64: new MockBase64Service(),
623
+ websocket: new MockWebSocketFactory(),
624
+ abortController: new MockAbortControllerFactory(),
625
+ platform: new MockPlatformService(),
626
+ checksumService: new MockChecksumService(),
627
+ fingerprintService: new MockFingerprintService<UploadInput>(),
628
+ };
629
+ }
@@ -1,3 +1,4 @@
1
+ import type { TypedOutput } from "@uploadista/core/flow";
1
2
  import type { UploadFile } from "@uploadista/core/types";
2
3
  import type { FlowUploadConfig } from "./flow-upload-config";
3
4
 
@@ -7,11 +8,21 @@ export interface FlowUploadOptions<TOutput = UploadFile> {
7
8
  */
8
9
  flowConfig: FlowUploadConfig;
9
10
 
11
+ /**
12
+ * Called when the flow job starts
13
+ * @param jobId - The unique identifier for the flow job
14
+ */
15
+ onJobStart?: (jobId: string) => void;
16
+
10
17
  /**
11
18
  * Called when upload progress updates
19
+ *
20
+ * @param uploadId - The unique identifier for this upload
21
+ * @param bytesUploaded - Number of bytes uploaded so far
22
+ * @param totalBytes - Total bytes to upload, null if unknown/deferred
12
23
  */
13
24
  onProgress?: (
14
- progress: number,
25
+ uploadId: string,
15
26
  bytesUploaded: number,
16
27
  totalBytes: number | null,
17
28
  ) => void;
@@ -27,10 +38,24 @@ export interface FlowUploadOptions<TOutput = UploadFile> {
27
38
 
28
39
  /**
29
40
  * Called when the flow completes successfully (receives full flow outputs)
30
- * This is the recommended callback for multi-output flows
31
- * Format: { [outputNodeId]: result, ... }
41
+ * This is the recommended callback for multi-output flows.
42
+ * Each output includes nodeId, optional nodeType, data, and timestamp.
43
+ *
44
+ * @param outputs - Array of typed outputs from all output nodes
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * onFlowComplete: (outputs) => {
49
+ * // Access all outputs with type information
50
+ * for (const output of outputs) {
51
+ * if (output.nodeType === 'storage-output-v1') {
52
+ * console.log('Storage output:', output.data);
53
+ * }
54
+ * }
55
+ * }
56
+ * ```
32
57
  */
33
- onFlowComplete?: (outputs: Record<string, unknown>) => void;
58
+ onFlowComplete?: (outputs: TypedOutput[]) => void;
34
59
 
35
60
  /**
36
61
  * Called when upload succeeds (legacy, single-output flows)
@@ -8,6 +8,7 @@ export * from "./multi-flow-upload-state";
8
8
  export * from "./performance-insights";
9
9
  export * from "./previous-upload";
10
10
  export * from "./upload-options";
11
+ export * from "./upload-metrics";
11
12
  export * from "./upload-response";
12
13
  export * from "./upload-result";
13
14
  export * from "./upload-session-metrics";