@arela/uploader 1.0.15 → 1.0.17

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,268 @@
1
+ import { Agent } from 'http';
2
+ import { Agent as HttpsAgent } from 'https';
3
+ import fetch from 'node-fetch';
4
+
5
+ import appConfig from '../config/config.js';
6
+ import logger from './LoggingService.js';
7
+
8
+ /**
9
+ * Pipeline API Service
10
+ * Handles HTTP communication with the API for pipeline job polling.
11
+ * Used by the poll worker mode as an alternative to Redis/BullMQ.
12
+ */
13
+ export class PipelineApiService {
14
+ /**
15
+ * @param {string|null} apiTarget - API target for polling: 'agencia' recommended
16
+ */
17
+ constructor(apiTarget = 'agencia') {
18
+ this.apiTarget = apiTarget;
19
+ const apiConfig = appConfig.getApiConfig(apiTarget);
20
+ this.baseUrl = apiConfig.baseUrl;
21
+ this.token = apiConfig.token;
22
+
23
+ // Get API connection settings
24
+ const maxApiConnections = parseInt(process.env.MAX_API_CONNECTIONS) || 5;
25
+ const connectionTimeout =
26
+ parseInt(process.env.API_CONNECTION_TIMEOUT) || 30000;
27
+
28
+ // Retry configuration
29
+ this.maxRetries = parseInt(process.env.API_MAX_RETRIES) || 3;
30
+ this.retryDelay = parseInt(process.env.API_RETRY_DELAY) || 1000;
31
+
32
+ // Initialize HTTP agents for connection pooling
33
+ this.httpAgent = new Agent({
34
+ keepAlive: true,
35
+ keepAliveMsecs: 30000,
36
+ maxSockets: maxApiConnections,
37
+ timeout: connectionTimeout,
38
+ });
39
+
40
+ this.httpsAgent = new HttpsAgent({
41
+ keepAlive: true,
42
+ keepAliveMsecs: 30000,
43
+ maxSockets: maxApiConnections,
44
+ timeout: connectionTimeout,
45
+ });
46
+
47
+ logger.debug(
48
+ `🔗 Pipeline API Service configured for ${apiTarget} → ${this.baseUrl}`,
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Get the appropriate HTTP agent based on URL protocol
54
+ * @private
55
+ */
56
+ #getAgent(url) {
57
+ return url.startsWith('https://') ? this.httpsAgent : this.httpAgent;
58
+ }
59
+
60
+ /**
61
+ * Make an HTTP request with retry logic
62
+ * @private
63
+ */
64
+ async #request(method, endpoint, body = null, retries = 0) {
65
+ const url = `${this.baseUrl}${endpoint}`;
66
+
67
+ try {
68
+ const options = {
69
+ method,
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ 'x-api-key': this.token,
73
+ },
74
+ agent: this.#getAgent(url),
75
+ };
76
+
77
+ if (body) {
78
+ options.body = JSON.stringify(body);
79
+ }
80
+
81
+ const response = await fetch(url, options);
82
+
83
+ if (!response.ok) {
84
+ // Handle non-2xx responses
85
+ if (response.status === 404) {
86
+ return null; // Resource not found is not an error for polling
87
+ }
88
+
89
+ const errorText = await response.text();
90
+ throw new Error(
91
+ `HTTP ${response.status}: ${response.statusText} - ${errorText}`,
92
+ );
93
+ }
94
+
95
+ // Check if response has content
96
+ const contentType = response.headers.get('content-type');
97
+ if (contentType && contentType.includes('application/json')) {
98
+ return response.json();
99
+ }
100
+
101
+ return { success: true };
102
+ } catch (error) {
103
+ // Retry on network errors
104
+ if (retries < this.maxRetries && this.#isRetryableError(error)) {
105
+ logger.debug(
106
+ `⚠️ Retrying request (${retries + 1}/${this.maxRetries}): ${error.message}`,
107
+ );
108
+ await this.#sleep(this.retryDelay * (retries + 1));
109
+ return this.#request(method, endpoint, body, retries + 1);
110
+ }
111
+
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Check if error is retryable
118
+ * @private
119
+ */
120
+ #isRetryableError(error) {
121
+ const retryableCodes = [
122
+ 'ECONNRESET',
123
+ 'ETIMEDOUT',
124
+ 'ECONNREFUSED',
125
+ 'ENOTFOUND',
126
+ 'EAI_AGAIN',
127
+ ];
128
+ return (
129
+ retryableCodes.includes(error.code) ||
130
+ (error.message && error.message.includes('timeout'))
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Sleep for specified milliseconds
136
+ * @private
137
+ */
138
+ #sleep(ms) {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
141
+
142
+ // =====================
143
+ // Public API Methods
144
+ // =====================
145
+
146
+ /**
147
+ * Get the next available job for this server
148
+ * @param {string} serverId - Server identifier
149
+ * @returns {Promise<Object|null>} Job data or null if no jobs available
150
+ */
151
+ async getNextJob(serverId) {
152
+ try {
153
+ const result = await this.#request(
154
+ 'GET',
155
+ `/api/uploader/pipeline/worker/jobs/next?serverId=${encodeURIComponent(serverId)}`,
156
+ );
157
+ return result;
158
+ } catch (error) {
159
+ logger.error(`❌ Failed to get next job: ${error.message}`);
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Update job progress
166
+ * @param {string} jobId - Job UUID
167
+ * @param {number} progress - Progress percentage (0-100)
168
+ * @param {string} message - Progress message
169
+ * @param {string} currentFile - Current file being processed
170
+ * @param {string} currentStep - Current pipeline step
171
+ * @returns {Promise<boolean>} Success status
172
+ */
173
+ async updateProgress(jobId, progress, message, currentFile, currentStep) {
174
+ try {
175
+ await this.#request(
176
+ 'PATCH',
177
+ `/api/uploader/pipeline/worker/jobs/${jobId}/progress`,
178
+ {
179
+ progress,
180
+ message,
181
+ currentFile,
182
+ currentStep,
183
+ },
184
+ );
185
+ return true;
186
+ } catch (error) {
187
+ logger.warn(`⚠️ Failed to update progress: ${error.message}`);
188
+ return false;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Mark job as completed
194
+ * @param {string} jobId - Job UUID
195
+ * @param {Object} result - Result data
196
+ * @returns {Promise<boolean>} Success status
197
+ */
198
+ async completeJob(jobId, result = {}) {
199
+ try {
200
+ await this.#request(
201
+ 'PATCH',
202
+ `/api/uploader/pipeline/worker/jobs/${jobId}/complete`,
203
+ {
204
+ status: 'completed',
205
+ result,
206
+ },
207
+ );
208
+ logger.success(`✅ Job ${jobId} marked as completed`);
209
+ return true;
210
+ } catch (error) {
211
+ logger.error(`❌ Failed to complete job: ${error.message}`);
212
+ return false;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Mark job as failed
218
+ * @param {string} jobId - Job UUID
219
+ * @param {string} errorMessage - Error description
220
+ * @returns {Promise<boolean>} Success status
221
+ */
222
+ async failJob(jobId, errorMessage) {
223
+ try {
224
+ await this.#request(
225
+ 'PATCH',
226
+ `/api/uploader/pipeline/worker/jobs/${jobId}/complete`,
227
+ {
228
+ status: 'failed',
229
+ error: errorMessage,
230
+ },
231
+ );
232
+ logger.error(`❌ Job ${jobId} marked as failed: ${errorMessage}`);
233
+ return true;
234
+ } catch (error) {
235
+ logger.error(`❌ Failed to mark job as failed: ${error.message}`);
236
+ return false;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Send a heartbeat to keep the worker alive during long-running jobs
242
+ * @param {string} serverId - Server identifier
243
+ * @param {'online'|'busy'} status - Worker status
244
+ * @returns {Promise<boolean>} Success status
245
+ */
246
+ async sendHeartbeat(serverId, status = 'busy') {
247
+ try {
248
+ await this.#request('POST', '/api/uploader/pipeline/worker/heartbeat', {
249
+ serverId,
250
+ status,
251
+ });
252
+ return true;
253
+ } catch (error) {
254
+ logger.warn(`⚠️ Failed to send heartbeat: ${error.message}`);
255
+ return false;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Close connections and cleanup
261
+ */
262
+ destroy() {
263
+ this.httpAgent.destroy();
264
+ this.httpsAgent.destroy();
265
+ }
266
+ }
267
+
268
+ export default PipelineApiService;