@umituz/react-native-ai-pruna-provider 1.0.63 → 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.
- package/package.json +1 -4
- package/src/application/dto/pruna.dto.ts +58 -0
- package/src/application/services/pruna-service.ts +81 -0
- package/src/application/use-cases/generate-image-edit.use-case.ts +165 -0
- package/src/application/use-cases/generate-image.use-case.ts +125 -0
- package/src/application/use-cases/generate-video.use-case.ts +147 -0
- package/src/domain/services/error-mapper.domain-service.ts +204 -0
- package/src/domain/services/validation.domain-service.ts +93 -0
- package/src/domain/value-objects/api-key.value.ts +30 -0
- package/src/domain/value-objects/model-id.value.ts +41 -0
- package/src/domain/value-objects/session-id.value.ts +22 -0
- package/src/index.new.ts +65 -0
- package/src/infrastructure/api/http-client.ts +111 -0
- package/src/infrastructure/logging/pruna-logger.ts +100 -0
- package/src/infrastructure/storage/file-storage.ts +97 -0
- package/src/infrastructure/utils/calculation.utils.ts +2 -55
- package/src/presentation/hooks/use-pruna-generation.new.ts +182 -0
|
@@ -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
|
+
}
|
package/src/index.new.ts
ADDED
|
@@ -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
|
+
}
|