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

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.21",
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",
@@ -28,8 +28,8 @@
28
28
  },
29
29
  "peerDependencies": {
30
30
  "@fal-ai/client": ">=0.6.0",
31
- "react": ">=18.2.0",
32
- "react-native": ">=0.74.0"
31
+ "react": ">=19.0.0",
32
+ "react-native": ">=0.81.0"
33
33
  },
34
34
  "dependencies": {
35
35
  "@fal-ai/client": ">=0.6.0"
@@ -46,7 +46,7 @@
46
46
  "@tanstack/query-async-storage-persister": "^5.66.7",
47
47
  "@tanstack/react-query": "^5.66.7",
48
48
  "@tanstack/react-query-persist-client": "^5.66.7",
49
- "@types/react": "~18.3.12",
49
+ "@types/react": "~19.1.0",
50
50
  "@typescript-eslint/eslint-plugin": "^7.0.0",
51
51
  "@typescript-eslint/parser": "^7.0.0",
52
52
  "@umituz/react-native-auth": "*",
@@ -77,8 +77,8 @@
77
77
  "expo-video": "^3.0.15",
78
78
  "expo-web-browser": "^12.0.0",
79
79
  "firebase": "^12.7.0",
80
- "react": "18.3.1",
81
- "react-native": "0.76.1",
80
+ "react": "19.1.0",
81
+ "react-native": "0.81.5",
82
82
  "react-native-gesture-handler": "^2.30.0",
83
83
  "react-native-purchases": "^9.7.5",
84
84
  "react-native-reanimated": "^4.2.1",
@@ -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 {
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Collection Filter Utilities
3
+ * Common filter operations for arrays of objects
4
+ */
5
+
6
+ /**
7
+ * Filter array by property value
8
+ */
9
+ export function filterByProperty<T>(
10
+ items: readonly T[],
11
+ property: keyof T,
12
+ value: unknown
13
+ ): T[] {
14
+ return items.filter((item) => item[property] === value);
15
+ }
16
+
17
+ /**
18
+ * Filter array by predicate function
19
+ */
20
+ export function filterByPredicate<T>(
21
+ items: readonly T[],
22
+ predicate: (item: T) => boolean
23
+ ): T[] {
24
+ return items.filter(predicate);
25
+ }
26
+
27
+ /**
28
+ * Filter array by time range (timestamp property)
29
+ */
30
+ export function filterByTimeRange<T>(
31
+ items: readonly T[],
32
+ timestampProperty: keyof T,
33
+ startTime: number,
34
+ endTime: number
35
+ ): T[] {
36
+ return items.filter((item) => {
37
+ const timestamp = item[timestampProperty] as unknown as number;
38
+ return timestamp >= startTime && timestamp <= endTime;
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Filter array by multiple property values (OR logic)
44
+ */
45
+ export function filterByAnyProperty<T>(
46
+ items: readonly T[],
47
+ property: keyof T,
48
+ values: readonly unknown[]
49
+ ): T[] {
50
+ const valueSet = new Set(values);
51
+ return items.filter((item) => valueSet.has(item[property]));
52
+ }
53
+
54
+ /**
55
+ * Sort array by date property (descending - newest first)
56
+ */
57
+ export function sortByDateDescending<T>(
58
+ items: readonly T[],
59
+ dateProperty: keyof T
60
+ ): T[] {
61
+ return [...items].sort((a, b) => {
62
+ const timeA = new Date(a[dateProperty] as unknown as string).getTime();
63
+ const timeB = new Date(b[dateProperty] as unknown as string).getTime();
64
+ return timeB - timeA;
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Sort array by date property (ascending - oldest first)
70
+ */
71
+ export function sortByDateAscending<T>(
72
+ items: readonly T[],
73
+ dateProperty: keyof T
74
+ ): T[] {
75
+ return [...items].sort((a, b) => {
76
+ const timeA = new Date(a[dateProperty] as unknown as string).getTime();
77
+ const timeB = new Date(b[dateProperty] as unknown as string).getTime();
78
+ return timeA - timeB;
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Sort array by number property (descending)
84
+ */
85
+ export function sortByNumberDescending<T>(
86
+ items: readonly T[],
87
+ numberProperty: keyof T
88
+ ): T[] {
89
+ return [...items].sort((a, b) => {
90
+ const numA = a[numberProperty] as unknown as number;
91
+ const numB = b[numberProperty] as unknown as number;
92
+ return numB - numA;
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Sort array by number property (ascending)
98
+ */
99
+ export function sortByNumberAscending<T>(
100
+ items: readonly T[],
101
+ numberProperty: keyof T
102
+ ): T[] {
103
+ return [...items].sort((a, b) => {
104
+ const numA = a[numberProperty] as unknown as number;
105
+ const numB = b[numberProperty] as unknown as number;
106
+ return numA - numB;
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Reduce array to sum of number property
112
+ */
113
+ export function sumByProperty<T>(
114
+ items: readonly T[],
115
+ numberProperty: keyof T
116
+ ): number {
117
+ return items.reduce((sum, item) => {
118
+ const value = item[numberProperty] as unknown as number;
119
+ return sum + (typeof value === "number" ? value : 0);
120
+ }, 0);
121
+ }
122
+
123
+ /**
124
+ * Group array by property value
125
+ */
126
+ export function groupByProperty<T>(
127
+ items: readonly T[],
128
+ property: keyof T
129
+ ): Map<unknown, T[]> {
130
+ const groups = new Map<unknown, T[]>();
131
+ for (const item of items) {
132
+ const key = item[property];
133
+ const existing = groups.get(key);
134
+ if (existing) {
135
+ existing.push(item);
136
+ } else {
137
+ groups.set(key, [item]);
138
+ }
139
+ }
140
+ return groups;
141
+ }
142
+
143
+ /**
144
+ * Chunk array into smaller arrays of specified size
145
+ */
146
+ export function chunkArray<T>(items: readonly T[], chunkSize: number): T[][] {
147
+ const result: T[][] = [];
148
+ for (let i = 0; i < items.length; i += chunkSize) {
149
+ result.push(items.slice(i, i + chunkSize) as T[]);
150
+ }
151
+ return result;
152
+ }
153
+
154
+ /**
155
+ * Get distinct values of a property from array
156
+ */
157
+ export function distinctByProperty<T>(
158
+ items: readonly T[],
159
+ property: keyof T
160
+ ): unknown[] {
161
+ const seen = new Set<unknown>();
162
+ const result: unknown[] = [];
163
+ for (const item of items) {
164
+ const value = item[property];
165
+ if (!seen.has(value)) {
166
+ seen.add(value);
167
+ result.push(value);
168
+ }
169
+ }
170
+ return result;
171
+ }
@@ -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],