@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 +1 -1
- package/src/domain/constants/default-models.constants.ts +13 -4
- package/src/domain/types/model-selection.types.ts +9 -5
- package/src/infrastructure/services/fal-models.service.ts +8 -7
- package/src/infrastructure/services/fal-provider.ts +8 -6
- package/src/infrastructure/services/fal-queue-operations.ts +8 -1
- package/src/infrastructure/services/nsfw-content-error.ts +1 -1
- package/src/infrastructure/services/request-store.ts +78 -22
- package/src/infrastructure/utils/collections/array-filters.util.ts +12 -1
- package/src/infrastructure/utils/cost-tracking-executor.util.ts +14 -3
- package/src/infrastructure/utils/helpers/timing-helpers.util.ts +5 -5
- package/src/infrastructure/utils/input-preprocessor.util.ts +8 -4
- package/src/infrastructure/utils/input-validator.util.ts +71 -28
- package/src/infrastructure/utils/parsers/json-parsers.util.ts +19 -3
- package/src/infrastructure/utils/type-guards/validation-guards.util.ts +12 -0
- package/src/presentation/hooks/use-models.ts +12 -4
package/package.json
CHANGED
|
@@ -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<
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
130
|
-
//
|
|
131
|
-
let resolvePromise
|
|
132
|
-
let rejectPromise
|
|
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
|
|
151
|
+
resolvePromise(res.result);
|
|
150
152
|
return res.result;
|
|
151
153
|
})
|
|
152
154
|
.catch((error) => {
|
|
153
|
-
rejectPromise
|
|
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
|
|
|
@@ -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
|
|
69
|
-
|
|
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
|
-
//
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
206
|
+
const existingTimer = getCleanupTimer();
|
|
207
|
+
if (existingTimer) {
|
|
169
208
|
return; // Already running
|
|
170
209
|
}
|
|
171
210
|
|
|
172
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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]
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
76
|
+
}, [type, memoizedConfig]);
|
|
69
77
|
|
|
70
78
|
const selectModel = useCallback(
|
|
71
79
|
(modelId: string) => {
|