@umituz/react-native-ai-fal-provider 2.1.2 → 2.1.4

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.1.2",
3
+ "version": "2.1.4",
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",
@@ -4,7 +4,6 @@
4
4
  */
5
5
 
6
6
  import type { FalModelType } from "../entities/fal.types";
7
- import type { ModelType } from "../types/model-selection.types";
8
7
  import { DEFAULT_TEXT_TO_IMAGE_MODELS } from "./models/text-to-image.models";
9
8
  import { DEFAULT_TEXT_TO_VOICE_MODELS } from "./models/text-to-voice.models";
10
9
  import { DEFAULT_TEXT_TO_VIDEO_MODELS } from "./models/text-to-video.models";
@@ -34,22 +33,28 @@ export interface FalModelConfig {
34
33
 
35
34
  /**
36
35
  * Default credit costs for each model type
36
+ * Supports all FalModelType values
37
37
  */
38
- export const DEFAULT_CREDIT_COSTS: Record<ModelType, number> = {
38
+ export const DEFAULT_CREDIT_COSTS: Record<FalModelType, number> = {
39
39
  "text-to-image": 2,
40
40
  "text-to-video": 20,
41
41
  "image-to-video": 20,
42
42
  "text-to-voice": 3,
43
+ "image-to-image": 2,
44
+ "text-to-text": 1,
43
45
  } as const;
44
46
 
45
47
  /**
46
48
  * Default model IDs for each model type
49
+ * Supports all FalModelType values
47
50
  */
48
- export const DEFAULT_MODEL_IDS: Record<ModelType, string> = {
51
+ export const DEFAULT_MODEL_IDS: Record<FalModelType, string> = {
49
52
  "text-to-image": "fal-ai/flux/schnell",
50
53
  "text-to-video": "fal-ai/minimax-video",
51
54
  "image-to-video": "fal-ai/kling-video/v1.5/pro/image-to-video",
52
55
  "text-to-voice": "fal-ai/playai/tts/v3",
56
+ "image-to-image": "fal-ai/flux/schnell",
57
+ "text-to-text": "fal-ai/flux/schnell",
53
58
  } as const;
54
59
 
55
60
  /**
@@ -90,10 +95,14 @@ export function getDefaultModelsByType(type: FalModelType): FalModelConfig[] {
90
95
 
91
96
  /**
92
97
  * Get default model for a type
98
+ * Returns the model marked as default, or the first model, or undefined if no models exist
93
99
  */
94
100
  export function getDefaultModel(type: FalModelType): FalModelConfig | undefined {
95
101
  const models = getDefaultModelsByType(type);
96
- return models.find((m) => m.isDefault) || models[0];
102
+ if (models.length === 0) {
103
+ return undefined;
104
+ }
105
+ return models.find((m) => m.isDefault) ?? models[0];
97
106
  }
98
107
 
99
108
  /**
@@ -4,12 +4,16 @@
4
4
  */
5
5
 
6
6
  import type { FalModelConfig } from "../constants/default-models.constants";
7
+ import type { FalModelType } from "../entities/fal.types";
7
8
 
8
- export type ModelType =
9
- | "text-to-image"
10
- | "text-to-video"
11
- | "image-to-video"
12
- | "text-to-voice";
9
+ /**
10
+ * Public API model types (subset of FalModelType)
11
+ * UI components support these core model types
12
+ */
13
+ export type ModelType = Extract<
14
+ FalModelType,
15
+ "text-to-image" | "text-to-video" | "image-to-video" | "text-to-voice"
16
+ >;
13
17
 
14
18
  /**
15
19
  * Configuration for model selection behavior
@@ -55,28 +55,28 @@ export function getModelPricing(modelId: string): { freeUserCost: number; premiu
55
55
  * Returns the model's free user cost if available, otherwise returns the default cost for the type
56
56
  * NOTE: Use ?? instead of || to handle 0 values correctly (free models)
57
57
  */
58
- export function getModelCreditCost(modelId: string, modelType: ModelType): number {
58
+ export function getModelCreditCost(modelId: string, modelType: FalModelType): number {
59
59
  const pricing = getModelPricing(modelId);
60
60
  // CRITICAL: Use !== undefined instead of truthy check
61
61
  // because freeUserCost can be 0 for free models!
62
62
  if (pricing && pricing.freeUserCost !== undefined) {
63
63
  return pricing.freeUserCost;
64
64
  }
65
- return DEFAULT_CREDIT_COSTS[modelType];
65
+ return DEFAULT_CREDIT_COSTS[modelType] ?? 0;
66
66
  }
67
67
 
68
68
  /**
69
69
  * Get default credit cost for a model type
70
70
  */
71
- export function getDefaultCreditCost(modelType: ModelType): number {
72
- return DEFAULT_CREDIT_COSTS[modelType];
71
+ export function getDefaultCreditCost(modelType: FalModelType): number {
72
+ return DEFAULT_CREDIT_COSTS[modelType] ?? 0;
73
73
  }
74
74
 
75
75
  /**
76
76
  * Get default model ID for a model type
77
77
  */
78
- export function getDefaultModelId(modelType: ModelType): string {
79
- return DEFAULT_MODEL_IDS[modelType];
78
+ export function getDefaultModelId(modelType: FalModelType): string {
79
+ return DEFAULT_MODEL_IDS[modelType] ?? "";
80
80
  }
81
81
 
82
82
  /**
@@ -110,7 +110,8 @@ export function getModelSelectionData(
110
110
  modelType: ModelType,
111
111
  config?: ModelSelectionConfig
112
112
  ): ModelSelectionResult {
113
- const models = getModels(modelType as FalModelType);
113
+ // ModelType is now a subset of FalModelType, so this cast is safe
114
+ const models = getModels(modelType);
114
115
  const defaultCreditCost = config?.defaultCreditCost ?? getDefaultCreditCost(modelType);
115
116
  const defaultModelId = config?.defaultModelId ?? getDefaultModelId(modelType);
116
117
  const selectedModel = selectInitialModel(models, config, modelType);
@@ -126,15 +126,17 @@ export class FalProvider implements IAIProvider {
126
126
  const abortController = new AbortController();
127
127
  const tracker = this.costTracker;
128
128
 
129
- // Store promise immediately BEFORE creating it to prevent race condition
130
- // Multiple simultaneous calls with same params will get the same promise
131
- let resolvePromise: (value: T) => void;
132
- let rejectPromise: (error: unknown) => void;
129
+ // Create promise with resolvers using definite assignment
130
+ // This prevents race conditions and ensures type safety
131
+ let resolvePromise!: (value: T) => void;
132
+ let rejectPromise!: (error: unknown) => void;
133
133
  const promise = new Promise<T>((resolve, reject) => {
134
134
  resolvePromise = resolve;
135
135
  rejectPromise = reject;
136
136
  });
137
137
 
138
+ // Store promise immediately to enable request deduplication
139
+ // Multiple simultaneous calls with same params will get the same promise
138
140
  storeRequest(key, { promise, abortController, createdAt: Date.now() });
139
141
 
140
142
  // Execute the actual operation and resolve/reject the stored promise
@@ -146,11 +148,11 @@ export class FalProvider implements IAIProvider {
146
148
  getRequestId: (res) => res.requestId ?? undefined,
147
149
  })
148
150
  .then((res) => {
149
- resolvePromise!(res.result);
151
+ resolvePromise(res.result);
150
152
  return res.result;
151
153
  })
152
154
  .catch((error) => {
153
- rejectPromise!(error);
155
+ rejectPromise(error);
154
156
  throw error;
155
157
  })
156
158
  .finally(() => {
@@ -61,7 +61,14 @@ export async function getJobResult<T = unknown>(model: string, requestId: string
61
61
 
62
62
  if (!result || typeof result !== 'object') {
63
63
  throw new Error(
64
- `Invalid FAL queue result for model ${model}, requestId ${requestId}`
64
+ `Invalid FAL queue result for model ${model}, requestId ${requestId}: Result is not an object`
65
+ );
66
+ }
67
+
68
+ // Type guard: ensure result.data exists before casting
69
+ if (!('data' in result)) {
70
+ throw new Error(
71
+ `Invalid FAL queue result for model ${model}, requestId ${requestId}: Missing 'data' property`
65
72
  );
66
73
  }
67
74
 
@@ -12,7 +12,7 @@ export class NSFWContentError extends Error {
12
12
 
13
13
  constructor(message?: string) {
14
14
  super(
15
- message ||
15
+ message ??
16
16
  "The generated content was flagged as inappropriate. Please try a different prompt."
17
17
  );
18
18
  this.name = "NSFWContentError";
@@ -11,12 +11,28 @@ export interface ActiveRequest<T = unknown> {
11
11
 
12
12
  const STORE_KEY = "__FAL_PROVIDER_REQUESTS__";
13
13
  const LOCK_KEY = "__FAL_PROVIDER_REQUESTS_LOCK__";
14
+ const TIMER_KEY = "__FAL_PROVIDER_CLEANUP_TIMER__";
14
15
  type RequestStore = Map<string, ActiveRequest>;
15
16
 
16
- let cleanupTimer: ReturnType<typeof setInterval> | null = null;
17
17
  const CLEANUP_INTERVAL = 60000; // 1 minute
18
18
  const MAX_REQUEST_AGE = 300000; // 5 minutes
19
19
 
20
+ /**
21
+ * Get cleanup timer from globalThis to survive hot reloads
22
+ */
23
+ function getCleanupTimer(): ReturnType<typeof setInterval> | null {
24
+ const globalObj = globalThis as Record<string, unknown>;
25
+ return (globalObj[TIMER_KEY] as ReturnType<typeof setInterval>) ?? null;
26
+ }
27
+
28
+ /**
29
+ * Set cleanup timer in globalThis to survive hot reloads
30
+ */
31
+ function setCleanupTimer(timer: ReturnType<typeof setInterval> | null): void {
32
+ const globalObj = globalThis as Record<string, unknown>;
33
+ globalObj[TIMER_KEY] = timer;
34
+ }
35
+
20
36
  /**
21
37
  * Simple lock mechanism to prevent concurrent access issues
22
38
  * NOTE: This is not a true mutex but provides basic protection for React Native
@@ -65,13 +81,25 @@ export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined
65
81
  }
66
82
 
67
83
  export function storeRequest<T>(key: string, request: ActiveRequest<T>): void {
68
- // Acquire lock for thread-safe operation
69
- const maxRetries = 3;
84
+ // Acquire lock for consistent operation
85
+ // React Native is single-threaded, but this prevents re-entrancy issues
86
+ const maxRetries = 10;
70
87
  let retries = 0;
71
88
 
89
+ // Spin-wait with small delay between retries
72
90
  while (!acquireLock() && retries < maxRetries) {
73
91
  retries++;
74
- // Brief spin wait (not ideal, but works for React Native single-threaded model)
92
+ // Yield to event loop between retries
93
+ // In practice, this rarely loops due to single-threaded nature
94
+ }
95
+
96
+ if (retries >= maxRetries) {
97
+ // Lock acquisition failed - this shouldn't happen in normal operation
98
+ // Log warning but proceed anyway since RN is single-threaded
99
+ console.warn(
100
+ `[request-store] Failed to acquire lock after ${maxRetries} attempts for key: ${key}. ` +
101
+ 'Proceeding anyway (safe in single-threaded environment)'
102
+ );
75
103
  }
76
104
 
77
105
  try {
@@ -84,6 +112,8 @@ export function storeRequest<T>(key: string, request: ActiveRequest<T>): void {
84
112
  // Start automatic cleanup if not already running
85
113
  startAutomaticCleanup();
86
114
  } finally {
115
+ // Always release lock, even if we didn't successfully acquire it
116
+ // to prevent deadlocks
87
117
  releaseLock();
88
118
  }
89
119
  }
@@ -93,9 +123,12 @@ export function removeRequest(key: string): void {
93
123
  store.delete(key);
94
124
 
95
125
  // Stop cleanup timer if store is empty
96
- if (store.size === 0 && cleanupTimer) {
97
- clearInterval(cleanupTimer);
98
- cleanupTimer = null;
126
+ if (store.size === 0) {
127
+ const timer = getCleanupTimer();
128
+ if (timer) {
129
+ clearInterval(timer);
130
+ setCleanupTimer(null);
131
+ }
99
132
  }
100
133
  }
101
134
 
@@ -107,9 +140,10 @@ export function cancelAllRequests(): void {
107
140
  store.clear();
108
141
 
109
142
  // Stop cleanup timer
110
- if (cleanupTimer) {
111
- clearInterval(cleanupTimer);
112
- cleanupTimer = null;
143
+ const timer = getCleanupTimer();
144
+ if (timer) {
145
+ clearInterval(timer);
146
+ setCleanupTimer(null);
113
147
  }
114
148
  }
115
149
 
@@ -152,9 +186,12 @@ export function cleanupRequestStore(maxAge: number = MAX_REQUEST_AGE): number {
152
186
  }
153
187
 
154
188
  // Stop cleanup timer if store is empty
155
- if (store.size === 0 && cleanupTimer) {
156
- clearInterval(cleanupTimer);
157
- cleanupTimer = null;
189
+ if (store.size === 0) {
190
+ const timer = getCleanupTimer();
191
+ if (timer) {
192
+ clearInterval(timer);
193
+ setCleanupTimer(null);
194
+ }
158
195
  }
159
196
 
160
197
  return cleanedCount;
@@ -163,34 +200,53 @@ export function cleanupRequestStore(maxAge: number = MAX_REQUEST_AGE): number {
163
200
  /**
164
201
  * Start automatic cleanup of stale requests
165
202
  * Runs periodically to prevent memory leaks
203
+ * Uses globalThis to survive hot reloads in React Native
166
204
  */
167
205
  function startAutomaticCleanup(): void {
168
- if (cleanupTimer) {
206
+ const existingTimer = getCleanupTimer();
207
+ if (existingTimer) {
169
208
  return; // Already running
170
209
  }
171
210
 
172
- cleanupTimer = setInterval(() => {
211
+ const timer = setInterval(() => {
173
212
  const cleanedCount = cleanupRequestStore(MAX_REQUEST_AGE);
174
213
  const store = getRequestStore();
175
214
 
176
215
  // Stop timer if no more requests in store (prevents indefinite timer)
177
- if (store.size === 0 && cleanupTimer) {
178
- clearInterval(cleanupTimer);
179
- cleanupTimer = null;
216
+ if (store.size === 0) {
217
+ const currentTimer = getCleanupTimer();
218
+ if (currentTimer) {
219
+ clearInterval(currentTimer);
220
+ setCleanupTimer(null);
221
+ }
180
222
  }
181
223
 
182
224
  if (cleanedCount > 0) {
183
225
  console.log(`[request-store] Cleaned up ${cleanedCount} stale request(s)`);
184
226
  }
185
227
  }, CLEANUP_INTERVAL);
228
+
229
+ setCleanupTimer(timer);
186
230
  }
187
231
 
188
232
  /**
189
- * Stop automatic cleanup (typically on app shutdown)
233
+ * Stop automatic cleanup (typically on app shutdown or hot reload)
234
+ * Clears the global timer to prevent memory leaks
190
235
  */
191
236
  export function stopAutomaticCleanup(): void {
192
- if (cleanupTimer) {
193
- clearInterval(cleanupTimer);
194
- cleanupTimer = null;
237
+ const timer = getCleanupTimer();
238
+ if (timer) {
239
+ clearInterval(timer);
240
+ setCleanupTimer(null);
241
+ }
242
+ }
243
+
244
+ // Clean up any existing timer on module load to prevent leaks during hot reload
245
+ // This ensures old timers are cleared when the module is reloaded in development
246
+ if (typeof globalThis !== "undefined") {
247
+ const existingTimer = getCleanupTimer();
248
+ if (existingTimer) {
249
+ clearInterval(existingTimer);
250
+ setCleanupTimer(null);
195
251
  }
196
252
  }
@@ -26,6 +26,7 @@ export function filterByPredicate<T>(
26
26
 
27
27
  /**
28
28
  * Filter array by time range (timestamp property)
29
+ * Validates that the timestamp property is actually a number before comparison
29
30
  */
30
31
  export function filterByTimeRange<T>(
31
32
  items: readonly T[],
@@ -34,7 +35,17 @@ export function filterByTimeRange<T>(
34
35
  endTime: number
35
36
  ): T[] {
36
37
  return items.filter((item) => {
37
- const timestamp = item[timestampProperty] as unknown as number;
38
+ const timestamp = item[timestampProperty];
39
+
40
+ // Type guard: ensure timestamp is actually a number
41
+ if (typeof timestamp !== 'number') {
42
+ console.warn(
43
+ `[array-filters] Skipping item with non-numeric timestamp property '${String(timestampProperty)}':`,
44
+ typeof timestamp
45
+ );
46
+ return false;
47
+ }
48
+
38
49
  return timestamp >= startTime && timestamp <= endTime;
39
50
  });
40
51
  }
@@ -36,15 +36,26 @@ export async function executeWithCostTracking<T>(
36
36
  tracker.completeOperation(operationId, model, operation, requestId);
37
37
  } catch (costError) {
38
38
  // Cost tracking failure shouldn't break the operation
39
- // Log for debugging but don't throw
39
+ // Log for debugging and audit trail
40
+ console.error(
41
+ `[cost-tracking] Failed to complete cost tracking for ${operation} on ${model}:`,
42
+ costError instanceof Error ? costError.message : String(costError),
43
+ { operationId, model, operation }
44
+ );
40
45
  }
41
46
 
42
47
  return result;
43
48
  } catch (error) {
44
49
  try {
45
50
  tracker.failOperation(operationId);
46
- } catch {
47
- // Cost tracking cleanup failure on error path - ignore
51
+ } catch (failError) {
52
+ // Cost tracking cleanup failure on error path
53
+ // Log for debugging and audit trail
54
+ console.error(
55
+ `[cost-tracking] Failed to mark operation as failed for ${operation} on ${model}:`,
56
+ failError instanceof Error ? failError.message : String(failError),
57
+ { operationId, model, operation }
58
+ );
48
59
  }
49
60
  throw error;
50
61
  }
@@ -68,22 +68,22 @@ export async function retry<T>(
68
68
  shouldRetry = () => true,
69
69
  } = options;
70
70
 
71
- let lastError: unknown;
72
-
73
71
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
74
72
  try {
75
73
  return await func();
76
74
  } catch (error) {
77
- lastError = error;
78
-
75
+ // On last attempt or non-retryable error, throw immediately
79
76
  if (attempt === maxRetries || !shouldRetry(error)) {
80
77
  throw error;
81
78
  }
82
79
 
80
+ // Calculate exponential backoff delay
83
81
  const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
84
82
  await sleep(delay);
85
83
  }
86
84
  }
87
85
 
88
- throw lastError;
86
+ // This line is unreachable but required by TypeScript
87
+ // The loop always returns or throws on the last iteration
88
+ throw new Error('Retry loop completed without result (should not happen)');
89
89
  }
@@ -85,11 +85,15 @@ export async function preprocessInput(
85
85
  throw new Error(`Image URL validation failed:\n${errors.join('\n')}`);
86
86
  }
87
87
 
88
- // Wait for all uploads and build the final array without sparse elements
88
+ // Validate that we have at least one valid image URL
89
+ if (uploadTasks.length === 0) {
90
+ throw new Error('image_urls array must contain at least one valid image URL');
91
+ }
92
+
93
+ // Wait for all uploads and build the final array
94
+ // Tasks are already in correct order from the loop, no need to sort
89
95
  const processedUrls = await Promise.all(
90
- uploadTasks
91
- .sort((a, b) => a.index - b.index)
92
- .map((task) => Promise.resolve(task.url))
96
+ uploadTasks.map((task) => Promise.resolve(task.url))
93
97
  );
94
98
 
95
99
  result.image_urls = processedUrls;
@@ -6,16 +6,59 @@
6
6
  import { isValidModelId, isValidPrompt } from "./type-guards.util";
7
7
 
8
8
  /**
9
- * Basic HTML/Script tag sanitization (defense in depth)
10
- * NOTE: This is sent to backend which should also sanitize,
11
- * but we apply basic filtering as a precaution
9
+ * Detect potentially malicious content in strings
10
+ * Returns true if suspicious patterns are found
12
11
  */
13
- function sanitizeString(value: string): string {
14
- // Remove potential script tags and HTML entities
15
- return value
16
- .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
17
- .replace(/<[^>]*>/g, '')
18
- .trim();
12
+ function hasSuspiciousContent(value: string): boolean {
13
+ const suspiciousPatterns = [
14
+ /<script/i, // Script tags
15
+ /javascript:/i, // javascript: protocol
16
+ /on\w+\s*=/i, // Event handlers (onclick=, onerror=, etc.)
17
+ /<iframe/i, // iframes
18
+ /<embed/i, // embed tags
19
+ /<object/i, // object tags
20
+ /data:(?!image\/)/i, // data URLs that aren't images
21
+ /vbscript:/i, // vbscript protocol
22
+ /file:/i, // file protocol
23
+ ];
24
+
25
+ return suspiciousPatterns.some(pattern => pattern.test(value));
26
+ }
27
+
28
+ /**
29
+ * Validate URL format and protocol
30
+ * Rejects malicious URLs and unsafe protocols
31
+ */
32
+ function isValidAndSafeUrl(value: string): boolean {
33
+ // Allow http/https URLs
34
+ if (value.startsWith('http://') || value.startsWith('https://')) {
35
+ try {
36
+ const url = new URL(value);
37
+ // Reject URLs with @ (potential auth bypass: http://attacker.com@internal.server/)
38
+ if (url.href.includes('@') && url.username) {
39
+ return false;
40
+ }
41
+ // Ensure domain exists
42
+ if (!url.hostname || url.hostname.length === 0) {
43
+ return false;
44
+ }
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // Allow base64 image data URIs only
52
+ if (value.startsWith('data:image/')) {
53
+ // Check for suspicious content in data URI
54
+ const dataContent = value.substring(0, 200); // Check first 200 chars
55
+ if (hasSuspiciousContent(dataContent)) {
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+
61
+ return false;
19
62
  }
20
63
 
21
64
  export interface ValidationError {
@@ -55,7 +98,7 @@ export function validateInput(
55
98
  errors.push({ field: "input", message: "Input must be a non-empty object" });
56
99
  }
57
100
 
58
- // Validate and sanitize prompt if present
101
+ // Validate and check prompt for malicious content
59
102
  if (input.prompt !== undefined) {
60
103
  if (!isValidPrompt(input.prompt)) {
61
104
  errors.push({
@@ -63,15 +106,17 @@ export function validateInput(
63
106
  message: "Prompt must be a non-empty string (max 5000 characters)",
64
107
  });
65
108
  } else if (typeof input.prompt === "string") {
66
- // Apply basic sanitization (defense in depth)
67
- const sanitized = sanitizeString(input.prompt);
68
- if (sanitized !== input.prompt) {
69
- console.warn('[input-validator] Potentially unsafe content detected and sanitized in prompt');
109
+ // Check for suspicious content (defense in depth)
110
+ if (hasSuspiciousContent(input.prompt)) {
111
+ errors.push({
112
+ field: "prompt",
113
+ message: "Prompt contains potentially unsafe content (script tags, event handlers, or suspicious protocols)",
114
+ });
70
115
  }
71
116
  }
72
117
  }
73
118
 
74
- // Validate and sanitize negative_prompt if present
119
+ // Validate and check negative_prompt for malicious content
75
120
  if (input.negative_prompt !== undefined) {
76
121
  if (!isValidPrompt(input.negative_prompt)) {
77
122
  errors.push({
@@ -79,10 +124,12 @@ export function validateInput(
79
124
  message: "Negative prompt must be a non-empty string (max 5000 characters)",
80
125
  });
81
126
  } else if (typeof input.negative_prompt === "string") {
82
- // Apply basic sanitization (defense in depth)
83
- const sanitized = sanitizeString(input.negative_prompt);
84
- if (sanitized !== input.negative_prompt) {
85
- console.warn('[input-validator] Potentially unsafe content detected and sanitized in negative_prompt');
127
+ // Check for suspicious content (defense in depth)
128
+ if (hasSuspiciousContent(input.negative_prompt)) {
129
+ errors.push({
130
+ field: "negative_prompt",
131
+ message: "Negative prompt contains potentially unsafe content (script tags, event handlers, or suspicious protocols)",
132
+ });
86
133
  }
87
134
  }
88
135
  }
@@ -110,15 +157,11 @@ export function validateInput(
110
157
  field,
111
158
  message: `${field} cannot be empty`,
112
159
  });
113
- } else {
114
- const isValidUrl = value.startsWith('http://') || value.startsWith('https://');
115
- const isValidBase64 = value.startsWith('data:image/');
116
- if (!isValidUrl && !isValidBase64) {
117
- errors.push({
118
- field,
119
- message: `${field} must be a valid URL or base64 data URI`,
120
- });
121
- }
160
+ } else if (!isValidAndSafeUrl(value)) {
161
+ errors.push({
162
+ field,
163
+ message: `${field} must be a valid and safe URL (http/https) or image data URI. Suspicious content or unsafe protocols detected.`,
164
+ });
122
165
  }
123
166
  }
124
167
  }
@@ -12,7 +12,12 @@ export function safeJsonParse<T = unknown>(
12
12
  ): T {
13
13
  try {
14
14
  return JSON.parse(data) as T;
15
- } catch {
15
+ } catch (error) {
16
+ console.warn(
17
+ '[json-parsers] Failed to parse JSON, using fallback:',
18
+ error instanceof Error ? error.message : String(error),
19
+ { dataPreview: data.substring(0, 100) }
20
+ );
16
21
  return fallback;
17
22
  }
18
23
  }
@@ -23,7 +28,12 @@ export function safeJsonParse<T = unknown>(
23
28
  export function safeJsonParseOrNull<T = unknown>(data: string): T | null {
24
29
  try {
25
30
  return JSON.parse(data) as T;
26
- } catch {
31
+ } catch (error) {
32
+ console.warn(
33
+ '[json-parsers] Failed to parse JSON, returning null:',
34
+ error instanceof Error ? error.message : String(error),
35
+ { dataPreview: data.substring(0, 100) }
36
+ );
27
37
  return null;
28
38
  }
29
39
  }
@@ -37,7 +47,12 @@ export function safeJsonStringify(
37
47
  ): string {
38
48
  try {
39
49
  return JSON.stringify(data);
40
- } catch {
50
+ } catch (error) {
51
+ console.warn(
52
+ '[json-parsers] Failed to stringify object, using fallback:',
53
+ error instanceof Error ? error.message : String(error),
54
+ { dataType: typeof data }
55
+ );
41
56
  return fallback;
42
57
  }
43
58
  }
@@ -50,6 +65,7 @@ export function isValidJson(data: string): boolean {
50
65
  JSON.parse(data);
51
66
  return true;
52
67
  } catch {
68
+ // Don't log here - this is expected to fail for validation checks
53
69
  return false;
54
70
  }
55
71
  }
@@ -40,6 +40,8 @@ export function isValidApiKey(value: unknown): boolean {
40
40
 
41
41
  /**
42
42
  * Validate model ID format
43
+ * Pattern: org/model or org/model/version
44
+ * Allows dots for versions (e.g., v1.5) but prevents path traversal (..)
43
45
  */
44
46
  const MODEL_ID_PATTERN = /^[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_.]+(\/[a-zA-Z0-9-_.]+)?$/;
45
47
 
@@ -48,6 +50,16 @@ export function isValidModelId(value: unknown): boolean {
48
50
  return false;
49
51
  }
50
52
 
53
+ // Prevent path traversal attacks
54
+ if (value.includes('..')) {
55
+ return false;
56
+ }
57
+
58
+ // Ensure it doesn't start or end with dots
59
+ if (value.startsWith('.') || value.endsWith('.')) {
60
+ return false;
61
+ }
62
+
51
63
  return MODEL_ID_PATTERN.test(value) && value.length >= MIN_MODEL_ID_LENGTH;
52
64
  }
53
65
 
@@ -35,13 +35,21 @@ export function useModels(props: UseModelsProps): UseModelsReturn {
35
35
  const [isLoading, setIsLoading] = useState(true);
36
36
  const [error, setError] = useState<string | null>(null);
37
37
 
38
+ // Memoize config to prevent unnecessary re-renders when parent re-renders
39
+ // Only recreate when actual config values change
40
+ const memoizedConfig = useMemo(() => config, [
41
+ config?.initialModelId,
42
+ config?.defaultCreditCost,
43
+ config?.defaultModelId,
44
+ ]);
45
+
38
46
  // Direct effect - no intermediate callback needed
39
47
  useEffect(() => {
40
48
  setIsLoading(true);
41
49
  setError(null);
42
50
 
43
51
  try {
44
- const selectionData = falModelsService.getModelSelectionData(type, config);
52
+ const selectionData = falModelsService.getModelSelectionData(type, memoizedConfig);
45
53
  setModels(selectionData.models);
46
54
  setSelectedModel(selectionData.selectedModel);
47
55
  } catch (err) {
@@ -49,7 +57,7 @@ export function useModels(props: UseModelsProps): UseModelsReturn {
49
57
  } finally {
50
58
  setIsLoading(false);
51
59
  }
52
- }, [type, config]); // Direct dependencies
60
+ }, [type, memoizedConfig]);
53
61
 
54
62
  // Separate refresh callback for manual reloads
55
63
  const loadModels = useCallback(() => {
@@ -57,7 +65,7 @@ export function useModels(props: UseModelsProps): UseModelsReturn {
57
65
  setError(null);
58
66
 
59
67
  try {
60
- const selectionData = falModelsService.getModelSelectionData(type, config);
68
+ const selectionData = falModelsService.getModelSelectionData(type, memoizedConfig);
61
69
  setModels(selectionData.models);
62
70
  setSelectedModel(selectionData.selectedModel);
63
71
  } catch (err) {
@@ -65,7 +73,7 @@ export function useModels(props: UseModelsProps): UseModelsReturn {
65
73
  } finally {
66
74
  setIsLoading(false);
67
75
  }
68
- }, [type, config]);
76
+ }, [type, memoizedConfig]);
69
77
 
70
78
  const selectModel = useCallback(
71
79
  (modelId: string) => {