@umituz/react-native-ai-fal-provider 2.0.19 → 2.0.20

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-ai-fal-provider",
3
- "version": "2.0.19",
3
+ "version": "2.0.20",
4
4
  "description": "FAL AI provider for React Native - implements IAIProvider interface for unified AI generation",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -80,10 +80,16 @@ export function getDefaultModelsByType(type: FalModelType): FalModelConfig[] {
80
80
  return DEFAULT_IMAGE_TO_VIDEO_MODELS;
81
81
  case "text-to-text":
82
82
  return DEFAULT_TEXT_TO_TEXT_MODELS;
83
- case "image-to-image":
83
+ case "image-to-image": {
84
+ // eslint-disable-next-line no-console
85
+ console.warn('Model type "image-to-image" not supported yet');
84
86
  return [];
85
- default:
87
+ }
88
+ default: {
89
+ // eslint-disable-next-line no-console
90
+ console.warn('Unknown model type:', type);
86
91
  return [];
92
+ }
87
93
  }
88
94
  }
89
95
 
@@ -123,9 +123,14 @@ export async function handleFalSubscription<T = unknown>(
123
123
  }
124
124
 
125
125
  const userMessage = parseFalError(error);
126
+ if (!userMessage || userMessage.trim().length === 0) {
127
+ throw new Error("An unknown error occurred. Please try again.");
128
+ }
126
129
  throw new Error(userMessage);
127
130
  } finally {
128
- if (timeoutId) clearTimeout(timeoutId);
131
+ if (timeoutId) {
132
+ clearTimeout(timeoutId);
133
+ }
129
134
  if (listenerAdded && abortHandler && signal) {
130
135
  signal.removeEventListener("abort", abortHandler);
131
136
  }
@@ -134,6 +134,7 @@ export class FalProvider implements IAIProvider {
134
134
  getRequestId: (res) => res.requestId ?? undefined,
135
135
  }).then((res) => res.result).finally(() => removeRequest(key));
136
136
 
137
+ // Store promise immediately to prevent race condition
137
138
  storeRequest(key, { promise, abortController });
138
139
  return promise;
139
140
  }
@@ -19,10 +19,8 @@ export function getRequestStore(): RequestStore {
19
19
  }
20
20
 
21
21
  /**
22
- * Create a collision-resistant request key using combination of:
23
- * - Model name
24
- * - Input hash (for quick comparison)
25
- * - Unique ID (guarantees uniqueness)
22
+ * Create a deterministic request key using model and input hash
23
+ * Same model + input will always produce the same key for deduplication
26
24
  */
27
25
  export function createRequestKey(model: string, input: Record<string, unknown>): string {
28
26
  const inputStr = JSON.stringify(input, Object.keys(input).sort());
@@ -32,11 +30,9 @@ export function createRequestKey(model: string, input: Record<string, unknown>):
32
30
  const char = inputStr.charCodeAt(i);
33
31
  hash = ((hash << 5) - hash + char) | 0;
34
32
  }
35
- // Use crypto.randomUUID() for guaranteed uniqueness without race conditions
36
- const uniqueId = typeof crypto !== 'undefined' && crypto.randomUUID
37
- ? crypto.randomUUID()
38
- : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
39
- return `${model}:${hash.toString(36)}:${uniqueId}`;
33
+ // Return deterministic key without unique ID
34
+ // This allows proper deduplication: same model + input = same key
35
+ return `${model}:${hash.toString(36)}`;
40
36
  }
41
37
 
42
38
  export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined {
@@ -45,7 +45,6 @@ export class CostTracker {
45
45
  private config: Required<CostTrackerConfig>;
46
46
  private costHistory: GenerationCost[] = [];
47
47
  private currentOperationCosts: Map<string, number> = new Map();
48
- private operationCounter = 0;
49
48
 
50
49
  constructor(config?: CostTrackerConfig) {
51
50
  this.config = {
@@ -84,11 +83,14 @@ export class CostTracker {
84
83
  }
85
84
 
86
85
  startOperation(modelId: string, operation: string): string {
87
- // Use counter + timestamp for guaranteed unique operation IDs
88
- const operationId = `${Date.now()}-${this.operationCounter++}-${operation}`;
86
+ // Use crypto.randomUUID() for guaranteed uniqueness without overflow
87
+ const uniqueId = typeof crypto !== 'undefined' && crypto.randomUUID
88
+ ? crypto.randomUUID()
89
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}-${operation}`;
90
+
89
91
  const estimatedCost = this.calculateEstimatedCost(modelId);
90
92
 
91
- this.currentOperationCosts.set(operationId, estimatedCost);
93
+ this.currentOperationCosts.set(uniqueId, estimatedCost);
92
94
 
93
95
  if (this.config.trackEstimatedCost) {
94
96
  const cost: GenerationCost = {
@@ -104,7 +106,7 @@ export class CostTracker {
104
106
  this.config.onCostUpdate(cost);
105
107
  }
106
108
 
107
- return operationId;
109
+ return uniqueId;
108
110
  }
109
111
 
110
112
  completeOperation(
@@ -156,7 +158,6 @@ export class CostTracker {
156
158
  clearHistory(): void {
157
159
  this.costHistory = [];
158
160
  this.currentOperationCosts.clear();
159
- this.operationCounter = 0;
160
161
  }
161
162
 
162
163
  getCostsByModel(modelId: string): GenerationCost[] {
@@ -27,8 +27,10 @@ export async function uploadToFalStorage(base64: string): Promise<string> {
27
27
  try {
28
28
  // eslint-disable-next-line @typescript-eslint/no-unsafe-call
29
29
  await deleteTempFile(tempUri);
30
- } catch {
31
- // Silently ignore cleanup failures
30
+ } catch (cleanupError) {
31
+ // Log cleanup failure but don't throw
32
+ // eslint-disable-next-line no-console
33
+ console.warn(`Failed to cleanup temp file ${tempUri}:`, cleanupError);
32
34
  }
33
35
  }
34
36
  }
@@ -20,8 +20,18 @@ export function extractBase64(dataUri: string): string {
20
20
  if (!dataUri.startsWith("data:")) {
21
21
  return dataUri;
22
22
  }
23
+
23
24
  const parts = dataUri.split(",");
24
- return parts.length > 1 ? parts[1] : dataUri;
25
+ if (parts.length < 2) {
26
+ throw new Error(`Invalid data URI format: ${dataUri}`);
27
+ }
28
+
29
+ const base64Part = parts[1];
30
+ if (!base64Part || base64Part.length === 0) {
31
+ throw new Error(`Empty base64 data in URI: ${dataUri}`);
32
+ }
33
+
34
+ return base64Part;
25
35
  }
26
36
 
27
37
  /**
@@ -51,9 +51,16 @@ export async function preprocessInput(
51
51
  if (Array.isArray(result.image_urls) && result.image_urls.length > 0) {
52
52
  const imageUrls = result.image_urls as unknown[];
53
53
  const processedUrls: string[] = [];
54
+ const errors: string[] = [];
54
55
 
55
56
  for (let i = 0; i < imageUrls.length; i++) {
56
57
  const imageUrl = imageUrls[i];
58
+
59
+ if (!imageUrl) {
60
+ errors.push(`image_urls[${i}] is null or undefined`);
61
+ continue;
62
+ }
63
+
57
64
  if (isBase64DataUri(imageUrl)) {
58
65
  const index = i;
59
66
  const uploadPromise = uploadToFalStorage(imageUrl)
@@ -61,6 +68,7 @@ export async function preprocessInput(
61
68
  processedUrls[index] = url;
62
69
  })
63
70
  .catch((error) => {
71
+ errors.push(`Failed to upload image_urls[${index}]: ${error instanceof Error ? error.message : "Unknown error"}`);
64
72
  throw new Error(`Failed to upload image_urls[${index}]: ${error instanceof Error ? error.message : "Unknown error"}`);
65
73
  });
66
74
 
@@ -68,10 +76,14 @@ export async function preprocessInput(
68
76
  } else if (typeof imageUrl === "string") {
69
77
  processedUrls[i] = imageUrl;
70
78
  } else {
71
- processedUrls[i] = "";
79
+ errors.push(`image_urls[${i}] has invalid type: ${typeof imageUrl}`);
72
80
  }
73
81
  }
74
82
 
83
+ if (errors.length > 0) {
84
+ throw new Error(`Image URL validation failed:\n${errors.join('\n')}`);
85
+ }
86
+
75
87
  result.image_urls = processedUrls;
76
88
  }
77
89
 
@@ -18,8 +18,22 @@ export function serializeJobMetadata(metadata: FalJobMetadata): string {
18
18
  */
19
19
  export function deserializeJobMetadata(data: string): FalJobMetadata | null {
20
20
  try {
21
- return JSON.parse(data) as FalJobMetadata;
22
- } catch {
21
+ const parsed = JSON.parse(data) as Record<string, unknown>;
22
+ // Validate structure
23
+ if (!parsed || typeof parsed !== 'object') {
24
+ // eslint-disable-next-line no-console
25
+ console.warn('Invalid job metadata: not an object', data);
26
+ return null;
27
+ }
28
+ if (!parsed.requestId || !parsed.model || !parsed.status) {
29
+ // eslint-disable-next-line no-console
30
+ console.warn('Invalid job metadata: missing required fields', data);
31
+ return null;
32
+ }
33
+ return parsed as unknown as FalJobMetadata;
34
+ } catch (error) {
35
+ // eslint-disable-next-line no-console
36
+ console.error('Failed to deserialize job metadata:', error, 'Data:', data);
23
37
  return null;
24
38
  }
25
39
  }
@@ -14,8 +14,14 @@ export function truncatePrompt(prompt: string, maxLength: number = 5000): string
14
14
  }
15
15
 
16
16
  /**
17
- * Sanitize prompt by removing excessive whitespace
17
+ * Sanitize prompt by removing excessive whitespace and control characters
18
18
  */
19
19
  export function sanitizePrompt(prompt: string): string {
20
- return prompt.trim().replace(/\s+/g, " ");
20
+ return prompt
21
+ .trim()
22
+ .replace(/\s+/g, " ")
23
+ // Remove control characters except tab, newline, carriage return
24
+ // eslint-disable-next-line no-control-regex
25
+ .replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, '')
26
+ .slice(0, 5000);
21
27
  }
@@ -87,15 +87,14 @@ export function isValidApiKey(value: unknown): boolean {
87
87
  /**
88
88
  * Validate model ID format
89
89
  */
90
+ const MODEL_ID_PATTERN = /^[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_.]+(\/[a-zA-Z0-9-_.]+)?$/;
91
+
90
92
  export function isValidModelId(value: unknown): boolean {
91
93
  if (typeof value !== "string") {
92
94
  return false;
93
95
  }
94
96
 
95
- // FAL model IDs follow pattern: "owner/model-name" or "owner/model/version"
96
- // Allow uppercase, dots, underscores, hyphens
97
- const modelIdPattern = /^[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_.]+(\/[a-zA-Z0-9-_.]+)?$/;
98
- return modelIdPattern.test(value) && value.length >= 3;
97
+ return MODEL_ID_PATTERN.test(value) && value.length >= 3;
99
98
  }
100
99
 
101
100
  /**
@@ -109,12 +108,12 @@ export function isValidPrompt(value: unknown): boolean {
109
108
  * Validate timeout value
110
109
  */
111
110
  export function isValidTimeout(value: unknown): boolean {
112
- return typeof value === "number" && value > 0 && value <= 600000; // Max 10 minutes
111
+ return typeof value === "number" && !isNaN(value) && isFinite(value) && value > 0 && value <= 600000; // Max 10 minutes
113
112
  }
114
113
 
115
114
  /**
116
115
  * Validate retry count
117
116
  */
118
117
  export function isValidRetryCount(value: unknown): boolean {
119
- return typeof value === "number" && value >= 0 && value <= 10;
118
+ return typeof value === "number" && !isNaN(value) && isFinite(value) && Number.isInteger(value) && value >= 0 && value <= 10;
120
119
  }
@@ -3,7 +3,7 @@
3
3
  * React hook for FAL AI generation operations
4
4
  */
5
5
 
6
- import { useState, useCallback, useRef } from "react";
6
+ import { useState, useCallback, useRef, useEffect } from "react";
7
7
  import { falProvider } from "../../infrastructure/services/fal-provider";
8
8
  import { mapFalError } from "../../infrastructure/utils/error-mapper";
9
9
  import type { FalJobInput, FalQueueStatus, FalLogEntry } from "../../domain/entities/fal.types";
@@ -38,9 +38,23 @@ export function useFalGeneration<T = unknown>(
38
38
 
39
39
  const lastRequestRef = useRef<{ endpoint: string; input: FalJobInput } | null>(null);
40
40
  const currentRequestIdRef = useRef<string | null>(null);
41
+ const isMountedRef = useRef(true);
42
+
43
+ // Cleanup on unmount
44
+ useEffect(() => {
45
+ isMountedRef.current = true;
46
+ return () => {
47
+ isMountedRef.current = false;
48
+ if (falProvider.hasRunningRequest()) {
49
+ falProvider.cancelCurrentRequest();
50
+ }
51
+ };
52
+ }, []);
41
53
 
42
54
  const generate = useCallback(
43
55
  async (modelEndpoint: string, input: FalJobInput): Promise<T | null> => {
56
+ if (!isMountedRef.current) return null;
57
+
44
58
  lastRequestRef.current = { endpoint: modelEndpoint, input };
45
59
  setIsLoading(true);
46
60
  setError(null);
@@ -52,6 +66,7 @@ export function useFalGeneration<T = unknown>(
52
66
  const result = await falProvider.subscribe<T>(modelEndpoint, input, {
53
67
  timeoutMs: options?.timeoutMs,
54
68
  onQueueUpdate: (status) => {
69
+ if (!isMountedRef.current) return;
55
70
  if (status.requestId) {
56
71
  currentRequestIdRef.current = status.requestId;
57
72
  }
@@ -68,16 +83,20 @@ export function useFalGeneration<T = unknown>(
68
83
  },
69
84
  });
70
85
 
86
+ if (!isMountedRef.current) return null;
71
87
  setData(result);
72
88
  return result;
73
89
  } catch (err) {
90
+ if (!isMountedRef.current) return null;
74
91
  const errorInfo = mapFalError(err);
75
92
  setError(errorInfo);
76
93
  options?.onError?.(errorInfo);
77
94
  return null;
78
95
  } finally {
79
- setIsLoading(false);
80
- setIsCancelling(false);
96
+ if (isMountedRef.current) {
97
+ setIsLoading(false);
98
+ setIsCancelling(false);
99
+ }
81
100
  }
82
101
  },
83
102
  [options]
@@ -68,6 +68,9 @@ export function useModels(props: UseModelsProps): UseModelsReturn {
68
68
  const model = models.find((m) => m.id === modelId);
69
69
  if (model) {
70
70
  setSelectedModel(model);
71
+ } else {
72
+ // eslint-disable-next-line no-console
73
+ console.warn(`Model not found: ${modelId}. Available models:`, models.map(m => m.id));
71
74
  }
72
75
  },
73
76
  [models],