@umituz/react-native-ai-pruna-provider 1.0.64 → 1.0.65

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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Error Mapper Domain Service
3
+ * Maps raw errors to domain-specific error types
4
+ */
5
+
6
+ import { PrunaErrorType } from "../entities/error.types";
7
+ import type { PrunaErrorInfo } from "../entities/error.types";
8
+
9
+ export class ErrorMapperService {
10
+ static mapError(error: unknown): PrunaErrorInfo {
11
+ const originalMessage = this.extractMessage(error);
12
+ const errorName = error instanceof Error ? error.name : undefined;
13
+ const stack = error instanceof Error ? error.stack : undefined;
14
+ const statusCode = (error as Error & { statusCode?: number }).statusCode;
15
+
16
+ // Check for specific error types
17
+ if (error instanceof Error && error.name === 'AbortError') {
18
+ return this.buildError(
19
+ PrunaErrorType.CANCELLED,
20
+ "Request cancelled by user",
21
+ false,
22
+ originalMessage,
23
+ errorName,
24
+ stack,
25
+ statusCode
26
+ );
27
+ }
28
+
29
+ // HTTP status code mapping
30
+ if (statusCode !== undefined) {
31
+ return this.mapStatusCode(statusCode, originalMessage, errorName, stack);
32
+ }
33
+
34
+ // Message pattern matching
35
+ return this.mapByMessage(originalMessage, errorName, stack);
36
+ }
37
+
38
+ static isRetryable(error: PrunaErrorInfo): boolean {
39
+ return error.retryable;
40
+ }
41
+
42
+ private static mapStatusCode(
43
+ statusCode: number,
44
+ message: string,
45
+ errorName?: string,
46
+ stack?: string
47
+ ): PrunaErrorInfo {
48
+ const statusMap: Record<number, { type: PrunaErrorType; messageKey: string; retryable: boolean }> = {
49
+ 400: { type: PrunaErrorType.VALIDATION, messageKey: "error.validation", retryable: false },
50
+ 401: { type: PrunaErrorType.AUTHENTICATION, messageKey: "error.auth", retryable: false },
51
+ 402: { type: PrunaErrorType.QUOTA_EXCEEDED, messageKey: "error.quota", retryable: false },
52
+ 403: { type: PrunaErrorType.AUTHENTICATION, messageKey: "error.auth", retryable: false },
53
+ 404: { type: PrunaErrorType.MODEL_NOT_FOUND, messageKey: "error.model_not_found", retryable: false },
54
+ 422: { type: PrunaErrorType.VALIDATION, messageKey: "error.validation", retryable: false },
55
+ 429: { type: PrunaErrorType.RATE_LIMIT, messageKey: "error.rate_limit", retryable: true },
56
+ };
57
+
58
+ const mapped = statusMap[statusCode];
59
+ if (mapped) {
60
+ return this.buildError(
61
+ mapped.type,
62
+ mapped.messageKey,
63
+ mapped.retryable,
64
+ message,
65
+ errorName,
66
+ stack,
67
+ statusCode
68
+ );
69
+ }
70
+
71
+ // Server errors (5xx)
72
+ if (statusCode >= 500 && statusCode <= 504) {
73
+ return this.buildError(
74
+ PrunaErrorType.API_ERROR,
75
+ "error.api",
76
+ true,
77
+ message,
78
+ errorName,
79
+ stack,
80
+ statusCode
81
+ );
82
+ }
83
+
84
+ return this.buildError(
85
+ PrunaErrorType.UNKNOWN,
86
+ "error.unknown",
87
+ false,
88
+ message,
89
+ errorName,
90
+ stack,
91
+ statusCode
92
+ );
93
+ }
94
+
95
+ private static mapByMessage(
96
+ message: string,
97
+ errorName?: string,
98
+ stack?: string
99
+ ): PrunaErrorInfo {
100
+ const msg = message.toLowerCase();
101
+
102
+ // Network errors
103
+ if (this.matchesAny(msg, ["network", "fetch", "econnrefused", "enotfound"])) {
104
+ return this.buildError(
105
+ PrunaErrorType.NETWORK,
106
+ "error.network",
107
+ true,
108
+ message,
109
+ errorName,
110
+ stack
111
+ );
112
+ }
113
+
114
+ // Timeout errors
115
+ if (this.matchesAny(msg, ["timeout", "timed out", "polling attempts reached"])) {
116
+ return this.buildError(
117
+ PrunaErrorType.POLLING_TIMEOUT,
118
+ "error.timeout",
119
+ true,
120
+ message,
121
+ errorName,
122
+ stack
123
+ );
124
+ }
125
+
126
+ // File upload errors
127
+ if (msg.includes("file upload")) {
128
+ return this.buildError(
129
+ PrunaErrorType.FILE_UPLOAD,
130
+ "error.file_upload",
131
+ true,
132
+ message,
133
+ errorName,
134
+ stack
135
+ );
136
+ }
137
+
138
+ // Validation errors
139
+ if (this.matchesAny(msg, ["prompt is required", "image is required", "invalid"])) {
140
+ return this.buildError(
141
+ PrunaErrorType.VALIDATION,
142
+ "error.validation",
143
+ false,
144
+ message,
145
+ errorName,
146
+ stack
147
+ );
148
+ }
149
+
150
+ // Cancellation
151
+ if (msg.includes("cancelled by user")) {
152
+ return this.buildError(
153
+ PrunaErrorType.CANCELLED,
154
+ "error.cancelled",
155
+ false,
156
+ message,
157
+ errorName,
158
+ stack
159
+ );
160
+ }
161
+
162
+ return this.buildError(
163
+ PrunaErrorType.UNKNOWN,
164
+ "error.unknown",
165
+ false,
166
+ message,
167
+ errorName,
168
+ stack
169
+ );
170
+ }
171
+
172
+ private static buildError(
173
+ type: PrunaErrorType,
174
+ messageKey: string,
175
+ retryable: boolean,
176
+ originalError: string,
177
+ originalErrorName?: string,
178
+ stack?: string,
179
+ statusCode?: number
180
+ ): PrunaErrorInfo {
181
+ const error: PrunaErrorInfo = {
182
+ type,
183
+ messageKey,
184
+ retryable,
185
+ originalError,
186
+ };
187
+
188
+ if (originalErrorName) error.originalErrorName = originalErrorName;
189
+ if (stack) error.stack = stack;
190
+ if (statusCode !== undefined) error.statusCode = statusCode;
191
+
192
+ return error;
193
+ }
194
+
195
+ private static extractMessage(error: unknown): string {
196
+ if (error instanceof Error) return error.message;
197
+ if (typeof error === 'string') return error;
198
+ return String(error);
199
+ }
200
+
201
+ private static matchesAny(text: string, patterns: string[]): boolean {
202
+ return patterns.some(pattern => text.includes(pattern));
203
+ }
204
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Validation Domain Service
3
+ * Centralized validation logic for Pruna operations
4
+ */
5
+
6
+ import { ApiKey } from "../value-objects/api-key.value";
7
+ import { ModelId } from "../value-objects/model-id.value";
8
+
9
+ export interface ValidationResult {
10
+ isValid: boolean;
11
+ error?: string;
12
+ }
13
+
14
+ export class ValidationService {
15
+ static validatePrompt(prompt: unknown): ValidationResult {
16
+ if (!prompt || typeof prompt !== 'string') {
17
+ return { isValid: false, error: "Prompt is required and must be a string" };
18
+ }
19
+ if (prompt.trim().length === 0) {
20
+ return { isValid: false, error: "Prompt cannot be empty" };
21
+ }
22
+ if (prompt.length > 2000) {
23
+ return { isValid: false, error: "Prompt exceeds maximum length of 2000 characters" };
24
+ }
25
+ return { isValid: true };
26
+ }
27
+
28
+ static validateApiKey(apiKey: string | ApiKey): ValidationResult {
29
+ try {
30
+ if (apiKey instanceof ApiKey) {
31
+ return { isValid: true };
32
+ }
33
+ ApiKey.create(apiKey);
34
+ return { isValid: true };
35
+ } catch (error) {
36
+ return {
37
+ isValid: false,
38
+ error: error instanceof Error ? error.message : "Invalid API key"
39
+ };
40
+ }
41
+ }
42
+
43
+ static validateModel(model: string): ValidationResult {
44
+ try {
45
+ ModelId.create(model);
46
+ return { isValid: true };
47
+ } catch (error) {
48
+ return {
49
+ isValid: false,
50
+ error: error instanceof Error ? error.message : "Invalid model"
51
+ };
52
+ }
53
+ }
54
+
55
+ static validateTimeout(timeoutMs: number, maxTimeoutMs: number): ValidationResult {
56
+ if (!Number.isInteger(timeoutMs)) {
57
+ return { isValid: false, error: "Timeout must be an integer" };
58
+ }
59
+ if (timeoutMs <= 0) {
60
+ return { isValid: false, error: "Timeout must be positive" };
61
+ }
62
+ if (timeoutMs > maxTimeoutMs) {
63
+ return { isValid: false, error: `Timeout cannot exceed ${maxTimeoutMs}ms` };
64
+ }
65
+ return { isValid: true };
66
+ }
67
+
68
+ static validateImageData(imageData: unknown): ValidationResult {
69
+ if (!imageData) {
70
+ return { isValid: false, error: "Image data is required" };
71
+ }
72
+
73
+ if (typeof imageData === 'string') {
74
+ if (imageData.trim().length === 0) {
75
+ return { isValid: false, error: "Image data cannot be empty" };
76
+ }
77
+ return { isValid: true };
78
+ }
79
+
80
+ if (Array.isArray(imageData)) {
81
+ if (imageData.length === 0) {
82
+ return { isValid: false, error: "Image array cannot be empty" };
83
+ }
84
+ const hasInvalidItem = imageData.some(item => typeof item !== 'string' || item.trim().length === 0);
85
+ if (hasInvalidItem) {
86
+ return { isValid: false, error: "Image array contains invalid items" };
87
+ }
88
+ return { isValid: true };
89
+ }
90
+
91
+ return { isValid: false, error: "Image data must be a string or array of strings" };
92
+ }
93
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * ApiKey Value Object
3
+ * Encapsulates API key validation and storage
4
+ */
5
+
6
+ export class ApiKey {
7
+ private static readonly MIN_LENGTH = 10;
8
+
9
+ private readonly value: string;
10
+
11
+ private constructor(value: string) {
12
+ this.value = value;
13
+ }
14
+
15
+ static create(value: string): ApiKey {
16
+ if (!value || value.trim().length < ApiKey.MIN_LENGTH) {
17
+ throw new Error(`API key must be at least ${ApiKey.MIN_LENGTH} characters`);
18
+ }
19
+ return new ApiKey(value.trim());
20
+ }
21
+
22
+ toString(): string {
23
+ return this.value;
24
+ }
25
+
26
+ mask(): string {
27
+ if (this.value.length <= 8) return '********';
28
+ return `${this.value.substring(0, 4)}...${this.value.substring(this.value.length - 4)}`;
29
+ }
30
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * ModelId Value Object
3
+ * Represents a valid Pruna model identifier
4
+ */
5
+
6
+ import { VALID_PRUNA_MODELS } from "../../infrastructure/services/pruna-provider.constants";
7
+
8
+ export type PrunaModelId = 'p-image' | 'p-image-edit' | 'p-video';
9
+
10
+ export class ModelId {
11
+ private readonly value: PrunaModelId;
12
+
13
+ private constructor(value: PrunaModelId) {
14
+ this.value = value;
15
+ }
16
+
17
+ static create(value: string): ModelId {
18
+ if (!VALID_PRUNA_MODELS.includes(value as PrunaModelId)) {
19
+ throw new Error(
20
+ `Invalid model: "${value}". Valid models: ${VALID_PRUNA_MODELS.join(', ')}`
21
+ );
22
+ }
23
+ return new ModelId(value as PrunaModelId);
24
+ }
25
+
26
+ toString(): string {
27
+ return this.value;
28
+ }
29
+
30
+ isImage(): boolean {
31
+ return this.value === 'p-image';
32
+ }
33
+
34
+ isImageEdit(): boolean {
35
+ return this.value === 'p-image-edit';
36
+ }
37
+
38
+ isVideo(): boolean {
39
+ return this.value === 'p-video';
40
+ }
41
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * SessionId Value Object
3
+ * Represents a unique session identifier for logging
4
+ */
5
+
6
+ export class SessionId {
7
+ private static counter = 0;
8
+
9
+ private readonly value: string;
10
+
11
+ constructor() {
12
+ this.value = `session_${++SessionId.counter}_${Date.now()}`;
13
+ }
14
+
15
+ toString(): string {
16
+ return this.value;
17
+ }
18
+
19
+ equals(other: SessionId): boolean {
20
+ return this.value === other.value;
21
+ }
22
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pruna AI Provider - Refactored with DDD Architecture
3
+ *
4
+ * @module react-native-ai-pruna-provider
5
+ *
6
+ * Architecture:
7
+ * - Domain: Business logic, value objects, domain services
8
+ * - Application: Use cases, orchestration, DTOs
9
+ * - Infrastructure: API clients, storage, logging
10
+ * - Presentation: React hooks, UI integration
11
+ *
12
+ * Key Features:
13
+ * - Clean separation of concerns
14
+ * - Maximum 150 lines per file
15
+ * - No code duplication
16
+ * - Type-safe with TypeScript
17
+ * - Comprehensive error handling
18
+ */
19
+
20
+ // Core Service
21
+ export { prunaService, PrunaService } from './application/services/pruna-service';
22
+ export type {
23
+ PrunaConfig,
24
+ PrunaModel,
25
+ } from './application/services/pruna-service';
26
+
27
+ // React Hooks
28
+ export { usePrunaGeneration } from './presentation/hooks/use-pruna-generation.new';
29
+ export type { PrunaGenerationState } from './presentation/hooks/use-pruna-generation.new';
30
+
31
+ // DTOs
32
+ export type {
33
+ PrunaImageGenerationRequest,
34
+ PrunaVideoGenerationRequest,
35
+ PrunaImageEditRequest,
36
+ PrunaGenerationResponse,
37
+ PrunaGenerationOptions,
38
+ PrunaGenerationError,
39
+ } from './application/dto/pruna.dto';
40
+
41
+ // Domain Layer
42
+ export { SessionId } from './domain/value-objects/session-id.value';
43
+ export { ApiKey } from './domain/value-objects/api-key.value';
44
+ export { ModelId, PrunaModelId } from './domain/value-objects/model-id.value';
45
+ export { ValidationService, ValidationResult } from './domain/services/validation.domain-service';
46
+ export { ErrorMapperService } from './domain/services/error-mapper.domain-service';
47
+
48
+ // Infrastructure Layer
49
+ export { logger, createLogger, LogLevel } from './infrastructure/logging/pruna-logger';
50
+ export { httpClient, HttpClient } from './infrastructure/api/http-client';
51
+ export { fileStorageService, FileStorageService } from './infrastructure/storage/file-storage';
52
+
53
+ // Use Cases (for advanced usage)
54
+ export { generateImageUseCase, GenerateImageUseCase } from './application/use-cases/generate-image.use-case';
55
+ export { generateVideoUseCase, GenerateVideoUseCase } from './application/use-cases/generate-video.use-case';
56
+ export { generateImageEditUseCase, GenerateImageEditUseCase } from './application/use-cases/generate-image-edit.use-case';
57
+
58
+ // Legacy Exports (for backward compatibility)
59
+ export { prunaProvider as PrunaProvider } from './infrastructure/services/pruna-provider';
60
+ export type {
61
+ IAIProvider,
62
+ AIProviderConfig,
63
+ SubscribeOptions,
64
+ RunOptions,
65
+ } from './domain/types';
@@ -0,0 +1,111 @@
1
+ /**
2
+ * HTTP Client Infrastructure
3
+ * Centralized HTTP request handling with error handling and retry logic
4
+ */
5
+
6
+ import { ErrorMapperService } from "../../domain/services/error-mapper.domain-service";
7
+ import { logger } from "../logging/pruna-logger";
8
+
9
+ export interface HttpRequestConfig {
10
+ url: string;
11
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
12
+ headers?: Record<string, string>;
13
+ body?: string | FormData;
14
+ signal?: AbortSignal;
15
+ timeout?: number;
16
+ }
17
+
18
+ export interface HttpResponse<T = unknown> {
19
+ data: T;
20
+ status: number;
21
+ statusText: string;
22
+ }
23
+
24
+ export class HttpClient {
25
+ async request<T>(config: HttpRequestConfig, sessionId: string, tag: string): Promise<HttpResponse<T>> {
26
+ const log = logger;
27
+ const controller = new AbortController();
28
+ const timeoutId = config.timeout ?
29
+ setTimeout(() => controller.abort(), config.timeout) : undefined;
30
+
31
+ try {
32
+ log.info(sessionId, tag, `HTTP ${config.method} ${config.url}`, {
33
+ hasBody: !!config.body,
34
+ hasSignal: !!config.signal,
35
+ });
36
+
37
+ const response = await fetch(config.url, {
38
+ method: config.method,
39
+ headers: config.headers,
40
+ body: config.body,
41
+ signal: config.signal || controller.signal,
42
+ });
43
+
44
+ if (timeoutId) clearTimeout(timeoutId);
45
+
46
+ if (!response.ok) {
47
+ await this.handleErrorResponse(response, sessionId, tag);
48
+ }
49
+
50
+ const data = await response.json() as T;
51
+ log.info(sessionId, tag, `HTTP ${response.status} ${response.statusText}`, {
52
+ keys: Object.keys(data as Record<string, unknown>),
53
+ });
54
+
55
+ return { data, status: response.status, statusText: response.statusText };
56
+
57
+ } catch (error) {
58
+ if (timeoutId) clearTimeout(timeoutId);
59
+ throw this.handleRequestError(error, sessionId, tag);
60
+ }
61
+ }
62
+
63
+ private async handleErrorResponse(response: Response, sessionId: string, tag: string): Promise<never> {
64
+ let rawBody = '';
65
+ try {
66
+ rawBody = await response.text();
67
+ } catch {
68
+ // Ignore body read errors
69
+ }
70
+
71
+ const errorMessage = this.extractErrorMessage(rawBody) || `HTTP ${response.status}`;
72
+ const error = new Error(errorMessage);
73
+ (error as Error & { statusCode?: number }).statusCode = response.status;
74
+
75
+ logger.error(sessionId, tag, `HTTP Error ${response.status}`, {
76
+ statusText: response.statusText,
77
+ errorMessage,
78
+ bodyLength: rawBody.length,
79
+ });
80
+
81
+ throw error;
82
+ }
83
+
84
+ private handleRequestError(error: unknown, sessionId: string, tag: string): Error {
85
+ if (error instanceof Error && error.name === 'AbortError') {
86
+ const abortError = new Error("Request cancelled by user");
87
+ (abortError as Error & { statusCode?: number }).statusCode = 499;
88
+ return abortError;
89
+ }
90
+
91
+ const mappedError = ErrorMapperService.mapError(error);
92
+ logger.error(sessionId, tag, `Request failed: ${mappedError.messageKey}`, {
93
+ originalError: mappedError.originalError,
94
+ });
95
+
96
+ return error instanceof Error ? error : new Error(String(error));
97
+ }
98
+
99
+ private extractErrorMessage(body: string): string | null {
100
+ if (!body) return null;
101
+
102
+ try {
103
+ const parsed = JSON.parse(body) as Record<string, unknown>;
104
+ return String(parsed.message || parsed.detail || parsed.error || body);
105
+ } catch {
106
+ return body || null;
107
+ }
108
+ }
109
+ }
110
+
111
+ export const httpClient = new HttpClient();
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Pruna Logger
3
+ * Centralized logging with automatic session management
4
+ * Eliminates repetitive logging code throughout the codebase
5
+ */
6
+
7
+ import { SessionId } from "../../domain/value-objects/session-id.value";
8
+
9
+ export enum LogLevel {
10
+ INFO = 'info',
11
+ WARN = 'warn',
12
+ ERROR = 'error',
13
+ }
14
+
15
+ export interface LogContext {
16
+ readonly sessionId: string;
17
+ readonly tag: string;
18
+ }
19
+
20
+ export class PrunaLogger {
21
+ private static instance: PrunaLogger;
22
+ private sessions = new Map<string, number>();
23
+
24
+ private constructor() {}
25
+
26
+ static getInstance(): PrunaLogger {
27
+ if (!PrunaLogger.instance) {
28
+ PrunaLogger.instance = new PrunaLogger();
29
+ }
30
+ return PrunaLogger.instance;
31
+ }
32
+
33
+ createSession(): string {
34
+ const sessionId = new SessionId();
35
+ const id = sessionId.toString();
36
+ this.sessions.set(id, Date.now());
37
+ return id;
38
+ }
39
+
40
+ endSession(sessionId: string): void {
41
+ this.sessions.delete(sessionId);
42
+ }
43
+
44
+ private log(
45
+ sessionId: string,
46
+ level: LogLevel,
47
+ tag: string,
48
+ message: string,
49
+ data?: Record<string, unknown>
50
+ ): void {
51
+ const elapsed = this.calculateElapsed(sessionId);
52
+ const entry = {
53
+ timestamp: Date.now(),
54
+ elapsed,
55
+ level,
56
+ tag,
57
+ message,
58
+ ...(data && { data }),
59
+ };
60
+
61
+ // Console output in DEV mode
62
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
63
+ const consoleFn = level === LogLevel.ERROR ? console.error :
64
+ level === LogLevel.WARN ? console.warn : console.log;
65
+ consoleFn(`[${tag}] ${message}`, data ?? '');
66
+ }
67
+ }
68
+
69
+ info(sessionId: string, tag: string, message: string, data?: Record<string, unknown>): void {
70
+ this.log(sessionId, LogLevel.INFO, tag, message, data);
71
+ }
72
+
73
+ warn(sessionId: string, tag: string, message: string, data?: Record<string, unknown>): void {
74
+ this.log(sessionId, LogLevel.WARN, tag, message, data);
75
+ }
76
+
77
+ error(sessionId: string, tag: string, message: string, data?: Record<string, unknown>): void {
78
+ this.log(sessionId, LogLevel.ERROR, tag, message, data);
79
+ }
80
+
81
+ private calculateElapsed(sessionId: string): number {
82
+ const startTime = this.sessions.get(sessionId);
83
+ return startTime ? Date.now() - startTime : 0;
84
+ }
85
+ }
86
+
87
+ // Singleton instance
88
+ export const logger = PrunaLogger.getInstance();
89
+
90
+ // Convenience factory function
91
+ export function createLogger(tag: string) {
92
+ return {
93
+ info: (sessionId: string, message: string, data?: Record<string, unknown>) =>
94
+ logger.info(sessionId, tag, message, data),
95
+ warn: (sessionId: string, message: string, data?: Record<string, unknown>) =>
96
+ logger.warn(sessionId, tag, message, data),
97
+ error: (sessionId: string, message: string, data?: Record<string, unknown>) =>
98
+ logger.error(sessionId, tag, message, data),
99
+ };
100
+ }