app-manager-edge-worker 1.1.2 → 1.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/src/client.js CHANGED
@@ -1,351 +1,396 @@
1
- import { io } from "socket.io-client";
2
- import os from "os";
3
- import path from "path";
4
- import axios from "axios";
5
- import { execSync } from "child_process";
6
- import localBrowserManager from "./browser.js";
7
- import { handleChromiumScrape, handleHttpScrape } from "./handlers/scrapeHandler.js";
8
- import { handlePreviewPage, handleInspectElement } from "./handlers/previewHandler.js";
9
- import { handleSendEmail } from "./handlers/mailHandler.js";
10
- import { handleAiSmartRepair } from "./handlers/aiRepairHandler.js";
11
- import { getWhatsAppHandler } from "./handlers/whatsappHandler.js";
12
- import { printBanner, logInfo, logSuccess, logWarn, logError, logJob } from "./banner.js";
13
-
14
- export async function startWorkerClient(config) {
15
- const { workerKey, serverUrl, concurrency, workerName, keepAliveMinutes = 7 } = config;
16
- const isTermux = localBrowserManager.isTermux();
17
-
18
- printBanner({
19
- workerName,
20
- serverUrl,
21
- concurrency,
22
- platform: `${os.platform()} ${os.arch()}`,
23
- isTermux,
24
- keepAliveMinutes,
25
- });
26
-
27
- if (isTermux) {
28
- try {
29
- execSync("termux-wake-lock", { stdio: "ignore" });
30
- logSuccess("[Android Termux] Wake lock acquired to keep CPU awake in background.");
31
- } catch {
32
- // termux-tools might not be installed yet
33
- }
34
- }
35
-
36
- logInfo("Checking Chromium browser availability...");
37
- let chromiumExecutable = null;
38
- try {
39
- chromiumExecutable = await localBrowserManager.ensureChromiumExecutable();
40
- logSuccess(`Chromium ready: ${chromiumExecutable}`);
41
- } catch (browserErr) {
42
- logWarn(`Chromium initialization warning: ${browserErr.message}`);
43
- }
44
-
45
- logInfo("Connecting to Apps Manager cluster...");
46
-
47
- const socket = io(`${serverUrl}/workers`, {
48
- transports: ["websocket", "polling"],
49
- reconnection: true,
50
- reconnectionAttempts: Infinity,
51
- reconnectionDelay: 1000,
52
- reconnectionDelayMax: 5000,
53
- auth: {
54
- workerKey,
55
- metadata: {
56
- hostname: workerName || os.hostname(),
57
- platform: os.platform(),
58
- arch: os.arch(),
59
- osVersion: os.release(),
60
- nodeVersion: process.version,
61
- chromiumVersion: chromiumExecutable ? path.basename(chromiumExecutable) : "",
62
- chromiumPath: chromiumExecutable || "",
63
- isTermux,
64
- },
65
- },
66
- });
67
-
68
- const CHUNK_SIZE = 256 * 1024; // 256 KB chunks to prevent any WebSocket frame buffer overflow
69
-
70
- const sendJobResult = (payload) => {
71
- let payloadStr = "";
72
- try {
73
- payloadStr = JSON.stringify(payload);
74
- } catch {
75
- socket.emit("job:result", payload);
76
- return;
77
- }
78
-
79
- // Small payload: send directly
80
- if (payloadStr.length <= CHUNK_SIZE) {
81
- socket.emit("job:result", payload);
82
- return;
83
- }
84
-
85
- // Large payload (e.g. screenshot or large scrape): stream in chunks
86
- const totalChunks = Math.ceil(payloadStr.length / CHUNK_SIZE);
87
- const jobId = payload.jobId;
88
-
89
- socket.emit("job:result:stream:start", {
90
- jobId,
91
- totalChunks,
92
- totalLength: payloadStr.length,
93
- });
94
-
95
- for (let i = 0; i < totalChunks; i++) {
96
- const chunk = payloadStr.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
97
- socket.emit("job:result:stream:chunk", {
98
- jobId,
99
- index: i,
100
- chunk,
101
- });
102
- }
103
-
104
- socket.emit("job:result:stream:end", { jobId });
105
- };
106
-
107
- let activeJobs = 0;
108
- let activeConcurrency = concurrency;
109
-
110
- // Initialize WhatsApp Companion Device Engine
111
- const waHandler = getWhatsAppHandler({
112
- onStatusChange: (statusData) => {
113
- socket.emit("whatsapp:status_update", statusData);
114
- },
115
- onQrGenerated: ({ qrDataUrl }) => {
116
- socket.emit("whatsapp:qr_stream", { qrDataUrl });
117
- },
118
- });
119
-
120
- socket.on("connect", () => {
121
- logSuccess(`Connected to cluster! (Socket ID: ${socket.id})`);
122
- logInfo(`Ready to accept scraping and visual preview jobs (Concurrency: ${activeConcurrency}).\n`);
123
-
124
- // Announce current WhatsApp companion device status
125
- const waTelemetry = waHandler.getTelemetry();
126
- socket.emit("whatsapp:status_update", waTelemetry);
127
- });
128
-
129
- socket.on("worker:registered", (data) => {
130
- if (data?.concurrencyLimit && typeof data.concurrencyLimit === "number") {
131
- activeConcurrency = data.concurrencyLimit;
132
- logInfo(`[Cluster Config] Worker registered as '${data.name || data.workerId}'. Active concurrency: ${activeConcurrency} concurrent tabs.`);
133
- }
134
- });
135
-
136
- // Handle Admin Panel WhatsApp QR stream request
137
- socket.on("whatsapp:request_qr", async () => {
138
- logInfo("[WhatsApp] QR pairing requested via Admin Panel...");
139
- try {
140
- await waHandler.initialize(true);
141
- } catch (err) {
142
- logError(`[WhatsApp] Failed to initialize QR pairing: ${err.message}`);
143
- }
144
- });
145
-
146
- // Handle Admin Panel Remote Logout
147
- socket.on("whatsapp:remote_logout", async () => {
148
- logInfo("[WhatsApp] Remote unpair requested via Admin Panel...");
149
- try {
150
- await waHandler.logout();
151
- logSuccess("[WhatsApp] Companion device unpaired and local session cleared.");
152
- } catch (err) {
153
- logError(`[WhatsApp] Logout error: ${err.message}`);
154
- }
155
- });
156
-
157
- socket.on("connect_error", (err) => {
158
- if (err.message && err.message.includes("Authentication failed")) {
159
- logError(`Authentication Failed: ${err.message}`);
160
- logWarn("Please verify your worker key or run with '--reset' to enter a new key.");
161
- } else {
162
- logWarn(`Connection failed (${err.message}). Retrying in a few seconds...`);
163
- }
164
- });
165
-
166
- socket.on("disconnect", (reason) => {
167
- logWarn(`Disconnected from cluster: ${reason}`);
168
- });
169
-
170
- // Handle incoming job dispatch from server
171
- socket.on("job:dispatch", async (job) => {
172
- const jobId = job.jobId;
173
- const jobType = job.jobType || job.type;
174
- const payload = job.payload || {};
175
- const targetUrl = jobType === "SEND_EMAIL"
176
- ? (payload.mailOptions?.to ? `to: ${payload.mailOptions.to}` : "email")
177
- : jobType.startsWith("WHATSAPP_")
178
- ? (payload.recipientPhone ? `WA: ${payload.recipientPhone}` : "whatsapp")
179
- : (payload.url || "(no url)");
180
-
181
- logJob(jobType, targetUrl);
182
- activeJobs++;
183
-
184
- const startTime = Date.now();
185
-
186
- try {
187
- let resultData = null;
188
-
189
- if (jobType === "SCRAPE_CHROMIUM") {
190
- resultData = await handleChromiumScrape(payload);
191
- } else if (jobType === "SCRAPE_HTTP") {
192
- resultData = await handleHttpScrape(payload);
193
- } else if (jobType === "PREVIEW_PAGE") {
194
- resultData = await handlePreviewPage(payload);
195
- } else if (jobType === "INSPECT_ELEMENT") {
196
- resultData = await handleInspectElement(payload);
197
- } else if (jobType === "SEND_EMAIL") {
198
- resultData = await handleSendEmail(payload);
199
- } else if (jobType === "AI_SMART_REPAIR") {
200
- resultData = await handleAiSmartRepair(payload, config);
201
- } else if (jobType === "WHATSAPP_SEND_MESSAGE") {
202
- resultData = await waHandler.sendMessage(payload);
203
- } else if (jobType === "WHATSAPP_SEND_DOCUMENT") {
204
- resultData = await waHandler.sendDocument(payload);
205
- } else if (jobType === "WHATSAPP_RECONCILE") {
206
- resultData = await waHandler.reconcileDelivery(payload.recipientPhone, payload.idempotencyKey);
207
- } else {
208
- throw new Error(`Unsupported job type: ${jobType}`);
209
- }
210
-
211
- const durationMs = Date.now() - startTime;
212
- logSuccess(`Completed ${jobType} for ${targetUrl} in ${durationMs}ms`);
213
-
214
- sendJobResult({
215
- jobId,
216
- success: true,
217
- data: resultData,
218
- durationMs,
219
- });
220
- } catch (err) {
221
- const durationMs = Date.now() - startTime;
222
- logError(`Failed ${jobType} for ${targetUrl}: ${err.message}`);
223
-
224
- sendJobResult({
225
- jobId,
226
- success: false,
227
- error: {
228
- message: err.message,
229
- code: err.code || "JOB_EXECUTION_ERROR",
230
- },
231
- durationMs,
232
- });
233
- } finally {
234
- activeJobs = Math.max(0, activeJobs - 1);
235
- }
236
- });
237
-
238
- // Handle cluster heartbeat ping with enhanced capacity & memory telemetry
239
- socket.on("ping", () => {
240
- const telemetry = waHandler.getTelemetry();
241
- socket.emit("pong", {
242
- activeJobs,
243
- memory: process.memoryUsage(),
244
- uptime: process.uptime(),
245
- freeSystemMemoryMb: telemetry.freeSystemMemoryMb,
246
- totalSystemMemoryMb: telemetry.totalSystemMemoryMb,
247
- processMemoryMb: telemetry.processMemoryMb,
248
- memoryPressure: telemetry.memoryPressure,
249
- whatsappStatus: telemetry.status,
250
- whatsappUser: telemetry.user,
251
- });
252
- });
253
-
254
- // Render Anti-Sleep Keep-Alive Ping
255
- let keepAliveTimer = null;
256
- let initialKeepAliveTimer = null;
257
-
258
- const clearKeepAliveTimers = () => {
259
- if (keepAliveTimer) {
260
- clearInterval(keepAliveTimer);
261
- keepAliveTimer = null;
262
- }
263
- if (initialKeepAliveTimer) {
264
- clearTimeout(initialKeepAliveTimer);
265
- initialKeepAliveTimer = null;
266
- }
267
- };
268
-
269
- const pingKeepAlive = async () => {
270
- try {
271
- // Try the dedicated keep-alive endpoint first
272
- const endpoint = `${serverUrl}/api/workers/keep-alive`;
273
- const res = await axios.get(endpoint, {
274
- timeout: 15000,
275
- headers: {
276
- "x-worker-key": workerKey || "",
277
- "user-agent": "AppsManagerEdgeWorker/1.1.0",
278
- },
279
- });
280
- logInfo(`[Keep-Alive] Server awake ping OK (${res.status}) - Render instance active`);
281
- } catch (err) {
282
- // Fallback to /health
283
- try {
284
- const fallbackUrl = `${serverUrl}/health`;
285
- await axios.get(fallbackUrl, {
286
- timeout: 10000,
287
- headers: {
288
- "user-agent": "AppsManagerEdgeWorker/1.1.0",
289
- },
290
- });
291
- logInfo(`[Keep-Alive] Fallback health ping OK - Render instance active`);
292
- } catch (fallbackErr) {
293
- logWarn(`[Keep-Alive] Server awake ping warning (${err.message}) - will retry next cycle`);
294
- }
295
- }
296
- };
297
-
298
- const startKeepAlive = () => {
299
- if (keepAliveMinutes <= 0) return;
300
- clearKeepAliveTimers();
301
-
302
- // Send initial ping 15s after startup to warm up/verify
303
- initialKeepAliveTimer = setTimeout(pingKeepAlive, 15000);
304
- if (initialKeepAliveTimer.unref) initialKeepAliveTimer.unref();
305
-
306
- const intervalMs = Math.max(1, keepAliveMinutes) * 60 * 1000;
307
- keepAliveTimer = setInterval(pingKeepAlive, intervalMs);
308
- if (keepAliveTimer.unref) keepAliveTimer.unref();
309
- };
310
-
311
- if (keepAliveMinutes > 0) {
312
- startKeepAlive();
313
- }
314
-
315
- // Clean up keep-alive timers on socket disconnect
316
- socket.on("disconnect", (reason) => {
317
- logWarn(`Disconnected from cluster: ${reason}`);
318
- clearKeepAliveTimers();
319
- });
320
-
321
- socket.on("connect", () => {
322
- if (keepAliveMinutes > 0 && !keepAliveTimer) {
323
- startKeepAlive();
324
- }
325
- });
326
-
327
- // Provide programmatic close for automated test harnesses
328
- socket.closeClient = async () => {
329
- clearKeepAliveTimers();
330
- if (isTermux) {
331
- try {
332
- execSync("termux-wake-unlock", { stdio: "ignore" });
333
- } catch {}
334
- }
335
- socket.disconnect();
336
- await localBrowserManager.close();
337
- };
338
-
339
- // Graceful shutdown handling
340
- const shutdown = async () => {
341
- logInfo("\nGracefully shutting down edge worker...");
342
- await socket.closeClient();
343
- logSuccess("Edge worker stopped cleanly.");
344
- process.exit(0);
345
- };
346
-
347
- process.on("SIGINT", shutdown);
348
- process.on("SIGTERM", shutdown);
349
-
350
- return socket;
351
- }
1
+ import { io } from "socket.io-client";
2
+ import os from "os";
3
+ import path from "path";
4
+ import axios from "axios";
5
+ import { execSync } from "child_process";
6
+ import localBrowserManager from "./browser.js";
7
+ import { handleChromiumScrape, handleHttpScrape } from "./handlers/scrapeHandler.js";
8
+ import { handlePreviewPage, handleInspectElement } from "./handlers/previewHandler.js";
9
+ import { handleSendEmail } from "./handlers/mailHandler.js";
10
+ import { handleAiSmartRepair } from "./handlers/aiRepairHandler.js";
11
+ import fs from "fs";
12
+ import { getWhatsAppHandler } from "./handlers/whatsappHandler.js";
13
+ import { printBanner, logInfo, logSuccess, logWarn, logError, logJob } from "./banner.js";
14
+
15
+ let workerPkgVersion = "1.1.4";
16
+ try {
17
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
18
+ if (pkg?.version) workerPkgVersion = pkg.version;
19
+ } catch (_) {}
20
+
21
+ export async function startWorkerClient(config) {
22
+ const { workerKey, serverUrl, concurrency, workerName, keepAliveMinutes = 7 } = config;
23
+ const isTermux = localBrowserManager.isTermux();
24
+
25
+ printBanner({
26
+ workerName,
27
+ serverUrl,
28
+ concurrency,
29
+ platform: `${os.platform()} ${os.arch()}`,
30
+ isTermux,
31
+ keepAliveMinutes,
32
+ });
33
+
34
+ if (isTermux) {
35
+ try {
36
+ execSync("termux-wake-lock", { stdio: "ignore" });
37
+ logSuccess("[Android Termux] Wake lock acquired to keep CPU awake in background.");
38
+ } catch {
39
+ // termux-tools might not be installed yet
40
+ }
41
+ }
42
+
43
+ logInfo("Checking Chromium browser availability...");
44
+ let chromiumExecutable = null;
45
+ try {
46
+ chromiumExecutable = await localBrowserManager.ensureChromiumExecutable();
47
+ logSuccess(`Chromium ready: ${chromiumExecutable}`);
48
+ } catch (browserErr) {
49
+ logWarn(`Chromium initialization warning: ${browserErr.message}`);
50
+ }
51
+
52
+ logInfo("Connecting to Apps Manager cluster...");
53
+
54
+ const socket = io(`${serverUrl}/workers`, {
55
+ transports: ["websocket", "polling"],
56
+ reconnection: true,
57
+ reconnectionAttempts: Infinity,
58
+ reconnectionDelay: 1000,
59
+ reconnectionDelayMax: 5000,
60
+ auth: {
61
+ workerKey,
62
+ metadata: {
63
+ hostname: workerName || os.hostname(),
64
+ platform: os.platform(),
65
+ arch: os.arch(),
66
+ osVersion: os.release(),
67
+ nodeVersion: process.version,
68
+ chromiumVersion: chromiumExecutable ? path.basename(chromiumExecutable) : "",
69
+ chromiumPath: chromiumExecutable || "",
70
+ isTermux,
71
+ },
72
+ },
73
+ });
74
+
75
+ const CHUNK_SIZE = 256 * 1024; // 256 KB chunks to prevent any WebSocket frame buffer overflow
76
+
77
+ const sendJobResult = (payload) => {
78
+ let payloadStr = "";
79
+ try {
80
+ payloadStr = JSON.stringify(payload);
81
+ } catch {
82
+ socket.emit("job:result", payload);
83
+ return;
84
+ }
85
+
86
+ // Small payload: send directly
87
+ if (payloadStr.length <= CHUNK_SIZE) {
88
+ socket.emit("job:result", payload);
89
+ return;
90
+ }
91
+
92
+ // Large payload (e.g. screenshot or large scrape): stream in chunks
93
+ const totalChunks = Math.ceil(payloadStr.length / CHUNK_SIZE);
94
+ const jobId = payload.jobId;
95
+
96
+ socket.emit("job:result:stream:start", {
97
+ jobId,
98
+ totalChunks,
99
+ totalLength: payloadStr.length,
100
+ });
101
+
102
+ for (let i = 0; i < totalChunks; i++) {
103
+ const chunk = payloadStr.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
104
+ socket.emit("job:result:stream:chunk", {
105
+ jobId,
106
+ index: i,
107
+ chunk,
108
+ });
109
+ }
110
+
111
+ socket.emit("job:result:stream:end", { jobId });
112
+ };
113
+
114
+ let activeJobs = 0;
115
+ let activeConcurrency = concurrency;
116
+
117
+ // Initialize WhatsApp Companion Device Engine
118
+ const waHandler = getWhatsAppHandler({
119
+ onStatusChange: (statusData) => {
120
+ socket.emit("whatsapp:status_update", statusData);
121
+ },
122
+ onQrGenerated: ({ qrDataUrl }) => {
123
+ socket.emit("whatsapp:qr_stream", { qrDataUrl });
124
+ },
125
+ onSessionBatchUpdate: (batchData) => {
126
+ socket.emit("whatsapp:session_batch_update", batchData);
127
+ },
128
+ onClearSession: () => {
129
+ socket.emit("whatsapp:clear_session");
130
+ },
131
+ });
132
+
133
+ socket.on("connect", () => {
134
+ logSuccess(`Connected to cluster! (Socket ID: ${socket.id})`);
135
+ logInfo(`Ready to accept scraping and visual preview jobs (Concurrency: ${activeConcurrency}).\n`);
136
+
137
+ // If Baileys is already CONNECTED in memory (e.g. quick network reconnection), announce immediately
138
+ if (waHandler.status === "CONNECTED" || waHandler.status === "STANDBY") {
139
+ const waTelemetry = waHandler.getTelemetry();
140
+ socket.emit("whatsapp:status_update", waTelemetry);
141
+ }
142
+ // If not connected yet, wait for whatsapp:session_sync from cluster server before announcing status
143
+ });
144
+
145
+ // Handle MongoDB WhatsApp Session restore from cluster server
146
+ socket.on("whatsapp:session_sync", async ({ hasSession, files }) => {
147
+ if (hasSession && files && Object.keys(files).length > 0) {
148
+ logInfo(`[WhatsApp] Cluster provided ${Object.keys(files).length} session credentials from MongoDB. Restoring session...`);
149
+ waHandler.hydrateFromSessionFiles(files);
150
+ try {
151
+ await waHandler.initialize(false);
152
+ logSuccess("[WhatsApp] Auto-connected successfully using restored MongoDB session!");
153
+ } catch (err) {
154
+ logWarn(`[WhatsApp] Auto-connect with MongoDB credentials failed: ${err.message}`);
155
+ socket.emit("whatsapp:status_update", waHandler.getTelemetry());
156
+ }
157
+ } else if (waHandler.hasCredentials()) {
158
+ logInfo("[WhatsApp] Found local credentials on disk not yet in MongoDB. Syncing to cluster database...");
159
+ const localFiles = waHandler.exportAllSessionFiles();
160
+ socket.emit("whatsapp:save_session_batch", { files: localFiles });
161
+ try {
162
+ await waHandler.initialize(false);
163
+ logSuccess("[WhatsApp] Connected using local credentials and backed up to cluster database.");
164
+ } catch (err) {
165
+ logWarn(`[WhatsApp] Auto-connect with local credentials failed: ${err.message}`);
166
+ socket.emit("whatsapp:status_update", waHandler.getTelemetry());
167
+ }
168
+ } else {
169
+ // No stored credentials on cluster or local disk - report clean disconnected status
170
+ socket.emit("whatsapp:status_update", waHandler.getTelemetry());
171
+ }
172
+ });
173
+
174
+ socket.on("worker:registered", (data) => {
175
+ if (data?.concurrencyLimit && typeof data.concurrencyLimit === "number") {
176
+ activeConcurrency = data.concurrencyLimit;
177
+ logInfo(`[Cluster Config] Worker registered as '${data.name || data.workerId}'. Active concurrency: ${activeConcurrency} concurrent tabs.`);
178
+ }
179
+ });
180
+
181
+ // Handle Admin Panel WhatsApp QR stream request
182
+ socket.on("whatsapp:request_qr", async () => {
183
+ logInfo("[WhatsApp] QR pairing requested via Admin Panel...");
184
+ try {
185
+ await waHandler.initialize(true);
186
+ } catch (err) {
187
+ logError(`[WhatsApp] Failed to initialize QR pairing: ${err.message}`);
188
+ }
189
+ });
190
+
191
+ // Handle Admin Panel Remote Logout
192
+ socket.on("whatsapp:remote_logout", async () => {
193
+ logInfo("[WhatsApp] Remote unpair requested via Admin Panel...");
194
+ try {
195
+ await waHandler.logout();
196
+ logSuccess("[WhatsApp] Companion device unpaired and local session cleared.");
197
+ } catch (err) {
198
+ logError(`[WhatsApp] Logout error: ${err.message}`);
199
+ }
200
+ });
201
+
202
+ socket.on("connect_error", (err) => {
203
+ if (err.message && err.message.includes("Authentication failed")) {
204
+ logError(`Authentication Failed: ${err.message}`);
205
+ logWarn("Please verify your worker key or run with '--reset' to enter a new key.");
206
+ } else {
207
+ logWarn(`Connection failed (${err.message}). Retrying in a few seconds...`);
208
+ }
209
+ });
210
+
211
+ socket.on("disconnect", (reason) => {
212
+ logWarn(`Disconnected from cluster: ${reason}`);
213
+ });
214
+
215
+ // Handle incoming job dispatch from server
216
+ socket.on("job:dispatch", async (job) => {
217
+ const jobId = job.jobId;
218
+ const jobType = job.jobType || job.type;
219
+ const payload = job.payload || {};
220
+ const targetUrl = jobType === "SEND_EMAIL"
221
+ ? (payload.mailOptions?.to ? `to: ${payload.mailOptions.to}` : "email")
222
+ : jobType.startsWith("WHATSAPP_")
223
+ ? (payload.recipientPhone ? `WA: ${payload.recipientPhone}` : "whatsapp")
224
+ : (payload.url || "(no url)");
225
+
226
+ logJob(jobType, targetUrl);
227
+ activeJobs++;
228
+
229
+ const startTime = Date.now();
230
+
231
+ try {
232
+ let resultData = null;
233
+
234
+ if (jobType === "SCRAPE_CHROMIUM") {
235
+ resultData = await handleChromiumScrape(payload);
236
+ } else if (jobType === "SCRAPE_HTTP") {
237
+ resultData = await handleHttpScrape(payload);
238
+ } else if (jobType === "PREVIEW_PAGE") {
239
+ resultData = await handlePreviewPage(payload);
240
+ } else if (jobType === "INSPECT_ELEMENT") {
241
+ resultData = await handleInspectElement(payload);
242
+ } else if (jobType === "SEND_EMAIL") {
243
+ resultData = await handleSendEmail(payload);
244
+ } else if (jobType === "AI_SMART_REPAIR") {
245
+ resultData = await handleAiSmartRepair(payload, config);
246
+ } else if (jobType === "WHATSAPP_SEND_MESSAGE") {
247
+ resultData = await waHandler.sendMessage(payload);
248
+ } else if (jobType === "WHATSAPP_SEND_DOCUMENT") {
249
+ resultData = await waHandler.sendDocument(payload);
250
+ } else if (jobType === "WHATSAPP_RECONCILE") {
251
+ resultData = await waHandler.reconcileDelivery(payload.recipientPhone, payload.idempotencyKey);
252
+ } else {
253
+ throw new Error(`Unsupported job type: ${jobType}`);
254
+ }
255
+
256
+ const durationMs = Date.now() - startTime;
257
+ logSuccess(`Completed ${jobType} for ${targetUrl} in ${durationMs}ms`);
258
+
259
+ sendJobResult({
260
+ jobId,
261
+ success: true,
262
+ data: resultData,
263
+ durationMs,
264
+ });
265
+ } catch (err) {
266
+ const durationMs = Date.now() - startTime;
267
+ logError(`Failed ${jobType} for ${targetUrl}: ${err.message}`);
268
+
269
+ sendJobResult({
270
+ jobId,
271
+ success: false,
272
+ error: {
273
+ message: err.message,
274
+ code: err.code || "JOB_EXECUTION_ERROR",
275
+ },
276
+ durationMs,
277
+ });
278
+ } finally {
279
+ activeJobs = Math.max(0, activeJobs - 1);
280
+ }
281
+ });
282
+
283
+ // Handle cluster heartbeat ping with enhanced capacity & memory telemetry
284
+ socket.on("ping", () => {
285
+ const telemetry = waHandler.getTelemetry();
286
+ socket.emit("pong", {
287
+ activeJobs,
288
+ memory: process.memoryUsage(),
289
+ uptime: process.uptime(),
290
+ freeSystemMemoryMb: telemetry.freeSystemMemoryMb,
291
+ totalSystemMemoryMb: telemetry.totalSystemMemoryMb,
292
+ processMemoryMb: telemetry.processMemoryMb,
293
+ memoryPressure: telemetry.memoryPressure,
294
+ whatsappStatus: telemetry.status,
295
+ whatsappUser: telemetry.user,
296
+ });
297
+ });
298
+
299
+ // Render Anti-Sleep Keep-Alive Ping
300
+ let keepAliveTimer = null;
301
+ let initialKeepAliveTimer = null;
302
+
303
+ const clearKeepAliveTimers = () => {
304
+ if (keepAliveTimer) {
305
+ clearInterval(keepAliveTimer);
306
+ keepAliveTimer = null;
307
+ }
308
+ if (initialKeepAliveTimer) {
309
+ clearTimeout(initialKeepAliveTimer);
310
+ initialKeepAliveTimer = null;
311
+ }
312
+ };
313
+
314
+ const pingKeepAlive = async () => {
315
+ try {
316
+ // Try the dedicated keep-alive endpoint first
317
+ const endpoint = `${serverUrl}/api/workers/keep-alive`;
318
+ const res = await axios.get(endpoint, {
319
+ timeout: 15000,
320
+ headers: {
321
+ "x-worker-key": workerKey || "",
322
+ "user-agent": `AppsManagerEdgeWorker/${workerPkgVersion}`,
323
+ },
324
+ });
325
+ logInfo(`[Keep-Alive] Server awake ping OK (${res.status}) - Render instance active`);
326
+ } catch (err) {
327
+ // Fallback to /health
328
+ try {
329
+ const fallbackUrl = `${serverUrl}/health`;
330
+ await axios.get(fallbackUrl, {
331
+ timeout: 10000,
332
+ headers: {
333
+ "user-agent": `AppsManagerEdgeWorker/${workerPkgVersion}`,
334
+ },
335
+ });
336
+ logInfo(`[Keep-Alive] Fallback health ping OK - Render instance active`);
337
+ } catch (fallbackErr) {
338
+ logWarn(`[Keep-Alive] Server awake ping warning (${err.message}) - will retry next cycle`);
339
+ }
340
+ }
341
+ };
342
+
343
+ const startKeepAlive = () => {
344
+ if (keepAliveMinutes <= 0) return;
345
+ clearKeepAliveTimers();
346
+
347
+ // Send initial ping 15s after startup to warm up/verify
348
+ initialKeepAliveTimer = setTimeout(pingKeepAlive, 15000);
349
+ if (initialKeepAliveTimer.unref) initialKeepAliveTimer.unref();
350
+
351
+ const intervalMs = Math.max(1, keepAliveMinutes) * 60 * 1000;
352
+ keepAliveTimer = setInterval(pingKeepAlive, intervalMs);
353
+ if (keepAliveTimer.unref) keepAliveTimer.unref();
354
+ };
355
+
356
+ if (keepAliveMinutes > 0) {
357
+ startKeepAlive();
358
+ }
359
+
360
+ // Clean up keep-alive timers on socket disconnect
361
+ socket.on("disconnect", (reason) => {
362
+ logWarn(`Disconnected from cluster: ${reason}`);
363
+ clearKeepAliveTimers();
364
+ });
365
+
366
+ socket.on("connect", () => {
367
+ if (keepAliveMinutes > 0 && !keepAliveTimer) {
368
+ startKeepAlive();
369
+ }
370
+ });
371
+
372
+ // Provide programmatic close for automated test harnesses
373
+ socket.closeClient = async () => {
374
+ clearKeepAliveTimers();
375
+ if (isTermux) {
376
+ try {
377
+ execSync("termux-wake-unlock", { stdio: "ignore" });
378
+ } catch {}
379
+ }
380
+ socket.disconnect();
381
+ await localBrowserManager.close();
382
+ };
383
+
384
+ // Graceful shutdown handling
385
+ const shutdown = async () => {
386
+ logInfo("\nGracefully shutting down edge worker...");
387
+ await socket.closeClient();
388
+ logSuccess("Edge worker stopped cleanly.");
389
+ process.exit(0);
390
+ };
391
+
392
+ process.on("SIGINT", shutdown);
393
+ process.on("SIGTERM", shutdown);
394
+
395
+ return socket;
396
+ }