@uploadista/client-core 0.1.2 → 0.1.3-beta.10

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@uploadista/client-core",
3
3
  "type": "module",
4
- "version": "0.1.2",
4
+ "version": "0.1.3-beta.10",
5
5
  "description": "Platform-agnostic core upload client logic for Uploadista",
6
6
  "license": "MIT",
7
7
  "author": "Uploadista",
@@ -33,16 +33,16 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "js-base64": "3.7.8",
36
- "@uploadista/core": "0.1.2"
36
+ "@uploadista/core": "0.1.3-beta.10"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "zod": "^4.0.0"
40
40
  },
41
41
  "devDependencies": {
42
- "tsdown": "0.19.0",
43
- "vitest": "4.0.17",
44
- "zod": "4.3.5",
45
- "@uploadista/typescript-config": "0.1.2"
42
+ "tsdown": "0.20.1",
43
+ "vitest": "4.0.18",
44
+ "zod": "4.3.6",
45
+ "@uploadista/typescript-config": "0.1.3-beta.10"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "tsc --noEmit && tsdown",
@@ -9,7 +9,10 @@ import type { Logger } from "../logger";
9
9
  import { createLogger } from "../logger";
10
10
  import { defaultClientCapabilities } from "../mock-data-store";
11
11
  import { NetworkMonitor, type NetworkMonitorConfig } from "../network-monitor";
12
- import type { AbortControllerFactory } from "../services/abort-controller-service";
12
+ import {
13
+ type AbortControllerFactory,
14
+ PausableAbortController,
15
+ } from "../services/abort-controller-service";
13
16
  import type { ChecksumService } from "../services/checksum-service";
14
17
  import type { FileReaderService } from "../services/file-reader-service";
15
18
  import type { FingerprintService } from "../services/fingerprint-service";
@@ -671,13 +674,15 @@ export function createUploadistaClient<UploadInput>({
671
674
  onShouldRetry,
672
675
  onJobStart,
673
676
  onError,
677
+ onAbort,
674
678
  }: Omit<
675
679
  UploadistaUploadOptions,
676
680
  "uploadLengthDeferred" | "uploadSize" | "metadata"
677
- > = {},
681
+ > & { onAbort?: () => void } = {},
678
682
  ): Promise<{
679
683
  abort: () => Promise<void>;
680
684
  pause: () => Promise<void>;
685
+ resume: () => Promise<void>;
681
686
  jobId: string;
682
687
  }> => {
683
688
  const source = await fileReader.openFile(file, chunkSize);
@@ -712,12 +717,17 @@ export function createUploadistaClient<UploadInput>({
712
717
  return {
713
718
  abort: async () => {},
714
719
  pause: async () => {},
720
+ resume: async () => {},
715
721
  jobId: "",
716
722
  };
717
723
  }
718
724
 
719
725
  const { jobId, uploadFile, inputNodeId } = result;
720
- const abortController = abortControllerFactory.create();
726
+ // Use PausableAbortController to support pausing chunk uploads
727
+ // Pass a real AbortController from the factory to ensure fetch compatibility
728
+ const abortController = new PausableAbortController(
729
+ abortControllerFactory.create(),
730
+ );
721
731
 
722
732
  // Open upload WebSocket to receive upload progress events
723
733
  await wsManager.openUploadWebSocket(uploadFile.id);
@@ -742,6 +752,7 @@ export function createUploadistaClient<UploadInput>({
742
752
  onChunkComplete,
743
753
  onSuccess,
744
754
  onShouldRetry,
755
+ onAbort,
745
756
  onRetry: (timeout) => {
746
757
  timeoutId = timeout;
747
758
  },
@@ -769,7 +780,13 @@ export function createUploadistaClient<UploadInput>({
769
780
  wsManager.closeUploadWebSocket(uploadFile.id);
770
781
  },
771
782
  pause: async () => {
772
- await uploadistaApi.pauseFlow(jobId);
783
+ // Pause client-side chunk uploads immediately
784
+ // This will cause the upload loop to wait at the start of the next chunk
785
+ abortController.pause();
786
+ },
787
+ resume: async () => {
788
+ // Resume client-side chunk uploads
789
+ abortController.resume();
773
790
  },
774
791
  jobId,
775
792
  };
@@ -812,6 +829,7 @@ export function createUploadistaClient<UploadInput>({
812
829
  ): Promise<{
813
830
  abort: () => Promise<void>;
814
831
  pause: () => Promise<void>;
832
+ resume: () => Promise<void>;
815
833
  jobId: string;
816
834
  }> => {
817
835
  // Start the flow and get job ID
@@ -905,7 +923,11 @@ export function createUploadistaClient<UploadInput>({
905
923
  input.inputType === "file" && input.uploadFile && input.source,
906
924
  )
907
925
  .map(async ({ nodeId, uploadFile, source }) => {
908
- const abortController = abortControllerFactory.create();
926
+ // Use PausableAbortController to support pausing chunk uploads
927
+ // Pass a real AbortController from the factory to ensure fetch compatibility
928
+ const abortController = new PausableAbortController(
929
+ abortControllerFactory.create(),
930
+ );
909
931
  abortControllers.set(nodeId, abortController);
910
932
 
911
933
  const metrics = new UploadMetrics({
@@ -1009,7 +1031,23 @@ export function createUploadistaClient<UploadInput>({
1009
1031
  }
1010
1032
  },
1011
1033
  pause: async () => {
1012
- await uploadistaApi.pauseFlow(jobId);
1034
+ // Pause all client-side chunk uploads
1035
+ for (const controller of abortControllers.values()) {
1036
+ if ("pause" in controller && typeof controller.pause === "function") {
1037
+ controller.pause();
1038
+ }
1039
+ }
1040
+ },
1041
+ resume: async () => {
1042
+ // Resume all client-side chunk uploads
1043
+ for (const controller of abortControllers.values()) {
1044
+ if (
1045
+ "resume" in controller &&
1046
+ typeof controller.resume === "function"
1047
+ ) {
1048
+ controller.resume();
1049
+ }
1050
+ }
1013
1051
  },
1014
1052
  jobId,
1015
1053
  };
@@ -6,7 +6,7 @@ import { detectInputType } from "../utils/input-detection";
6
6
 
7
7
  /**
8
8
  * Flow upload status representing the current state of a flow upload lifecycle.
9
- * Flow uploads progress through: idle → uploading → processing → success/error/aborted
9
+ * Flow uploads progress through: idle → uploading → processing → success/error/aborted/paused
10
10
  */
11
11
  export type FlowUploadStatus =
12
12
  | "idle"
@@ -14,7 +14,8 @@ export type FlowUploadStatus =
14
14
  | "processing"
15
15
  | "success"
16
16
  | "error"
17
- | "aborted";
17
+ | "aborted"
18
+ | "paused";
18
19
 
19
20
  /**
20
21
  * Complete state information for a flow upload operation.
@@ -45,6 +46,8 @@ export interface FlowUploadState {
45
46
  * Available when status is "success".
46
47
  */
47
48
  flowOutputs: TypedOutput[] | null;
49
+ /** Node ID where the flow was paused (available when status is "paused") */
50
+ pausedAtNodeId: string | null;
48
51
  }
49
52
 
50
53
  /**
@@ -146,6 +149,11 @@ export interface FlowManagerCallbacks {
146
149
  * Called when upload or flow is aborted
147
150
  */
148
151
  onAbort?: () => void;
152
+
153
+ /**
154
+ * Called when flow is paused
155
+ */
156
+ onPause?: () => void;
149
157
  }
150
158
 
151
159
  /**
@@ -176,6 +184,7 @@ export interface FlowConfig {
176
184
  export interface FlowUploadAbortController {
177
185
  abort: () => void | Promise<void>;
178
186
  pause: () => void | Promise<void>;
187
+ resume: () => void | Promise<void>;
179
188
  }
180
189
 
181
190
  /**
@@ -293,6 +302,7 @@ const initialState: FlowUploadState = {
293
302
  currentNodeName: null,
294
303
  currentNodeType: null,
295
304
  flowOutputs: null,
305
+ pausedAtNodeId: null,
296
306
  };
297
307
 
298
308
  /**
@@ -378,6 +388,13 @@ export class FlowManager<TInput = FlowUploadInput> {
378
388
  return this.state.status === "processing";
379
389
  }
380
390
 
391
+ /**
392
+ * Check if flow is paused
393
+ */
394
+ isPaused(): boolean {
395
+ return this.state.status === "paused";
396
+ }
397
+
381
398
  /**
382
399
  * Get the current job ID
383
400
  */
@@ -385,6 +402,13 @@ export class FlowManager<TInput = FlowUploadInput> {
385
402
  return this.state.jobId;
386
403
  }
387
404
 
405
+ /**
406
+ * Get the node ID where the flow is paused (if applicable)
407
+ */
408
+ getPausedAtNodeId(): string | null {
409
+ return this.state.pausedAtNodeId;
410
+ }
411
+
388
412
  /**
389
413
  * Update the internal state and notify callbacks
390
414
  */
@@ -516,6 +540,14 @@ export class FlowManager<TInput = FlowUploadInput> {
516
540
  this.callbacks.onAbort?.();
517
541
  this.abortController = null;
518
542
  break;
543
+
544
+ case EventType.FlowPause:
545
+ this.updateState({
546
+ status: "paused",
547
+ pausedAtNodeId: event.pausedAt ?? null,
548
+ });
549
+ this.callbacks.onPause?.();
550
+ break;
519
551
  }
520
552
  }
521
553
 
@@ -723,22 +755,81 @@ export class FlowManager<TInput = FlowUploadInput> {
723
755
  }
724
756
 
725
757
  /**
726
- * Abort the current flow upload
758
+ * Abort the current flow upload.
759
+ * This will:
760
+ * 1. Cancel the flow on the server
761
+ * 2. Abort any in-progress chunk uploads
762
+ * 3. Close WebSocket connections
727
763
  */
728
- abort(): void {
764
+ async abort(): Promise<void> {
729
765
  if (this.abortController) {
730
- this.abortController.abort();
766
+ await this.abortController.abort();
731
767
  // Note: State update happens in onAbort callback or FlowCancel event
732
768
  }
733
769
  }
734
770
 
735
771
  /**
736
- * Pause the current flow upload
772
+ * Pause the current flow upload.
773
+ * During upload phase: Pauses chunk uploads client-side.
774
+ * During processing phase: Calls server to pause flow execution.
737
775
  */
738
- pause(): void {
739
- if (this.abortController) {
740
- this.abortController.pause();
776
+ async pause(): Promise<void> {
777
+ // Only pause if we're actively uploading or processing
778
+ if (
779
+ this.state.status !== "uploading" &&
780
+ this.state.status !== "processing"
781
+ ) {
782
+ return;
741
783
  }
784
+
785
+ // Call abort controller's pause method if available
786
+ // This pauses chunk uploads during upload phase
787
+ if (this.abortController?.pause) {
788
+ try {
789
+ await this.abortController.pause();
790
+ } catch {
791
+ // If pause fails, continue with state update
792
+ }
793
+ }
794
+
795
+ // Update client state to paused
796
+ this.updateState({
797
+ status: "paused",
798
+ pausedAtNodeId: this.state.currentNodeName ?? null,
799
+ });
800
+ }
801
+
802
+ /**
803
+ * Resume a paused flow upload.
804
+ * Continues chunk uploads if paused during upload phase.
805
+ */
806
+ async resume(): Promise<void> {
807
+ // Only resume if we're paused
808
+ if (this.state.status !== "paused") {
809
+ return;
810
+ }
811
+
812
+ // Determine what status to resume to
813
+ // If we had started uploading, resume to uploading state
814
+ // Otherwise, this shouldn't happen (can only pause during upload/processing)
815
+ const resumeStatus: "uploading" | "processing" =
816
+ this.state.flowStarted ? "processing" : "uploading";
817
+
818
+ // Call abort controller's resume method if available
819
+ // This allows chunk uploads to continue
820
+ if (this.abortController?.resume) {
821
+ try {
822
+ await this.abortController.resume();
823
+ } catch {
824
+ // If resume fails, continue with state update
825
+ }
826
+ }
827
+
828
+ // Update client state to resume previous status
829
+ this.updateState({
830
+ status: resumeStatus,
831
+ pausedAtNodeId: null,
832
+ });
742
833
  }
743
834
 
744
835
  /**
@@ -5,6 +5,23 @@
5
5
  export interface AbortControllerLike {
6
6
  readonly signal: AbortSignalLike;
7
7
  abort(reason?: unknown): void;
8
+ /**
9
+ * Pause the operation (optional, may not be supported by all implementations)
10
+ */
11
+ pause?(): void;
12
+ /**
13
+ * Resume a paused operation (optional, may not be supported by all implementations)
14
+ */
15
+ resume?(): void;
16
+ /**
17
+ * Whether the operation is currently paused
18
+ */
19
+ readonly isPaused?: boolean;
20
+ /**
21
+ * Wait for resume to be called (returns immediately if not paused)
22
+ * Used by upload loops to pause between chunk uploads
23
+ */
24
+ waitForResume?(): Promise<void>;
8
25
  }
9
26
 
10
27
  export interface AbortSignalLike {
@@ -19,3 +36,92 @@ export interface AbortControllerFactory {
19
36
  */
20
37
  create(): AbortControllerLike;
21
38
  }
39
+
40
+ /**
41
+ * A wrapper that adds pause/resume functionality to an AbortController.
42
+ * Used by FlowManager and upload loops to support pausing chunk uploads.
43
+ *
44
+ * IMPORTANT: This class requires an inner AbortController to be provided.
45
+ * The inner controller's signal is used directly, which ensures compatibility
46
+ * with browser's fetch API (which requires a real AbortSignal).
47
+ */
48
+ export class PausableAbortController implements AbortControllerLike {
49
+ private _isPaused = false;
50
+ private _resumeResolvers: Array<() => void> = [];
51
+ private readonly _innerController: AbortControllerLike;
52
+
53
+ /**
54
+ * Create a PausableAbortController that wraps an inner AbortController.
55
+ *
56
+ * @param innerController - The inner AbortController to wrap. This should be
57
+ * a real AbortController (from the browser or platform) so that its signal
58
+ * is compatible with fetch API.
59
+ */
60
+ constructor(innerController: AbortControllerLike) {
61
+ this._innerController = innerController;
62
+ }
63
+
64
+ /**
65
+ * Returns the inner controller's signal directly.
66
+ * This ensures compatibility with browser's fetch API which requires a real AbortSignal.
67
+ */
68
+ get signal(): AbortSignalLike {
69
+ return this._innerController.signal;
70
+ }
71
+
72
+ get isPaused(): boolean {
73
+ return this._isPaused;
74
+ }
75
+
76
+ abort(reason?: unknown): void {
77
+ // When aborting, also resolve any pending waitForResume promises
78
+ // so the upload loop can exit cleanly
79
+ for (const resolve of this._resumeResolvers) {
80
+ resolve();
81
+ }
82
+ this._resumeResolvers = [];
83
+
84
+ // Delegate to inner controller
85
+ this._innerController.abort(reason);
86
+ }
87
+
88
+ pause(): void {
89
+ this._isPaused = true;
90
+ }
91
+
92
+ resume(): void {
93
+ this._isPaused = false;
94
+ // Resolve all pending waitForResume promises
95
+ for (const resolve of this._resumeResolvers) {
96
+ resolve();
97
+ }
98
+ this._resumeResolvers = [];
99
+ }
100
+
101
+ /**
102
+ * Returns a promise that resolves when resume() is called.
103
+ * If not paused, resolves immediately.
104
+ * If aborted while waiting, resolves immediately (caller should check signal.aborted).
105
+ */
106
+ waitForResume(): Promise<void> {
107
+ if (!this._isPaused || this._innerController.signal.aborted) {
108
+ return Promise.resolve();
109
+ }
110
+
111
+ return new Promise<void>((resolve) => {
112
+ this._resumeResolvers.push(resolve);
113
+ });
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Helper function to wait for resume if the controller supports it.
119
+ * Returns immediately if the controller doesn't support pause or isn't paused.
120
+ */
121
+ export async function waitForResumeIfPaused(
122
+ controller: AbortControllerLike,
123
+ ): Promise<void> {
124
+ if (controller.isPaused && controller.waitForResume) {
125
+ await controller.waitForResume();
126
+ }
127
+ }
@@ -8,6 +8,7 @@ import type { PlatformService, Timeout } from "../services/platform-service";
8
8
  import type { SmartChunker, SmartChunkerConfig } from "../smart-chunker";
9
9
  import type { FlowUploadConfig } from "../types/flow-upload-config";
10
10
 
11
+ import { waitForResumeIfPaused } from "../services/abort-controller-service";
11
12
  import { shouldRetry } from "./chunk-upload";
12
13
  import type { Callbacks } from "./single-upload";
13
14
  import type { UploadMetrics } from "./upload-metrics";
@@ -185,6 +186,16 @@ export async function performFlowUpload({
185
186
  let currentOffset = offset;
186
187
 
187
188
  try {
189
+ // Check if paused before starting chunk upload
190
+ // This allows the upload loop to pause between chunks
191
+ await waitForResumeIfPaused(abortController);
192
+
193
+ // Check if aborted after waiting for resume
194
+ if (abortController.signal.aborted) {
195
+ callbacks.onAbort?.();
196
+ return;
197
+ }
198
+
188
199
  // Get optimal chunk size
189
200
  const remainingBytes = source.size ? source.size - offset : undefined;
190
201
  const chunkSizeDecision = smartChunker.getNextChunkSize(remainingBytes);
@@ -306,6 +317,20 @@ export async function performFlowUpload({
306
317
  ...callbacks,
307
318
  });
308
319
  } catch (err) {
320
+ // Check if this is an abort error (from fetch being cancelled)
321
+ const isAbortError =
322
+ abortController.signal.aborted ||
323
+ (err instanceof Error &&
324
+ (err.name === "AbortError" ||
325
+ (err as { code?: number }).code === 20 || // DOMException.ABORT_ERR
326
+ err.message?.includes("abort")));
327
+
328
+ if (isAbortError) {
329
+ logger.log(`Flow upload aborted for job ${jobId}`);
330
+ callbacks.onAbort?.();
331
+ return;
332
+ }
333
+
309
334
  // Retry logic similar to single-upload
310
335
  if (retryDelays != null) {
311
336
  const shouldResetDelays =
@@ -10,6 +10,7 @@ import type { PlatformService, Timeout } from "../services/platform-service";
10
10
  import type { WebSocketLike } from "../services/websocket-service";
11
11
  import type { SmartChunker, SmartChunkerConfig } from "../smart-chunker";
12
12
  import type { ClientStorage } from "../storage/client-storage";
13
+ import { waitForResumeIfPaused } from "../services/abort-controller-service";
13
14
  import {
14
15
  type OnProgress,
15
16
  type OnShouldRetry,
@@ -32,6 +33,7 @@ export type Callbacks = {
32
33
  ) => void;
33
34
  onSuccess?: (payload: UploadFile) => void;
34
35
  onError?: (error: Error | UploadistaError) => void;
36
+ onAbort?: () => void;
35
37
  onStart?: (file: { uploadId: string; size: number | null }) => void;
36
38
  onJobStart?: (jobId: string) => void;
37
39
  onShouldRetry?: OnShouldRetry;
@@ -84,6 +86,15 @@ export async function performUpload({
84
86
  let currentOffset = offset;
85
87
 
86
88
  try {
89
+ // Check if paused before starting chunk upload
90
+ // This allows the upload loop to pause between chunks
91
+ await waitForResumeIfPaused(abortController);
92
+
93
+ // Check if aborted after waiting for resume
94
+ if (abortController.signal.aborted) {
95
+ return;
96
+ }
97
+
87
98
  const res = await uploadChunk({
88
99
  uploadId,
89
100
  source,