@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,614 @@
1
+ import type { FlowEvent, TypedOutput } from "@uploadista/core/flow";
2
+ import { EventType } from "@uploadista/core/flow";
3
+ import type { UploadFile } from "@uploadista/core/types";
4
+ import type { FlowUploadOptions } from "../types/flow-upload-options";
5
+
6
+ /**
7
+ * Flow upload status representing the current state of a flow upload lifecycle.
8
+ * Flow uploads progress through: idle → uploading → processing → success/error/aborted
9
+ */
10
+ export type FlowUploadStatus =
11
+ | "idle"
12
+ | "uploading"
13
+ | "processing"
14
+ | "success"
15
+ | "error"
16
+ | "aborted";
17
+
18
+ /**
19
+ * Complete state information for a flow upload operation.
20
+ * Tracks both the upload phase (file transfer) and processing phase (flow execution).
21
+ *
22
+ * @template TOutput - Type of the final output from the flow (defaults to UploadFile)
23
+ */
24
+ export interface FlowUploadState<TOutput = UploadFile> {
25
+ /** Current upload status */
26
+ status: FlowUploadStatus;
27
+ /** Upload progress percentage (0-100) */
28
+ progress: number;
29
+ /** Number of bytes uploaded */
30
+ bytesUploaded: number;
31
+ /** Total bytes to upload, null if unknown */
32
+ totalBytes: number | null;
33
+ /** Error if upload or processing failed */
34
+ error: Error | null;
35
+ /** Final output from the flow (available when status is "success") */
36
+ result: TOutput | null;
37
+ /** Unique identifier for the flow execution job */
38
+ jobId: string | null;
39
+ /** Whether the flow processing has started */
40
+ flowStarted: boolean;
41
+ /** Name of the currently executing flow node */
42
+ currentNodeName: string | null;
43
+ /** Type of the currently executing flow node */
44
+ currentNodeType: string | null;
45
+ /**
46
+ * Complete typed outputs from all output nodes in the flow.
47
+ * Each output includes nodeId, optional nodeType, data, and timestamp.
48
+ * Available when status is "success".
49
+ */
50
+ flowOutputs: TypedOutput[] | null;
51
+ }
52
+
53
+ /**
54
+ * Callbacks that FlowManager invokes during the flow upload lifecycle
55
+ */
56
+ export interface FlowManagerCallbacks<TOutput = UploadFile> {
57
+ /**
58
+ * Called when the flow upload state changes
59
+ */
60
+ onStateChange: (state: FlowUploadState<TOutput>) => void;
61
+
62
+ /**
63
+ * Called when upload progress updates
64
+ * @param progress - Progress percentage (0-100)
65
+ * @param bytesUploaded - Number of bytes uploaded
66
+ * @param totalBytes - Total bytes to upload, null if unknown
67
+ */
68
+ onProgress?: (
69
+ uploadId: string,
70
+ bytesUploaded: number,
71
+ totalBytes: number | null,
72
+ ) => void;
73
+
74
+ /**
75
+ * Called when a chunk completes
76
+ * @param chunkSize - Size of the completed chunk
77
+ * @param bytesAccepted - Total bytes accepted so far
78
+ * @param bytesTotal - Total bytes to upload, null if unknown
79
+ */
80
+ onChunkComplete?: (
81
+ chunkSize: number,
82
+ bytesAccepted: number,
83
+ bytesTotal: number | null,
84
+ ) => void;
85
+
86
+ /**
87
+ * Called when the flow completes successfully (receives full flow outputs)
88
+ * Each output includes nodeId, optional nodeType (e.g., "storage-output-v1"), data, and timestamp.
89
+ *
90
+ * @param outputs - Array of typed outputs from all output nodes
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * onFlowComplete: (outputs) => {
95
+ * for (const output of outputs) {
96
+ * console.log(`${output.nodeId} (${output.nodeType}):`, output.data);
97
+ * }
98
+ * }
99
+ * ```
100
+ */
101
+ onFlowComplete?: (outputs: TypedOutput[]) => void;
102
+
103
+ /**
104
+ * Called when upload succeeds (receives single extracted output)
105
+ * For single-output flows, receives the value from the specified outputNodeId
106
+ * or the first output node if outputNodeId is not specified
107
+ */
108
+ onSuccess?: (result: TOutput) => void;
109
+
110
+ /**
111
+ * Called when upload or flow processing fails with an error
112
+ * @param error - The error that occurred
113
+ */
114
+ onError?: (error: Error) => void;
115
+
116
+ /**
117
+ * Called when upload or flow is aborted
118
+ */
119
+ onAbort?: () => void;
120
+ }
121
+
122
+ /**
123
+ * Generic flow upload input type - can be any value that the upload client accepts
124
+ */
125
+ export type FlowUploadInput = unknown;
126
+
127
+ /**
128
+ * Flow configuration for upload
129
+ */
130
+ export interface FlowConfig {
131
+ flowId: string;
132
+ storageId: string;
133
+ outputNodeId?: string;
134
+ metadata?: Record<string, string>;
135
+ }
136
+
137
+ /**
138
+ * Abort and pause controller interface for canceling/pausing flow uploads
139
+ */
140
+ export interface FlowUploadAbortController {
141
+ abort: () => void | Promise<void>;
142
+ pause: () => void | Promise<void>;
143
+ }
144
+
145
+ /**
146
+ * Internal upload options used by the flow upload function.
147
+ * The upload phase always returns UploadFile, regardless of the final TOutput type.
148
+ */
149
+ export interface InternalFlowUploadOptions {
150
+ onJobStart?: (jobId: string) => void;
151
+ onProgress?: (
152
+ uploadId: string,
153
+ bytesUploaded: number,
154
+ totalBytes: number | null,
155
+ ) => void;
156
+ onChunkComplete?: (
157
+ chunkSize: number,
158
+ bytesAccepted: number,
159
+ bytesTotal: number | null,
160
+ ) => void;
161
+ onSuccess?: (result: UploadFile) => void;
162
+ onError?: (error: Error) => void;
163
+ onAbort?: () => void;
164
+ onShouldRetry?: (error: Error, retryAttempt: number) => boolean;
165
+ }
166
+
167
+ /**
168
+ * Flow upload function that performs the actual upload with flow processing.
169
+ * Returns a promise that resolves to an abort controller with pause capability.
170
+ *
171
+ * Note: The upload phase onSuccess always receives UploadFile. The final TOutput
172
+ * result comes from the flow execution and is handled via FlowEnd events.
173
+ */
174
+ export type FlowUploadFunction<TInput = FlowUploadInput> = (
175
+ input: TInput,
176
+ flowConfig: FlowConfig,
177
+ options: InternalFlowUploadOptions,
178
+ ) => Promise<FlowUploadAbortController>;
179
+
180
+ /**
181
+ * Initial state for a new flow upload
182
+ */
183
+ const initialState: FlowUploadState = {
184
+ status: "idle",
185
+ progress: 0,
186
+ bytesUploaded: 0,
187
+ totalBytes: null,
188
+ error: null,
189
+ result: null,
190
+ jobId: null,
191
+ flowStarted: false,
192
+ currentNodeName: null,
193
+ currentNodeType: null,
194
+ flowOutputs: null,
195
+ };
196
+
197
+ /**
198
+ * Platform-agnostic flow upload manager that handles flow upload state machine,
199
+ * progress tracking, flow event handling, error handling, abort, pause, reset, and retry logic.
200
+ *
201
+ * Framework packages (React, Vue, React Native) should wrap this manager
202
+ * with framework-specific hooks/composables.
203
+ *
204
+ * @example
205
+ * ```typescript
206
+ * const flowUploadFn = (input, options) => client.uploadWithFlow(input, options.flowConfig, options);
207
+ * const manager = new FlowManager(flowUploadFn, {
208
+ * onStateChange: (state) => setState(state),
209
+ * onProgress: (progress, bytes, total) => console.log(`${progress}%`),
210
+ * onSuccess: (result) => console.log('Flow complete:', result),
211
+ * onError: (error) => console.error('Flow failed:', error),
212
+ * }, {
213
+ * flowConfig: { flowId: 'my-flow', storageId: 'storage1' }
214
+ * });
215
+ *
216
+ * // Subscribe to events and forward them to the manager
217
+ * const unsubscribe = client.subscribeToEvents((event) => {
218
+ * if (isFlowEvent(event)) {
219
+ * manager.handleFlowEvent(event);
220
+ * } else if (isUploadProgress(event)) {
221
+ * manager.handleUploadProgress(event);
222
+ * }
223
+ * });
224
+ *
225
+ * await manager.upload(file);
226
+ * ```
227
+ */
228
+ export class FlowManager<TInput = FlowUploadInput, TOutput = UploadFile> {
229
+ private state: FlowUploadState<TOutput>;
230
+ private abortController: FlowUploadAbortController | null = null;
231
+
232
+ /**
233
+ * Create a new FlowManager
234
+ *
235
+ * @param flowUploadFn - Flow upload function to use for uploads
236
+ * @param callbacks - Callbacks to invoke during flow upload lifecycle
237
+ * @param options - Flow upload configuration options
238
+ */
239
+ constructor(
240
+ private readonly flowUploadFn: FlowUploadFunction<TInput>,
241
+ private readonly callbacks: FlowManagerCallbacks<TOutput>,
242
+ private readonly options: FlowUploadOptions<TOutput>,
243
+ ) {
244
+ this.state = { ...initialState } as FlowUploadState<TOutput>;
245
+ }
246
+
247
+ /**
248
+ * Get the current flow upload state
249
+ */
250
+ getState(): FlowUploadState<TOutput> {
251
+ return { ...this.state };
252
+ }
253
+
254
+ /**
255
+ * Check if an upload or flow is currently active
256
+ */
257
+ isUploading(): boolean {
258
+ return (
259
+ this.state.status === "uploading" || this.state.status === "processing"
260
+ );
261
+ }
262
+
263
+ /**
264
+ * Check if file upload is in progress
265
+ */
266
+ isUploadingFile(): boolean {
267
+ return this.state.status === "uploading";
268
+ }
269
+
270
+ /**
271
+ * Check if flow processing is in progress
272
+ */
273
+ isProcessing(): boolean {
274
+ return this.state.status === "processing";
275
+ }
276
+
277
+ /**
278
+ * Get the current job ID
279
+ */
280
+ getJobId(): string | null {
281
+ return this.state.jobId;
282
+ }
283
+
284
+ /**
285
+ * Update the internal state and notify callbacks
286
+ */
287
+ private updateState(update: Partial<FlowUploadState<TOutput>>): void {
288
+ this.state = { ...this.state, ...update };
289
+ this.callbacks.onStateChange(this.state);
290
+ }
291
+
292
+ /**
293
+ * Handle flow events from the event subscription
294
+ * This method should be called by the framework wrapper when it receives flow events
295
+ *
296
+ * @param event - Flow event to process
297
+ */
298
+ handleFlowEvent(event: FlowEvent): void {
299
+ // For FlowStart, accept if we don't have a jobId yet (first event)
300
+ // This handles the race condition where flow events arrive before onJobStart callback
301
+ if (event.eventType === EventType.FlowStart && !this.state.jobId) {
302
+ this.updateState({
303
+ jobId: event.jobId,
304
+ flowStarted: true,
305
+ status: "processing",
306
+ });
307
+ return;
308
+ }
309
+
310
+ // Only handle events for the current job
311
+ if (!this.state.jobId || event.jobId !== this.state.jobId) {
312
+ return;
313
+ }
314
+
315
+ switch (event.eventType) {
316
+ case EventType.FlowStart:
317
+ this.updateState({
318
+ flowStarted: true,
319
+ status: "processing",
320
+ });
321
+ break;
322
+
323
+ case EventType.NodeStart:
324
+ this.updateState({
325
+ status: "processing",
326
+ currentNodeName: event.nodeName,
327
+ currentNodeType: event.nodeType,
328
+ });
329
+ break;
330
+
331
+ case EventType.NodePause:
332
+ // When input node pauses, it's waiting for upload - switch to uploading state
333
+ this.updateState({
334
+ status: "uploading",
335
+ currentNodeName: event.nodeName,
336
+ // NodePause doesn't have nodeType, keep previous value
337
+ });
338
+ break;
339
+
340
+ case EventType.NodeResume:
341
+ // When node resumes, upload is complete - switch to processing state
342
+ this.updateState({
343
+ status: "processing",
344
+ currentNodeName: event.nodeName,
345
+ currentNodeType: event.nodeType,
346
+ });
347
+ break;
348
+
349
+ case EventType.NodeEnd:
350
+ this.updateState({
351
+ status:
352
+ this.state.status === "uploading"
353
+ ? "processing"
354
+ : this.state.status,
355
+ currentNodeName: null,
356
+ currentNodeType: null,
357
+ });
358
+ break;
359
+
360
+ case EventType.FlowEnd: {
361
+ // Get typed outputs from the event
362
+ const flowOutputs = event.outputs || null;
363
+
364
+ // Call onFlowComplete with full typed outputs
365
+ if (flowOutputs && this.callbacks.onFlowComplete) {
366
+ this.callbacks.onFlowComplete(flowOutputs);
367
+ }
368
+
369
+ // Extract single output for onSuccess callback
370
+ let extractedOutput: TOutput | null = null;
371
+ if (flowOutputs && flowOutputs.length > 0) {
372
+ if (this.options.flowConfig.outputNodeId) {
373
+ // Find output by specified nodeId
374
+ const targetOutput = flowOutputs.find(
375
+ (output) => output.nodeId === this.options.flowConfig.outputNodeId,
376
+ );
377
+ if (targetOutput) {
378
+ extractedOutput = targetOutput.data as TOutput;
379
+ }
380
+ } else {
381
+ // Use first output
382
+ const firstOutput = flowOutputs[0];
383
+ if (firstOutput) {
384
+ extractedOutput = firstOutput.data as TOutput;
385
+ }
386
+ }
387
+ }
388
+
389
+ // Call onSuccess with extracted output
390
+ if (extractedOutput && this.callbacks.onSuccess) {
391
+ this.callbacks.onSuccess(extractedOutput);
392
+ }
393
+
394
+ this.updateState({
395
+ status: "success",
396
+ currentNodeName: null,
397
+ currentNodeType: null,
398
+ result: extractedOutput,
399
+ flowOutputs,
400
+ });
401
+
402
+ this.abortController = null;
403
+ break;
404
+ }
405
+
406
+ case EventType.FlowError: {
407
+ const error = new Error(event.error);
408
+ this.updateState({
409
+ status: "error",
410
+ error,
411
+ });
412
+ this.callbacks.onError?.(error);
413
+ this.abortController = null;
414
+ break;
415
+ }
416
+
417
+ case EventType.NodeError: {
418
+ const error = new Error(event.error);
419
+ this.updateState({
420
+ status: "error",
421
+ error,
422
+ });
423
+ this.callbacks.onError?.(error);
424
+ this.abortController = null;
425
+ break;
426
+ }
427
+
428
+ case EventType.FlowCancel:
429
+ this.updateState({
430
+ status: "aborted",
431
+ });
432
+ this.callbacks.onAbort?.();
433
+ this.abortController = null;
434
+ break;
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Handle upload progress events from the event subscription
440
+ * This method should be called by the framework wrapper when it receives upload progress events
441
+ *
442
+ * @param uploadId - The unique identifier for this upload
443
+ * @param bytesUploaded - Number of bytes uploaded
444
+ * @param totalBytes - Total bytes to upload, null if unknown
445
+ */
446
+ handleUploadProgress(
447
+ uploadId: string,
448
+ bytesUploaded: number,
449
+ totalBytes: number | null,
450
+ ): void {
451
+ // Calculate progress percentage
452
+ const progress =
453
+ totalBytes && totalBytes > 0
454
+ ? Math.round((bytesUploaded / totalBytes) * 100)
455
+ : 0;
456
+
457
+ this.updateState({
458
+ bytesUploaded,
459
+ totalBytes,
460
+ progress,
461
+ });
462
+
463
+ this.callbacks.onProgress?.(uploadId, bytesUploaded, totalBytes);
464
+ }
465
+
466
+ /**
467
+ * Start uploading a file through the flow
468
+ *
469
+ * @param input - File or input to upload (type depends on platform)
470
+ */
471
+ async upload(input: TInput): Promise<void> {
472
+ // Determine totalBytes from input if possible (File/Blob on browser platforms)
473
+ let totalBytes: number | null = null;
474
+ if (input && typeof input === "object") {
475
+ if ("size" in input && typeof input.size === "number") {
476
+ totalBytes = input.size;
477
+ }
478
+ }
479
+
480
+ // Reset state but keep reference for potential retries
481
+ this.updateState({
482
+ status: "uploading",
483
+ progress: 0,
484
+ bytesUploaded: 0,
485
+ totalBytes,
486
+ error: null,
487
+ result: null,
488
+ jobId: null,
489
+ flowStarted: false,
490
+ currentNodeName: null,
491
+ currentNodeType: null,
492
+ flowOutputs: null,
493
+ });
494
+
495
+ try {
496
+ // Build internal upload options with our callbacks
497
+ const internalOptions: InternalFlowUploadOptions = {
498
+ onJobStart: (jobId: string) => {
499
+ this.updateState({
500
+ jobId,
501
+ });
502
+ this.options?.onJobStart?.(jobId);
503
+ },
504
+ onProgress: (
505
+ uploadId: string,
506
+ bytesUploaded: number,
507
+ totalBytes: number | null,
508
+ ) => {
509
+ this.handleUploadProgress(uploadId, bytesUploaded, totalBytes);
510
+ this.options?.onProgress?.(uploadId, bytesUploaded, totalBytes);
511
+ },
512
+ onChunkComplete: (
513
+ chunkSize: number,
514
+ bytesAccepted: number,
515
+ bytesTotal: number | null,
516
+ ) => {
517
+ this.callbacks.onChunkComplete?.(
518
+ chunkSize,
519
+ bytesAccepted,
520
+ bytesTotal,
521
+ );
522
+ this.options?.onChunkComplete?.(chunkSize, bytesAccepted, bytesTotal);
523
+ },
524
+ onSuccess: (_result: UploadFile) => {
525
+ // Note: This gets called when upload phase completes, not flow completion
526
+ // Flow completion is handled by FlowEnd event
527
+ this.updateState({
528
+ progress: 100,
529
+ });
530
+ // Don't call callbacks.onSuccess here - wait for FlowEnd event with TOutput
531
+ },
532
+ onError: (error: Error) => {
533
+ this.updateState({
534
+ status: "error",
535
+ error,
536
+ });
537
+ this.callbacks.onError?.(error);
538
+ this.options?.onError?.(error);
539
+ this.abortController = null;
540
+ },
541
+ onAbort: () => {
542
+ this.updateState({
543
+ status: "aborted",
544
+ });
545
+ this.callbacks.onAbort?.();
546
+ this.options?.onAbort?.();
547
+ this.abortController = null;
548
+ },
549
+ onShouldRetry: this.options?.onShouldRetry,
550
+ };
551
+
552
+ // Start the flow upload
553
+ this.abortController = await this.flowUploadFn(
554
+ input,
555
+ this.options.flowConfig,
556
+ internalOptions,
557
+ );
558
+ } catch (error) {
559
+ // Handle errors from upload initiation
560
+ const uploadError =
561
+ error instanceof Error ? error : new Error(String(error));
562
+ this.updateState({
563
+ status: "error",
564
+ error: uploadError,
565
+ });
566
+
567
+ this.callbacks.onError?.(uploadError);
568
+ this.options?.onError?.(uploadError);
569
+ this.abortController = null;
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Abort the current flow upload
575
+ */
576
+ abort(): void {
577
+ if (this.abortController) {
578
+ this.abortController.abort();
579
+ // Note: State update happens in onAbort callback or FlowCancel event
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Pause the current flow upload
585
+ */
586
+ pause(): void {
587
+ if (this.abortController) {
588
+ this.abortController.pause();
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Reset the flow upload state to idle
594
+ */
595
+ reset(): void {
596
+ if (this.abortController) {
597
+ this.abortController.abort();
598
+ this.abortController = null;
599
+ }
600
+
601
+ this.state = { ...initialState } as FlowUploadState<TOutput>;
602
+ this.callbacks.onStateChange(this.state);
603
+ }
604
+
605
+ /**
606
+ * Clean up resources (call when disposing the manager)
607
+ */
608
+ cleanup(): void {
609
+ if (this.abortController) {
610
+ this.abortController.abort();
611
+ this.abortController = null;
612
+ }
613
+ }
614
+ }
@@ -0,0 +1,28 @@
1
+ export {
2
+ type EventFilterOptions,
3
+ type EventSource,
4
+ EventSubscriptionManager,
5
+ type GenericEvent,
6
+ type SubscriptionEventHandler,
7
+ type UnsubscribeFunction,
8
+ } from "./event-subscription-manager";
9
+ export {
10
+ type FlowConfig,
11
+ FlowManager,
12
+ type FlowManagerCallbacks,
13
+ type FlowUploadAbortController,
14
+ type FlowUploadFunction,
15
+ type FlowUploadInput,
16
+ type FlowUploadState,
17
+ type FlowUploadStatus,
18
+ type InternalFlowUploadOptions,
19
+ } from "./flow-manager";
20
+ export {
21
+ type UploadAbortController,
22
+ type UploadFunction,
23
+ type UploadInput,
24
+ UploadManager,
25
+ type UploadManagerCallbacks,
26
+ type UploadState,
27
+ type UploadStatus,
28
+ } from "./upload-manager";