@uploadista/vue 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,431 @@
1
+ import type {
2
+ FlowUploadConfig,
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 { computed, onUnmounted, readonly, ref } from "vue";
9
+ import { useUploadistaClient } from "./useUploadistaClient";
10
+
11
+ /**
12
+ * Type guard to check if an event is a flow event
13
+ */
14
+ function isFlowEvent(event: UploadistaEvent): event is FlowEvent {
15
+ const flowEvent = event as FlowEvent;
16
+ return (
17
+ flowEvent.eventType === EventType.FlowStart ||
18
+ flowEvent.eventType === EventType.FlowEnd ||
19
+ flowEvent.eventType === EventType.FlowError ||
20
+ flowEvent.eventType === EventType.NodeStart ||
21
+ flowEvent.eventType === EventType.NodeEnd ||
22
+ flowEvent.eventType === EventType.NodePause ||
23
+ flowEvent.eventType === EventType.NodeResume ||
24
+ flowEvent.eventType === EventType.NodeError
25
+ );
26
+ }
27
+
28
+ export type FlowUploadStatus =
29
+ | "idle"
30
+ | "uploading"
31
+ | "processing"
32
+ | "success"
33
+ | "error"
34
+ | "aborted";
35
+
36
+ export interface FlowUploadState<TOutput = UploadFile> {
37
+ status: FlowUploadStatus;
38
+ progress: number;
39
+ bytesUploaded: number;
40
+ totalBytes: number | null;
41
+ error: Error | null;
42
+ result: TOutput | null;
43
+ jobId: string | null;
44
+ // Flow execution tracking
45
+ flowStarted: boolean;
46
+ currentNodeName: string | null;
47
+ currentNodeType: string | null;
48
+ // Full flow outputs (all output nodes)
49
+ flowOutputs: Record<string, unknown> | null;
50
+ }
51
+
52
+ export interface UseFlowUploadOptions<TOutput = UploadFile> {
53
+ /**
54
+ * Flow configuration
55
+ */
56
+ flowConfig: FlowUploadConfig;
57
+
58
+ /**
59
+ * Called when upload progress updates
60
+ */
61
+ onProgress?: (
62
+ progress: number,
63
+ bytesUploaded: number,
64
+ totalBytes: number | null,
65
+ ) => void;
66
+
67
+ /**
68
+ * Called when a chunk completes
69
+ */
70
+ onChunkComplete?: (
71
+ chunkSize: number,
72
+ bytesAccepted: number,
73
+ bytesTotal: number | null,
74
+ ) => void;
75
+
76
+ /**
77
+ * Called when the flow completes successfully (receives full flow outputs)
78
+ * This is the recommended callback for multi-output flows
79
+ * Format: { [outputNodeId]: result, ... }
80
+ */
81
+ onFlowComplete?: (outputs: Record<string, unknown>) => void;
82
+
83
+ /**
84
+ * Called when upload succeeds (legacy, single-output flows)
85
+ * For single-output flows, receives the value from the specified outputNodeId
86
+ * or the first output node if outputNodeId is not specified
87
+ */
88
+ onSuccess?: (result: TOutput) => void;
89
+
90
+ /**
91
+ * Called when upload fails
92
+ */
93
+ onError?: (error: Error) => void;
94
+
95
+ /**
96
+ * Called when upload is aborted
97
+ */
98
+ onAbort?: () => void;
99
+
100
+ /**
101
+ * Custom retry logic
102
+ */
103
+ onShouldRetry?: (error: Error, retryAttempt: number) => boolean;
104
+ }
105
+
106
+ const initialState: FlowUploadState = {
107
+ status: "idle",
108
+ progress: 0,
109
+ bytesUploaded: 0,
110
+ totalBytes: null,
111
+ error: null,
112
+ result: null,
113
+ jobId: null,
114
+ flowStarted: false,
115
+ currentNodeName: null,
116
+ currentNodeType: null,
117
+ flowOutputs: null,
118
+ };
119
+
120
+ /**
121
+ * Vue composable for uploading files through a flow.
122
+ *
123
+ * This composable provides a simple interface for uploading files through a flow.
124
+ * The flow handles the upload process and can perform post-processing like
125
+ * saving to storage, optimizing images, etc.
126
+ *
127
+ * Must be used within a component tree that has the Uploadista plugin installed.
128
+ * Events are automatically wired up through the plugin.
129
+ *
130
+ * @example
131
+ * ```vue
132
+ * <script setup lang="ts">
133
+ * import { useFlowUpload } from '@uploadista/vue';
134
+ *
135
+ * const flowUpload = useFlowUpload({
136
+ * flowConfig: {
137
+ * flowId: "my-upload-flow",
138
+ * storageId: "my-storage",
139
+ * },
140
+ * onSuccess: (result) => {
141
+ * console.log("Upload complete:", result);
142
+ * },
143
+ * });
144
+ *
145
+ * const handleFileChange = (event: Event) => {
146
+ * const file = (event.target as HTMLInputElement).files?.[0];
147
+ * if (file) flowUpload.upload(file);
148
+ * };
149
+ * </script>
150
+ *
151
+ * <template>
152
+ * <input type="file" @change="handleFileChange" />
153
+ * </template>
154
+ * ```
155
+ */
156
+ export function useFlowUpload<TOutput = UploadFile>(
157
+ options: UseFlowUploadOptions<TOutput>,
158
+ ) {
159
+ // Get client and event subscription
160
+ const client = useUploadistaClient();
161
+ const state = ref<FlowUploadState<TOutput>>(
162
+ initialState as FlowUploadState<TOutput>,
163
+ );
164
+ const abortFn = ref<(() => void) | null>(null);
165
+ const jobId = ref<string | null>(null);
166
+
167
+ // Handle flow events
168
+ const handleFlowEvent = (event: FlowEvent) => {
169
+ console.log("handleFlowEvent", event);
170
+ // Only handle events for the current job
171
+ if (!jobId.value || event.jobId !== jobId.value) {
172
+ console.log("handleFlowEvent - jobId mismatch", event.jobId, jobId.value);
173
+ return;
174
+ }
175
+
176
+ switch (event.eventType) {
177
+ case EventType.FlowStart:
178
+ state.value = {
179
+ ...state.value,
180
+ flowStarted: true,
181
+ status: "processing",
182
+ };
183
+ break;
184
+
185
+ case EventType.NodeStart:
186
+ state.value = {
187
+ ...state.value,
188
+ status: "processing",
189
+ currentNodeName: event.nodeName,
190
+ currentNodeType: event.nodeType,
191
+ };
192
+ break;
193
+
194
+ case EventType.NodePause:
195
+ // When input node pauses, it's waiting for upload - switch to uploading state
196
+ state.value = {
197
+ ...state.value,
198
+ status: "uploading",
199
+ currentNodeName: event.nodeName,
200
+ };
201
+ break;
202
+
203
+ case EventType.NodeResume:
204
+ // When node resumes, upload is complete - switch to processing state
205
+ state.value = {
206
+ ...state.value,
207
+ status: "processing",
208
+ currentNodeName: event.nodeName,
209
+ currentNodeType: event.nodeType,
210
+ };
211
+ break;
212
+
213
+ case EventType.NodeEnd:
214
+ state.value = {
215
+ ...state.value,
216
+ status:
217
+ state.value.status === "uploading"
218
+ ? "processing"
219
+ : state.value.status,
220
+ currentNodeName: null,
221
+ currentNodeType: null,
222
+ };
223
+ break;
224
+
225
+ case EventType.FlowEnd: {
226
+ // Get flow outputs from the event result
227
+ const flowOutputs = (event.result as Record<string, unknown>) || null;
228
+
229
+ // Call onFlowComplete with full outputs
230
+ if (flowOutputs && options.onFlowComplete) {
231
+ options.onFlowComplete(flowOutputs);
232
+ }
233
+
234
+ // Extract single output for onSuccess callback
235
+ let extractedOutput: TOutput | null = null;
236
+ if (flowOutputs) {
237
+ if (
238
+ options.flowConfig.outputNodeId &&
239
+ options.flowConfig.outputNodeId in flowOutputs
240
+ ) {
241
+ // Use specified output node
242
+ extractedOutput = flowOutputs[
243
+ options.flowConfig.outputNodeId
244
+ ] as TOutput;
245
+ } else {
246
+ // Use first output node
247
+ const firstOutputValue = Object.values(flowOutputs)[0];
248
+ extractedOutput = firstOutputValue as TOutput;
249
+ }
250
+ }
251
+
252
+ // Call onSuccess with extracted output
253
+ if (extractedOutput && options.onSuccess) {
254
+ options.onSuccess(extractedOutput);
255
+ }
256
+
257
+ state.value = {
258
+ ...state.value,
259
+ status: "success",
260
+ currentNodeName: null,
261
+ currentNodeType: null,
262
+ result: extractedOutput,
263
+ flowOutputs,
264
+ };
265
+ break;
266
+ }
267
+
268
+ case EventType.FlowError:
269
+ state.value = {
270
+ ...state.value,
271
+ status: "error",
272
+ error: new Error(event.error),
273
+ };
274
+ options.onError?.(new Error(event.error));
275
+ break;
276
+
277
+ case EventType.NodeError:
278
+ state.value = {
279
+ ...state.value,
280
+ status: "error",
281
+ error: new Error(event.error),
282
+ };
283
+ options.onError?.(new Error(event.error));
284
+ break;
285
+ }
286
+ };
287
+
288
+ // Automatically subscribe to flow events and upload events
289
+ const unsubscribe = client.subscribeToEvents((event: UploadistaEvent) => {
290
+ console.log("subscribeToEvents", event);
291
+ // Handle flow events
292
+ if (isFlowEvent(event)) {
293
+ handleFlowEvent(event);
294
+ return;
295
+ }
296
+
297
+ // Handle upload progress events for this job's upload
298
+ const uploadEvent = event as {
299
+ type: string;
300
+ data?: { id: string; progress: number; total: number };
301
+ flow?: { jobId: string };
302
+ };
303
+ if (
304
+ uploadEvent.type === UploadEventType.UPLOAD_PROGRESS &&
305
+ uploadEvent.flow?.jobId === jobId.value &&
306
+ uploadEvent.data
307
+ ) {
308
+ const { progress: bytesUploaded, total: totalBytes } = uploadEvent.data;
309
+ const progress = totalBytes
310
+ ? Math.round((bytesUploaded / totalBytes) * 100)
311
+ : 0;
312
+
313
+ state.value = {
314
+ ...state.value,
315
+ progress,
316
+ bytesUploaded,
317
+ totalBytes,
318
+ };
319
+ }
320
+ });
321
+
322
+ // Cleanup on unmount
323
+ onUnmounted(() => {
324
+ unsubscribe();
325
+ });
326
+
327
+ const abort = () => {
328
+ if (abortFn.value) {
329
+ abortFn.value();
330
+ abortFn.value = null;
331
+
332
+ state.value = {
333
+ ...state.value,
334
+ status: "aborted",
335
+ };
336
+
337
+ options.onAbort?.();
338
+ }
339
+ };
340
+
341
+ const upload = async (file: File | Blob) => {
342
+ jobId.value = null;
343
+
344
+ state.value = {
345
+ ...initialState,
346
+ status: "uploading",
347
+ totalBytes: file.size,
348
+ } as FlowUploadState<TOutput>;
349
+
350
+ try {
351
+ const { abort: _abortFn } = await client.client.uploadWithFlow(
352
+ file,
353
+ options.flowConfig,
354
+ {
355
+ onJobStart: (id: string) => {
356
+ jobId.value = id;
357
+ state.value = { ...state.value, jobId: id };
358
+ },
359
+ onProgress: (
360
+ _uploadId: string,
361
+ bytesUploaded: number,
362
+ totalBytes: number | null,
363
+ ) => {
364
+ const progress = totalBytes
365
+ ? Math.round((bytesUploaded / totalBytes) * 100)
366
+ : 0;
367
+
368
+ state.value = {
369
+ ...state.value,
370
+ progress,
371
+ bytesUploaded,
372
+ totalBytes,
373
+ };
374
+
375
+ options.onProgress?.(progress, bytesUploaded, totalBytes);
376
+ },
377
+ onChunkComplete: options.onChunkComplete,
378
+ onSuccess: (_result: UploadFile) => {
379
+ // Upload phase is complete, now waiting for flow execution
380
+ // Status transition from "uploading" to "processing" is handled by NodeResume event
381
+ state.value = {
382
+ ...state.value,
383
+ progress: 100,
384
+ };
385
+ // Don't call onSuccess here - wait for FlowEnd event
386
+ },
387
+ onError: (error: Error) => {
388
+ state.value = {
389
+ ...state.value,
390
+ status: "error",
391
+ error,
392
+ };
393
+
394
+ options.onError?.(error);
395
+ },
396
+ onShouldRetry: options.onShouldRetry,
397
+ },
398
+ );
399
+
400
+ abortFn.value = _abortFn;
401
+ } catch (error) {
402
+ state.value = {
403
+ ...state.value,
404
+ status: "error",
405
+ error: error as Error,
406
+ };
407
+
408
+ options.onError?.(error as Error);
409
+ }
410
+ };
411
+
412
+ const reset = () => {
413
+ state.value = initialState as FlowUploadState<TOutput>;
414
+ abortFn.value = null;
415
+ jobId.value = null;
416
+ };
417
+
418
+ return {
419
+ state: readonly(state),
420
+ upload,
421
+ abort,
422
+ reset,
423
+ isUploading: computed(
424
+ () =>
425
+ state.value.status === "uploading" ||
426
+ state.value.status === "processing",
427
+ ),
428
+ isUploadingFile: computed(() => state.value.status === "uploading"),
429
+ isProcessing: computed(() => state.value.status === "processing"),
430
+ };
431
+ }