flowforge-client 0.1.3 → 0.2.1

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/dist/index.d.mts CHANGED
@@ -269,6 +269,27 @@ interface ClientOptions {
269
269
  apiKey?: string;
270
270
  /** Custom fetch implementation (for testing or custom environments) */
271
271
  fetch?: typeof fetch;
272
+ /**
273
+ * Request timeout in milliseconds.
274
+ * Requests will be aborted if they exceed this duration.
275
+ * Default: 30000 (30 seconds)
276
+ */
277
+ timeout?: number;
278
+ /**
279
+ * Retry configuration for failed requests.
280
+ */
281
+ retry?: {
282
+ /** Maximum number of retry attempts (default: 3) */
283
+ maxAttempts?: number;
284
+ /** Base delay in milliseconds for exponential backoff (default: 1000) */
285
+ baseDelay?: number;
286
+ /** Maximum delay in milliseconds (default: 30000) */
287
+ maxDelay?: number;
288
+ /** Whether to retry on timeout errors (default: true) */
289
+ retryOnTimeout?: boolean;
290
+ /** HTTP status codes that should trigger a retry (default: [429, 500, 502, 503, 504]) */
291
+ retryableStatuses?: number[];
292
+ };
272
293
  }
273
294
 
274
295
  /**
@@ -896,10 +917,15 @@ interface FlowForgeClient {
896
917
  * });
897
918
  * ```
898
919
  *
899
- * @example With API key
920
+ * @example With API key and timeout
900
921
  * ```ts
901
922
  * const ff = createClient('http://localhost:8000', {
902
- * apiKey: process.env.FLOWFORGE_API_KEY // ff_live_xxx
923
+ * apiKey: process.env.FLOWFORGE_API_KEY,
924
+ * timeout: 60000, // 60 seconds
925
+ * retry: {
926
+ * maxAttempts: 5,
927
+ * baseDelay: 2000,
928
+ * }
903
929
  * });
904
930
  * ```
905
931
  */
package/dist/index.d.ts CHANGED
@@ -269,6 +269,27 @@ interface ClientOptions {
269
269
  apiKey?: string;
270
270
  /** Custom fetch implementation (for testing or custom environments) */
271
271
  fetch?: typeof fetch;
272
+ /**
273
+ * Request timeout in milliseconds.
274
+ * Requests will be aborted if they exceed this duration.
275
+ * Default: 30000 (30 seconds)
276
+ */
277
+ timeout?: number;
278
+ /**
279
+ * Retry configuration for failed requests.
280
+ */
281
+ retry?: {
282
+ /** Maximum number of retry attempts (default: 3) */
283
+ maxAttempts?: number;
284
+ /** Base delay in milliseconds for exponential backoff (default: 1000) */
285
+ baseDelay?: number;
286
+ /** Maximum delay in milliseconds (default: 30000) */
287
+ maxDelay?: number;
288
+ /** Whether to retry on timeout errors (default: true) */
289
+ retryOnTimeout?: boolean;
290
+ /** HTTP status codes that should trigger a retry (default: [429, 500, 502, 503, 504]) */
291
+ retryableStatuses?: number[];
292
+ };
272
293
  }
273
294
 
274
295
  /**
@@ -896,10 +917,15 @@ interface FlowForgeClient {
896
917
  * });
897
918
  * ```
898
919
  *
899
- * @example With API key
920
+ * @example With API key and timeout
900
921
  * ```ts
901
922
  * const ff = createClient('http://localhost:8000', {
902
- * apiKey: process.env.FLOWFORGE_API_KEY // ff_live_xxx
923
+ * apiKey: process.env.FLOWFORGE_API_KEY,
924
+ * timeout: 60000, // 60 seconds
925
+ * retry: {
926
+ * maxAttempts: 5,
927
+ * baseDelay: 2000,
928
+ * }
903
929
  * });
904
930
  * ```
905
931
  */
package/dist/index.js CHANGED
@@ -733,22 +733,60 @@ var ApiKeysResource = class {
733
733
  };
734
734
 
735
735
  // src/client.ts
736
+ var DEFAULT_TIMEOUT = 3e4;
737
+ function anySignal(signals) {
738
+ const controller = new AbortController();
739
+ for (const signal of signals) {
740
+ if (signal.aborted) {
741
+ controller.abort(signal.reason);
742
+ return controller.signal;
743
+ }
744
+ signal.addEventListener(
745
+ "abort",
746
+ () => controller.abort(signal.reason),
747
+ { once: true }
748
+ );
749
+ }
750
+ return controller.signal;
751
+ }
752
+ var DEFAULT_RETRY = {
753
+ maxAttempts: 3,
754
+ baseDelay: 1e3,
755
+ maxDelay: 3e4,
756
+ retryOnTimeout: true,
757
+ retryableStatuses: [429, 500, 502, 503, 504]
758
+ };
759
+ function calculateBackoff(attempt, baseDelay, maxDelay) {
760
+ const exponentialDelay = baseDelay * Math.pow(2, attempt);
761
+ const jitter = 0.5 + Math.random();
762
+ return Math.min(exponentialDelay * jitter, maxDelay);
763
+ }
764
+ function sleep(ms) {
765
+ return new Promise((resolve) => setTimeout(resolve, ms));
766
+ }
736
767
  function createClient(baseUrl, options = {}) {
737
768
  const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
738
769
  const fetchFn = options.fetch || globalThis.fetch;
739
- async function request(method, path, body) {
770
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
771
+ const retryConfig = { ...DEFAULT_RETRY, ...options.retry };
772
+ async function executeRequest(method, path, body, signal) {
740
773
  const headers = {
741
774
  "Content-Type": "application/json"
742
775
  };
743
776
  if (options.apiKey) {
744
777
  headers["X-FlowForge-API-Key"] = options.apiKey;
745
778
  }
779
+ const timeoutController = new AbortController();
780
+ const timeoutId = setTimeout(() => timeoutController.abort(), timeout);
781
+ const combinedSignal = signal ? anySignal([signal, timeoutController.signal]) : timeoutController.signal;
746
782
  try {
747
783
  const response = await fetchFn(`${normalizedBaseUrl}/api/v1${path}`, {
748
784
  method,
749
785
  headers,
750
- body: body ? JSON.stringify(body) : void 0
786
+ body: body ? JSON.stringify(body) : void 0,
787
+ signal: combinedSignal
751
788
  });
789
+ clearTimeout(timeoutId);
752
790
  if (!response.ok) {
753
791
  const errorBody = await response.json().catch(() => ({}));
754
792
  const error = {
@@ -763,6 +801,27 @@ function createClient(baseUrl, options = {}) {
763
801
  const data = await response.json();
764
802
  return { data, error: null };
765
803
  } catch (err) {
804
+ clearTimeout(timeoutId);
805
+ if (timeoutController.signal.aborted) {
806
+ const error2 = {
807
+ message: `Request timed out after ${timeout}ms`,
808
+ status: 0,
809
+ code: "TIMEOUT",
810
+ detail: { timeout },
811
+ name: "FlowForgeError"
812
+ };
813
+ return { data: null, error: error2 };
814
+ }
815
+ if (signal?.aborted) {
816
+ const error2 = {
817
+ message: "Request was cancelled",
818
+ status: 0,
819
+ code: "CANCELLED",
820
+ detail: err,
821
+ name: "FlowForgeError"
822
+ };
823
+ return { data: null, error: error2 };
824
+ }
766
825
  const error = {
767
826
  message: err instanceof Error ? err.message : "Network error",
768
827
  status: 0,
@@ -773,6 +832,46 @@ function createClient(baseUrl, options = {}) {
773
832
  return { data: null, error };
774
833
  }
775
834
  }
835
+ function isRetryable(error) {
836
+ if (error.code === "TIMEOUT" && retryConfig.retryOnTimeout) {
837
+ return true;
838
+ }
839
+ if (error.code === "NETWORK_ERROR") {
840
+ return true;
841
+ }
842
+ if (retryConfig.retryableStatuses.includes(error.status)) {
843
+ return true;
844
+ }
845
+ return false;
846
+ }
847
+ async function request(method, path, body) {
848
+ let lastError = null;
849
+ for (let attempt = 0; attempt < retryConfig.maxAttempts; attempt++) {
850
+ const result = await executeRequest(method, path, body);
851
+ if (result.data !== null) {
852
+ return result;
853
+ }
854
+ lastError = result.error;
855
+ if (!lastError || !isRetryable(lastError) || attempt === retryConfig.maxAttempts - 1) {
856
+ return result;
857
+ }
858
+ const delay = calculateBackoff(
859
+ attempt,
860
+ retryConfig.baseDelay,
861
+ retryConfig.maxDelay
862
+ );
863
+ await sleep(delay);
864
+ }
865
+ return {
866
+ data: null,
867
+ error: lastError || {
868
+ message: "Unknown error",
869
+ status: 0,
870
+ code: "UNKNOWN",
871
+ name: "FlowForgeError"
872
+ }
873
+ };
874
+ }
776
875
  return {
777
876
  events: new EventsResource(request),
778
877
  runs: new RunsResource(request),
package/dist/index.mjs CHANGED
@@ -697,22 +697,60 @@ var ApiKeysResource = class {
697
697
  };
698
698
 
699
699
  // src/client.ts
700
+ var DEFAULT_TIMEOUT = 3e4;
701
+ function anySignal(signals) {
702
+ const controller = new AbortController();
703
+ for (const signal of signals) {
704
+ if (signal.aborted) {
705
+ controller.abort(signal.reason);
706
+ return controller.signal;
707
+ }
708
+ signal.addEventListener(
709
+ "abort",
710
+ () => controller.abort(signal.reason),
711
+ { once: true }
712
+ );
713
+ }
714
+ return controller.signal;
715
+ }
716
+ var DEFAULT_RETRY = {
717
+ maxAttempts: 3,
718
+ baseDelay: 1e3,
719
+ maxDelay: 3e4,
720
+ retryOnTimeout: true,
721
+ retryableStatuses: [429, 500, 502, 503, 504]
722
+ };
723
+ function calculateBackoff(attempt, baseDelay, maxDelay) {
724
+ const exponentialDelay = baseDelay * Math.pow(2, attempt);
725
+ const jitter = 0.5 + Math.random();
726
+ return Math.min(exponentialDelay * jitter, maxDelay);
727
+ }
728
+ function sleep(ms) {
729
+ return new Promise((resolve) => setTimeout(resolve, ms));
730
+ }
700
731
  function createClient(baseUrl, options = {}) {
701
732
  const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
702
733
  const fetchFn = options.fetch || globalThis.fetch;
703
- async function request(method, path, body) {
734
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
735
+ const retryConfig = { ...DEFAULT_RETRY, ...options.retry };
736
+ async function executeRequest(method, path, body, signal) {
704
737
  const headers = {
705
738
  "Content-Type": "application/json"
706
739
  };
707
740
  if (options.apiKey) {
708
741
  headers["X-FlowForge-API-Key"] = options.apiKey;
709
742
  }
743
+ const timeoutController = new AbortController();
744
+ const timeoutId = setTimeout(() => timeoutController.abort(), timeout);
745
+ const combinedSignal = signal ? anySignal([signal, timeoutController.signal]) : timeoutController.signal;
710
746
  try {
711
747
  const response = await fetchFn(`${normalizedBaseUrl}/api/v1${path}`, {
712
748
  method,
713
749
  headers,
714
- body: body ? JSON.stringify(body) : void 0
750
+ body: body ? JSON.stringify(body) : void 0,
751
+ signal: combinedSignal
715
752
  });
753
+ clearTimeout(timeoutId);
716
754
  if (!response.ok) {
717
755
  const errorBody = await response.json().catch(() => ({}));
718
756
  const error = {
@@ -727,6 +765,27 @@ function createClient(baseUrl, options = {}) {
727
765
  const data = await response.json();
728
766
  return { data, error: null };
729
767
  } catch (err) {
768
+ clearTimeout(timeoutId);
769
+ if (timeoutController.signal.aborted) {
770
+ const error2 = {
771
+ message: `Request timed out after ${timeout}ms`,
772
+ status: 0,
773
+ code: "TIMEOUT",
774
+ detail: { timeout },
775
+ name: "FlowForgeError"
776
+ };
777
+ return { data: null, error: error2 };
778
+ }
779
+ if (signal?.aborted) {
780
+ const error2 = {
781
+ message: "Request was cancelled",
782
+ status: 0,
783
+ code: "CANCELLED",
784
+ detail: err,
785
+ name: "FlowForgeError"
786
+ };
787
+ return { data: null, error: error2 };
788
+ }
730
789
  const error = {
731
790
  message: err instanceof Error ? err.message : "Network error",
732
791
  status: 0,
@@ -737,6 +796,46 @@ function createClient(baseUrl, options = {}) {
737
796
  return { data: null, error };
738
797
  }
739
798
  }
799
+ function isRetryable(error) {
800
+ if (error.code === "TIMEOUT" && retryConfig.retryOnTimeout) {
801
+ return true;
802
+ }
803
+ if (error.code === "NETWORK_ERROR") {
804
+ return true;
805
+ }
806
+ if (retryConfig.retryableStatuses.includes(error.status)) {
807
+ return true;
808
+ }
809
+ return false;
810
+ }
811
+ async function request(method, path, body) {
812
+ let lastError = null;
813
+ for (let attempt = 0; attempt < retryConfig.maxAttempts; attempt++) {
814
+ const result = await executeRequest(method, path, body);
815
+ if (result.data !== null) {
816
+ return result;
817
+ }
818
+ lastError = result.error;
819
+ if (!lastError || !isRetryable(lastError) || attempt === retryConfig.maxAttempts - 1) {
820
+ return result;
821
+ }
822
+ const delay = calculateBackoff(
823
+ attempt,
824
+ retryConfig.baseDelay,
825
+ retryConfig.maxDelay
826
+ );
827
+ await sleep(delay);
828
+ }
829
+ return {
830
+ data: null,
831
+ error: lastError || {
832
+ message: "Unknown error",
833
+ status: 0,
834
+ code: "UNKNOWN",
835
+ name: "FlowForgeError"
836
+ }
837
+ };
838
+ }
740
839
  return {
741
840
  events: new EventsResource(request),
742
841
  runs: new RunsResource(request),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowforge-client",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "TypeScript client for FlowForge workflow orchestration",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -24,12 +24,17 @@
24
24
  ],
25
25
  "license": "MIT",
26
26
  "devDependencies": {
27
+ "@vitest/coverage-v8": "^2.0.0",
27
28
  "tsup": "^8.0.0",
28
- "typescript": "^5.3.0"
29
+ "typescript": "^5.3.0",
30
+ "vitest": "^2.0.0"
29
31
  },
30
32
  "scripts": {
31
33
  "build": "tsup src/index.ts --format cjs,esm --dts",
32
34
  "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
33
- "typecheck": "tsc --noEmit"
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest",
38
+ "test:coverage": "vitest run --coverage"
34
39
  }
35
40
  }