@uploadista/react 0.0.3

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,568 @@
1
+ import type {
2
+ FlowUploadOptions,
3
+ UploadistaEvent,
4
+ } from "@uploadista/client-browser";
5
+ import { EventType, type FlowEvent } from "@uploadista/core/flow";
6
+ import type { UploadFile } from "@uploadista/core/types";
7
+ import { UploadEventType } from "@uploadista/core/types";
8
+ import { useCallback, useEffect, useRef, useState } from "react";
9
+ import { useUploadistaContext } from "../components/uploadista-provider";
10
+
11
+ /**
12
+ * Type guard to check if an event is a flow event
13
+ */
14
+ function isFlowEvent(event: UploadistaEvent): event is FlowEvent {
15
+ // FlowEvent has eventType, not type
16
+ const flowEvent = event as FlowEvent;
17
+ return (
18
+ flowEvent.eventType === EventType.FlowStart ||
19
+ flowEvent.eventType === EventType.FlowEnd ||
20
+ flowEvent.eventType === EventType.FlowError ||
21
+ flowEvent.eventType === EventType.NodeStart ||
22
+ flowEvent.eventType === EventType.NodeEnd ||
23
+ flowEvent.eventType === EventType.NodePause ||
24
+ flowEvent.eventType === EventType.NodeResume ||
25
+ flowEvent.eventType === EventType.NodeError
26
+ );
27
+ }
28
+
29
+ /**
30
+ * Possible states for a flow upload lifecycle.
31
+ * Flow uploads progress through: idle → uploading → processing → success/error/aborted
32
+ */
33
+ export type FlowUploadStatus =
34
+ | "idle"
35
+ | "uploading"
36
+ | "processing"
37
+ | "success"
38
+ | "error"
39
+ | "aborted";
40
+
41
+ /**
42
+ * Complete state information for a flow upload operation.
43
+ * Tracks both the upload phase (file transfer) and processing phase (flow execution).
44
+ *
45
+ * @template TOutput - Type of the final output from the flow (defaults to UploadFile)
46
+ *
47
+ * @property status - Current upload status (idle, uploading, processing, success, error, aborted)
48
+ * @property progress - Upload progress percentage (0-100)
49
+ * @property bytesUploaded - Number of bytes successfully uploaded
50
+ * @property totalBytes - Total file size in bytes (null if unknown)
51
+ * @property error - Error object if upload or processing failed
52
+ * @property result - Final output from the flow (available when status is "success")
53
+ * @property jobId - Unique identifier for the flow execution job
54
+ * @property flowStarted - Whether the flow processing has started
55
+ * @property currentNodeName - Name of the currently executing flow node
56
+ * @property currentNodeType - Type of the currently executing flow node
57
+ * @property flowOutputs - Complete outputs from all output nodes in the flow
58
+ */
59
+ export interface FlowUploadState<TOutput = UploadFile> {
60
+ status: FlowUploadStatus;
61
+ progress: number;
62
+ bytesUploaded: number;
63
+ totalBytes: number | null;
64
+ error: Error | null;
65
+ result: TOutput | null;
66
+ jobId: string | null;
67
+ // Flow execution tracking
68
+ flowStarted: boolean;
69
+ currentNodeName: string | null;
70
+ currentNodeType: string | null;
71
+ // Full flow outputs (all output nodes)
72
+ flowOutputs: Record<string, unknown> | null;
73
+ }
74
+
75
+ /**
76
+ * Return value from the useFlowUpload hook with upload control methods and state.
77
+ *
78
+ * @template TOutput - Type of the final output from the flow (defaults to UploadFile)
79
+ *
80
+ * @property state - Complete flow upload state with progress and outputs
81
+ * @property upload - Function to initiate file upload through the flow
82
+ * @property abort - Cancel the current upload and flow execution
83
+ * @property reset - Reset state to idle (clears all data)
84
+ * @property isUploading - True when upload or processing is active
85
+ * @property isUploadingFile - True only during file upload phase
86
+ * @property isProcessing - True only during flow processing phase
87
+ */
88
+ export interface UseFlowUploadReturn<TOutput = UploadFile> {
89
+ /**
90
+ * Current upload state
91
+ */
92
+ state: FlowUploadState<TOutput>;
93
+
94
+ /**
95
+ * Upload a file through the flow
96
+ */
97
+ upload: (file: File | Blob) => Promise<void>;
98
+
99
+ /**
100
+ * Abort the current upload
101
+ */
102
+ abort: () => void;
103
+
104
+ /**
105
+ * Reset the upload state
106
+ */
107
+ reset: () => void;
108
+
109
+ /**
110
+ * Whether an upload or flow execution is in progress (uploading OR processing)
111
+ */
112
+ isUploading: boolean;
113
+
114
+ /**
115
+ * Whether the file is currently being uploaded (chunks being sent)
116
+ */
117
+ isUploadingFile: boolean;
118
+
119
+ /**
120
+ * Whether the flow is currently processing (after upload completes)
121
+ */
122
+ isProcessing: boolean;
123
+ }
124
+
125
+ const initialState: FlowUploadState = {
126
+ status: "idle",
127
+ progress: 0,
128
+ bytesUploaded: 0,
129
+ totalBytes: null,
130
+ error: null,
131
+ result: null,
132
+ jobId: null,
133
+ flowStarted: false,
134
+ currentNodeName: null,
135
+ currentNodeType: null,
136
+ flowOutputs: null,
137
+ };
138
+
139
+ /**
140
+ * React hook for uploading files through a flow with automatic flow execution.
141
+ * Handles both the file upload phase and the flow processing phase, providing
142
+ * real-time progress updates and flow node execution tracking.
143
+ *
144
+ * The flow engine processes the uploaded file through a DAG of nodes, which can
145
+ * perform operations like image optimization, storage saving, webhooks, etc.
146
+ *
147
+ * Must be used within an UploadistaProvider. Flow events (node start/end, flow complete)
148
+ * are automatically subscribed through the provider context.
149
+ *
150
+ * @template TOutput - Type of the final result from the flow (defaults to UploadFile)
151
+ * @param options - Flow upload configuration including flow ID and event handlers
152
+ * @returns Flow upload state and control methods
153
+ *
154
+ * @example
155
+ * ```tsx
156
+ * // Basic flow upload with progress tracking
157
+ * function ImageUploader() {
158
+ * const flowUpload = useFlowUpload({
159
+ * flowConfig: {
160
+ * flowId: "image-optimization-flow",
161
+ * storageId: "s3-images",
162
+ * outputNodeId: "optimized-output", // Optional: specify which output to use
163
+ * },
164
+ * onSuccess: (result) => {
165
+ * console.log("Image optimized and saved:", result);
166
+ * },
167
+ * onFlowComplete: (outputs) => {
168
+ * console.log("All flow outputs:", outputs);
169
+ * // outputs might include: { thumbnail: {...}, optimized: {...}, original: {...} }
170
+ * },
171
+ * onError: (error) => {
172
+ * console.error("Upload or processing failed:", error);
173
+ * },
174
+ * });
175
+ *
176
+ * return (
177
+ * <div>
178
+ * <input
179
+ * type="file"
180
+ * accept="image/*"
181
+ * onChange={(e) => {
182
+ * const file = e.target.files?.[0];
183
+ * if (file) flowUpload.upload(file);
184
+ * }}
185
+ * />
186
+ *
187
+ * {flowUpload.isUploadingFile && (
188
+ * <div>Uploading... {flowUpload.state.progress}%</div>
189
+ * )}
190
+ *
191
+ * {flowUpload.isProcessing && (
192
+ * <div>
193
+ * Processing...
194
+ * {flowUpload.state.currentNodeName && (
195
+ * <span>Current step: {flowUpload.state.currentNodeName}</span>
196
+ * )}
197
+ * </div>
198
+ * )}
199
+ *
200
+ * {flowUpload.state.status === "success" && (
201
+ * <div>
202
+ * <p>Upload complete!</p>
203
+ * {flowUpload.state.result && (
204
+ * <img src={flowUpload.state.result.url} alt="Uploaded" />
205
+ * )}
206
+ * </div>
207
+ * )}
208
+ *
209
+ * {flowUpload.state.status === "error" && (
210
+ * <div>
211
+ * <p>Error: {flowUpload.state.error?.message}</p>
212
+ * <button onClick={flowUpload.reset}>Try Again</button>
213
+ * </div>
214
+ * )}
215
+ *
216
+ * {flowUpload.isUploading && (
217
+ * <button onClick={flowUpload.abort}>Cancel</button>
218
+ * )}
219
+ * </div>
220
+ * );
221
+ * }
222
+ * ```
223
+ *
224
+ * @see {@link useMultiFlowUpload} for uploading multiple files through a flow
225
+ * @see {@link useUpload} for simple uploads without flow processing
226
+ */
227
+ export function useFlowUpload<TOutput = UploadFile>(
228
+ options: FlowUploadOptions<TOutput>,
229
+ ): UseFlowUploadReturn<TOutput> {
230
+ // Get client and event subscription from context
231
+ const client = useUploadistaContext();
232
+ const [state, setState] = useState<FlowUploadState<TOutput>>(
233
+ initialState as FlowUploadState<TOutput>,
234
+ );
235
+ const abortRef = useRef<(() => void) | null>(null);
236
+ const onSuccessRef = useRef(options.onSuccess);
237
+ const onErrorRef = useRef(options.onError);
238
+ const onFlowCompleteRef = useRef(options.onFlowComplete);
239
+ const outputNodeIdRef = useRef(options.flowConfig.outputNodeId);
240
+
241
+ // Update refs when callbacks change
242
+ useEffect(() => {
243
+ onSuccessRef.current = options.onSuccess;
244
+ onErrorRef.current = options.onError;
245
+ onFlowCompleteRef.current = options.onFlowComplete;
246
+ outputNodeIdRef.current = options.flowConfig.outputNodeId;
247
+ }, [
248
+ options.onSuccess,
249
+ options.onError,
250
+ options.onFlowComplete,
251
+ options.flowConfig.outputNodeId,
252
+ ]);
253
+
254
+ // Store jobId in ref for event handling
255
+ const jobIdRef = useRef<string | null>(null);
256
+
257
+ useEffect(() => {
258
+ jobIdRef.current = state.jobId;
259
+ }, [state.jobId]);
260
+
261
+ // Create stable event handler
262
+ const handleFlowEvent = useCallback((event: FlowEvent) => {
263
+ console.log(
264
+ "[useFlowUpload] Received event:",
265
+ event,
266
+ "Current jobId:",
267
+ jobIdRef.current,
268
+ );
269
+
270
+ // Only handle events for the current job
271
+ if (!jobIdRef.current || event.jobId !== jobIdRef.current) {
272
+ console.log("[useFlowUpload] Ignoring event - jobId mismatch");
273
+ return;
274
+ }
275
+
276
+ console.log("[useFlowUpload] Processing event type:", event.eventType);
277
+
278
+ switch (event.eventType) {
279
+ case EventType.FlowStart:
280
+ console.log("[useFlowUpload] Flow started");
281
+ setState((prev) => ({
282
+ ...prev,
283
+ flowStarted: true,
284
+ status: "processing",
285
+ }));
286
+ break;
287
+
288
+ case EventType.NodeStart:
289
+ console.log("[useFlowUpload] Node started:", event.nodeName);
290
+ setState((prev) => ({
291
+ ...prev,
292
+ status: "processing",
293
+ currentNodeName: event.nodeName,
294
+ currentNodeType: event.nodeType,
295
+ }));
296
+ break;
297
+
298
+ case EventType.NodePause:
299
+ console.log(
300
+ "[useFlowUpload] Node paused (waiting for upload):",
301
+ event.nodeName,
302
+ );
303
+ // When input node pauses, it's waiting for upload - switch to uploading state
304
+ setState((prev) => ({
305
+ ...prev,
306
+ status: "uploading",
307
+ currentNodeName: event.nodeName,
308
+ // NodePause doesn't have nodeType, keep previous value
309
+ }));
310
+ break;
311
+
312
+ case EventType.NodeResume:
313
+ console.log(
314
+ "[useFlowUpload] Node resumed (upload complete):",
315
+ event.nodeName,
316
+ );
317
+ // When node resumes, upload is complete - switch to processing state
318
+ setState((prev) => ({
319
+ ...prev,
320
+ status: "processing",
321
+ currentNodeName: event.nodeName,
322
+ currentNodeType: event.nodeType,
323
+ }));
324
+ break;
325
+
326
+ case EventType.NodeEnd:
327
+ console.log("[useFlowUpload] Node ended:", event.nodeName);
328
+ setState((prev) => ({
329
+ ...prev,
330
+ status: prev.status === "uploading" ? "processing" : prev.status,
331
+ currentNodeName: null,
332
+ currentNodeType: null,
333
+ }));
334
+ break;
335
+
336
+ case EventType.FlowEnd:
337
+ console.log("[useFlowUpload] Flow ended, processing outputs");
338
+ setState((prev) => {
339
+ // Get flow outputs from the event result
340
+ const flowOutputs = (event.result as Record<string, unknown>) || null;
341
+
342
+ console.log("[useFlowUpload] Flow outputs:", flowOutputs);
343
+
344
+ // Call onFlowComplete with full outputs
345
+ if (flowOutputs && onFlowCompleteRef.current) {
346
+ console.log(
347
+ "[useFlowUpload] Calling onFlowComplete with outputs:",
348
+ flowOutputs,
349
+ );
350
+ onFlowCompleteRef.current(flowOutputs);
351
+ }
352
+
353
+ // Extract single output for onSuccess callback
354
+ let extractedOutput: TOutput | null = null;
355
+ if (flowOutputs) {
356
+ if (
357
+ outputNodeIdRef.current &&
358
+ outputNodeIdRef.current in flowOutputs
359
+ ) {
360
+ // Use specified output node
361
+ extractedOutput = flowOutputs[outputNodeIdRef.current] as TOutput;
362
+ console.log(
363
+ "[useFlowUpload] Extracted output from specified node:",
364
+ outputNodeIdRef.current,
365
+ );
366
+ } else {
367
+ // Use first output node
368
+ const firstOutputValue = Object.values(flowOutputs)[0];
369
+ extractedOutput = firstOutputValue as TOutput;
370
+ console.log("[useFlowUpload] Extracted output from first node");
371
+ }
372
+ }
373
+
374
+ // Call onSuccess with extracted output
375
+ if (extractedOutput && onSuccessRef.current) {
376
+ console.log(
377
+ "[useFlowUpload] Calling onSuccess with result:",
378
+ extractedOutput,
379
+ );
380
+ onSuccessRef.current(extractedOutput);
381
+ } else if (!extractedOutput && onSuccessRef.current) {
382
+ console.warn("[useFlowUpload] No result available for onSuccess");
383
+ }
384
+
385
+ return {
386
+ ...prev,
387
+ status: "success",
388
+ currentNodeName: null,
389
+ currentNodeType: null,
390
+ result: extractedOutput,
391
+ flowOutputs,
392
+ };
393
+ });
394
+ break;
395
+
396
+ case EventType.FlowError:
397
+ console.log("[useFlowUpload] Flow error:", event.error);
398
+ setState((prev) => ({
399
+ ...prev,
400
+ status: "error",
401
+ error: new Error(event.error),
402
+ }));
403
+ onErrorRef.current?.(new Error(event.error));
404
+ break;
405
+
406
+ case EventType.NodeError:
407
+ console.log("[useFlowUpload] Node error:", event.error);
408
+ setState((prev) => ({
409
+ ...prev,
410
+ status: "error",
411
+ error: new Error(event.error),
412
+ }));
413
+ onErrorRef.current?.(new Error(event.error));
414
+ break;
415
+ }
416
+ }, []);
417
+
418
+ // Automatically subscribe to flow events and upload events from context
419
+ useEffect(() => {
420
+ console.log("[useFlowUpload] Subscribing to events from context");
421
+ const unsubscribe = client.subscribeToEvents((event: UploadistaEvent) => {
422
+ // Handle flow events
423
+ if (isFlowEvent(event)) {
424
+ handleFlowEvent(event);
425
+ return;
426
+ }
427
+
428
+ // Handle upload progress events for this job's upload
429
+ const uploadEvent = event as {
430
+ type: string;
431
+ data?: { id: string; progress: number; total: number };
432
+ flow?: { jobId: string };
433
+ };
434
+ if (
435
+ uploadEvent.type === UploadEventType.UPLOAD_PROGRESS &&
436
+ uploadEvent.flow?.jobId === jobIdRef.current &&
437
+ uploadEvent.data
438
+ ) {
439
+ const { progress: bytesUploaded, total: totalBytes } = uploadEvent.data;
440
+ const progress = totalBytes
441
+ ? Math.round((bytesUploaded / totalBytes) * 100)
442
+ : 0;
443
+
444
+ console.log("[useFlowUpload] Upload progress event:", {
445
+ progress,
446
+ bytesUploaded,
447
+ totalBytes,
448
+ jobId: uploadEvent.flow.jobId,
449
+ });
450
+
451
+ setState((prev) => ({
452
+ ...prev,
453
+ progress,
454
+ bytesUploaded,
455
+ totalBytes,
456
+ }));
457
+ }
458
+ });
459
+
460
+ return unsubscribe;
461
+ }, [client, handleFlowEvent]);
462
+
463
+ const abort = useCallback(() => {
464
+ if (abortRef.current) {
465
+ abortRef.current();
466
+ abortRef.current = null;
467
+
468
+ setState((prev) => ({
469
+ ...prev,
470
+ status: "aborted",
471
+ }));
472
+
473
+ options.onAbort?.();
474
+ }
475
+ }, [options]);
476
+
477
+ const upload = useCallback(
478
+ async (file: File | Blob) => {
479
+ jobIdRef.current = null;
480
+
481
+ setState({
482
+ ...initialState,
483
+ status: "uploading",
484
+ totalBytes: file.size,
485
+ } as FlowUploadState<TOutput>);
486
+
487
+ try {
488
+ const { abort: _abortFn } = await client.client.uploadWithFlow(
489
+ file,
490
+ options.flowConfig,
491
+ {
492
+ onJobStart: (jobId: string) => {
493
+ jobIdRef.current = jobId;
494
+ setState((prev) => ({ ...prev, jobId }));
495
+ },
496
+ onProgress: (
497
+ _uploadId: string,
498
+ bytesUploaded: number,
499
+ totalBytes: number | null,
500
+ ) => {
501
+ const progress = totalBytes
502
+ ? Math.round((bytesUploaded / totalBytes) * 100)
503
+ : 0;
504
+
505
+ setState((prev) => ({
506
+ ...prev,
507
+ progress,
508
+ bytesUploaded,
509
+ totalBytes,
510
+ }));
511
+
512
+ options.onProgress?.(progress, bytesUploaded, totalBytes);
513
+ },
514
+ onChunkComplete: options.onChunkComplete,
515
+ onSuccess: (_result: UploadFile) => {
516
+ // Upload phase is complete, now waiting for flow execution
517
+ // Note: we don't store the upload result as our final result
518
+ // The final result will come from the FlowEnd event
519
+ // Status transition from "uploading" to "processing" is handled by NodeResume event
520
+ setState((prev) => ({
521
+ ...prev,
522
+ progress: 100,
523
+ }));
524
+ // Don't call onSuccess here - wait for FlowEnd event
525
+ },
526
+ onError: (error: Error) => {
527
+ setState((prev) => ({
528
+ ...prev,
529
+ status: "error",
530
+ error,
531
+ }));
532
+
533
+ options.onError?.(error);
534
+ },
535
+ onShouldRetry: options.onShouldRetry,
536
+ },
537
+ );
538
+
539
+ abortRef.current = _abortFn;
540
+ } catch (error) {
541
+ setState((prev) => ({
542
+ ...prev,
543
+ status: "error",
544
+ error: error as Error,
545
+ }));
546
+
547
+ options.onError?.(error as Error);
548
+ }
549
+ },
550
+ [client, options],
551
+ );
552
+
553
+ const reset = useCallback(() => {
554
+ setState(initialState as FlowUploadState<TOutput>);
555
+ abortRef.current = null;
556
+ jobIdRef.current = null;
557
+ }, []);
558
+
559
+ return {
560
+ state,
561
+ upload,
562
+ abort,
563
+ reset,
564
+ isUploading: state.status === "uploading" || state.status === "processing",
565
+ isUploadingFile: state.status === "uploading",
566
+ isProcessing: state.status === "processing",
567
+ };
568
+ }