service-keepalive 1.0.0
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/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +420 -0
- package/dist/bin.js +1129 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.cjs +1142 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +375 -0
- package/dist/index.d.ts +375 -0
- package/dist/index.js +1121 -0
- package/dist/index.js.map +1 -0
- package/package.json +83 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
|
|
6
|
+
// src/keepalive.ts
|
|
7
|
+
|
|
8
|
+
// src/utils/duration.ts
|
|
9
|
+
var TIME_UNITS = {
|
|
10
|
+
ms: 1,
|
|
11
|
+
millisecond: 1,
|
|
12
|
+
milliseconds: 1,
|
|
13
|
+
s: 1e3,
|
|
14
|
+
sec: 1e3,
|
|
15
|
+
second: 1e3,
|
|
16
|
+
seconds: 1e3,
|
|
17
|
+
m: 60 * 1e3,
|
|
18
|
+
min: 60 * 1e3,
|
|
19
|
+
minute: 60 * 1e3,
|
|
20
|
+
minutes: 60 * 1e3,
|
|
21
|
+
h: 60 * 60 * 1e3,
|
|
22
|
+
hr: 60 * 60 * 1e3,
|
|
23
|
+
hour: 60 * 60 * 1e3,
|
|
24
|
+
hours: 60 * 60 * 1e3,
|
|
25
|
+
d: 24 * 60 * 60 * 1e3,
|
|
26
|
+
day: 24 * 60 * 60 * 1e3,
|
|
27
|
+
days: 24 * 60 * 60 * 1e3
|
|
28
|
+
};
|
|
29
|
+
var DURATION_REGEX = /^(\s*(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*)+$/;
|
|
30
|
+
var DURATION_PART_REGEX = /(\d+(?:\.\d+)?)\s*([a-zA-Z]+)/g;
|
|
31
|
+
function parseDuration(value, fieldName = "Duration") {
|
|
32
|
+
if (value === null || value === void 0) {
|
|
33
|
+
throw new TypeError(`${fieldName} must be provided as a string or number.`);
|
|
34
|
+
}
|
|
35
|
+
if (typeof value === "number") {
|
|
36
|
+
if (!Number.isFinite(value) || Number.isNaN(value)) {
|
|
37
|
+
throw new RangeError(`${fieldName} must be a finite number, received ${value}.`);
|
|
38
|
+
}
|
|
39
|
+
if (value <= 0) {
|
|
40
|
+
throw new RangeError(`${fieldName} must be greater than 0, received ${value}.`);
|
|
41
|
+
}
|
|
42
|
+
return Math.round(value);
|
|
43
|
+
}
|
|
44
|
+
if (typeof value !== "string") {
|
|
45
|
+
throw new TypeError(`${fieldName} must be a string or number, received ${typeof value}.`);
|
|
46
|
+
}
|
|
47
|
+
const trimmed = value.trim();
|
|
48
|
+
if (trimmed.length === 0) {
|
|
49
|
+
throw new RangeError(`${fieldName} cannot be an empty string.`);
|
|
50
|
+
}
|
|
51
|
+
if (/^\d+$/.test(trimmed)) {
|
|
52
|
+
const parsedNumber = Number(trimmed);
|
|
53
|
+
if (parsedNumber <= 0) {
|
|
54
|
+
throw new RangeError(`${fieldName} must be greater than 0.`);
|
|
55
|
+
}
|
|
56
|
+
return parsedNumber;
|
|
57
|
+
}
|
|
58
|
+
if (!DURATION_REGEX.test(trimmed)) {
|
|
59
|
+
throw new RangeError(
|
|
60
|
+
`Invalid ${fieldName} format: "${value}". Expected formats like "10s", "1m", "5m", "1h", "500ms".`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
let totalMs = 0;
|
|
64
|
+
let match;
|
|
65
|
+
DURATION_PART_REGEX.lastIndex = 0;
|
|
66
|
+
while ((match = DURATION_PART_REGEX.exec(trimmed)) !== null) {
|
|
67
|
+
const amountStr = match[1];
|
|
68
|
+
const unitStr = match[2];
|
|
69
|
+
if (!amountStr || !unitStr) {
|
|
70
|
+
throw new RangeError(`Failed parsing ${fieldName} segment "${match[0]}".`);
|
|
71
|
+
}
|
|
72
|
+
const amount = parseFloat(amountStr);
|
|
73
|
+
const unit = unitStr.toLowerCase();
|
|
74
|
+
const multiplier = TIME_UNITS[unit];
|
|
75
|
+
if (!multiplier) {
|
|
76
|
+
throw new RangeError(
|
|
77
|
+
`Unknown time unit "${unit}" in ${fieldName}. Supported units: ms, s, m, h, d.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (amount < 0 || !Number.isFinite(amount)) {
|
|
81
|
+
throw new RangeError(`Invalid amount "${amount}" for unit "${unit}" in ${fieldName}.`);
|
|
82
|
+
}
|
|
83
|
+
totalMs += amount * multiplier;
|
|
84
|
+
}
|
|
85
|
+
const rounded = Math.round(totalMs);
|
|
86
|
+
if (rounded <= 0) {
|
|
87
|
+
throw new RangeError(`${fieldName} must evaluate to greater than 0ms.`);
|
|
88
|
+
}
|
|
89
|
+
return rounded;
|
|
90
|
+
}
|
|
91
|
+
function formatDuration(ms) {
|
|
92
|
+
if (ms < 1e3) {
|
|
93
|
+
return `${Math.round(ms)}ms`;
|
|
94
|
+
}
|
|
95
|
+
const seconds = Math.floor(ms / 1e3 % 60);
|
|
96
|
+
const minutes = Math.floor(ms / (1e3 * 60) % 60);
|
|
97
|
+
const hours = Math.floor(ms / (1e3 * 60 * 60) % 24);
|
|
98
|
+
const days = Math.floor(ms / (1e3 * 60 * 60 * 24));
|
|
99
|
+
const parts = [];
|
|
100
|
+
if (days > 0) parts.push(`${days}d`);
|
|
101
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
102
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
103
|
+
if (seconds > 0) parts.push(`${seconds}s`);
|
|
104
|
+
return parts.length > 0 ? parts.join(" ") : `${ms}ms`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/utils/redact.ts
|
|
108
|
+
var SENSITIVE_HEADER_PATTERNS = [
|
|
109
|
+
/authorization/i,
|
|
110
|
+
/cookie/i,
|
|
111
|
+
/token/i,
|
|
112
|
+
/secret/i,
|
|
113
|
+
/password/i,
|
|
114
|
+
/api[_-]?key/i,
|
|
115
|
+
/auth/i,
|
|
116
|
+
/session/i,
|
|
117
|
+
/credential/i,
|
|
118
|
+
/private[_-]?key/i
|
|
119
|
+
];
|
|
120
|
+
var SENSITIVE_QUERY_PARAMS = [
|
|
121
|
+
"token",
|
|
122
|
+
"key",
|
|
123
|
+
"api_key",
|
|
124
|
+
"apikey",
|
|
125
|
+
"secret",
|
|
126
|
+
"password",
|
|
127
|
+
"passwd",
|
|
128
|
+
"auth",
|
|
129
|
+
"access_token",
|
|
130
|
+
"bearer",
|
|
131
|
+
"session"
|
|
132
|
+
];
|
|
133
|
+
function redactHeaders(headers) {
|
|
134
|
+
if (!headers) return {};
|
|
135
|
+
const sanitized = {};
|
|
136
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
137
|
+
const isSensitive = SENSITIVE_HEADER_PATTERNS.some((pattern) => pattern.test(key));
|
|
138
|
+
if (isSensitive) {
|
|
139
|
+
sanitized[key] = "[REDACTED]";
|
|
140
|
+
} else {
|
|
141
|
+
sanitized[key] = value;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return sanitized;
|
|
145
|
+
}
|
|
146
|
+
function redactUrl(urlStr) {
|
|
147
|
+
try {
|
|
148
|
+
const url = new URL(urlStr);
|
|
149
|
+
let modified = false;
|
|
150
|
+
for (const param of SENSITIVE_QUERY_PARAMS) {
|
|
151
|
+
if (url.searchParams.has(param)) {
|
|
152
|
+
url.searchParams.set(param, "[REDACTED]");
|
|
153
|
+
modified = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return modified ? url.toString() : urlStr;
|
|
157
|
+
} catch {
|
|
158
|
+
return urlStr;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/logger.ts
|
|
163
|
+
var COLORS = {
|
|
164
|
+
reset: "\x1B[0m",
|
|
165
|
+
dim: "\x1B[2m",
|
|
166
|
+
bold: "\x1B[1m",
|
|
167
|
+
green: "\x1B[32m",
|
|
168
|
+
red: "\x1B[31m",
|
|
169
|
+
yellow: "\x1B[33m",
|
|
170
|
+
blue: "\x1B[34m",
|
|
171
|
+
cyan: "\x1B[36m",
|
|
172
|
+
gray: "\x1B[90m"
|
|
173
|
+
};
|
|
174
|
+
function supportsColor() {
|
|
175
|
+
if (typeof process === "undefined") return false;
|
|
176
|
+
if ("NO_COLOR" in process.env && process.env["NO_COLOR"] !== "") return false;
|
|
177
|
+
if ("FORCE_COLOR" in process.env && process.env["FORCE_COLOR"] !== "0") return true;
|
|
178
|
+
return Boolean(process.stdout && process.stdout.isTTY);
|
|
179
|
+
}
|
|
180
|
+
var isColorSupported = supportsColor();
|
|
181
|
+
function colorize(text, color) {
|
|
182
|
+
if (!isColorSupported) return text;
|
|
183
|
+
return `${COLORS[color]}${text}${COLORS.reset}`;
|
|
184
|
+
}
|
|
185
|
+
function formatTimestamp(date = /* @__PURE__ */ new Date()) {
|
|
186
|
+
const pad = (n) => n.toString().padStart(2, "0");
|
|
187
|
+
const h = pad(date.getHours());
|
|
188
|
+
const m = pad(date.getMinutes());
|
|
189
|
+
const s = pad(date.getSeconds());
|
|
190
|
+
return `${h}:${m}:${s}`;
|
|
191
|
+
}
|
|
192
|
+
var Logger = class {
|
|
193
|
+
logLevel;
|
|
194
|
+
prefix;
|
|
195
|
+
constructor(options = {}) {
|
|
196
|
+
this.logLevel = options.logLevel ?? "normal";
|
|
197
|
+
this.prefix = options.prefix;
|
|
198
|
+
}
|
|
199
|
+
setLevel(level) {
|
|
200
|
+
this.logLevel = level;
|
|
201
|
+
}
|
|
202
|
+
setPrefix(prefix) {
|
|
203
|
+
this.prefix = prefix;
|
|
204
|
+
}
|
|
205
|
+
shouldLog(level) {
|
|
206
|
+
if (this.logLevel === "quiet") {
|
|
207
|
+
return level === "quiet";
|
|
208
|
+
}
|
|
209
|
+
if (this.logLevel === "normal") {
|
|
210
|
+
return level !== "verbose";
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
formatLine(content, timeColor = "gray") {
|
|
215
|
+
const timeStr = colorize(`[${formatTimestamp()}]`, timeColor);
|
|
216
|
+
const prefixStr = this.prefix ? colorize(`[${this.prefix}] `, "cyan") : "";
|
|
217
|
+
return `${timeStr} ${prefixStr}${content}`;
|
|
218
|
+
}
|
|
219
|
+
info(message, ...args) {
|
|
220
|
+
if (!this.shouldLog("normal")) return;
|
|
221
|
+
const formatted = this.formatLine(message);
|
|
222
|
+
if (args.length > 0) {
|
|
223
|
+
console.log(formatted, ...args);
|
|
224
|
+
} else {
|
|
225
|
+
console.log(formatted);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
success(message, ...args) {
|
|
229
|
+
if (!this.shouldLog("normal")) return;
|
|
230
|
+
const symbol = colorize("\u2713", "green");
|
|
231
|
+
const formatted = this.formatLine(`${symbol} ${colorize(message, "green")}`);
|
|
232
|
+
if (args.length > 0) {
|
|
233
|
+
console.log(formatted, ...args);
|
|
234
|
+
} else {
|
|
235
|
+
console.log(formatted);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
warn(message, ...args) {
|
|
239
|
+
if (!this.shouldLog("normal")) return;
|
|
240
|
+
const symbol = colorize("\u26A0", "yellow");
|
|
241
|
+
const formatted = this.formatLine(`${symbol} ${colorize(message, "yellow")}`);
|
|
242
|
+
if (args.length > 0) {
|
|
243
|
+
console.warn(formatted, ...args);
|
|
244
|
+
} else {
|
|
245
|
+
console.warn(formatted);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
error(message, ...args) {
|
|
249
|
+
const symbol = colorize("\u2717", "red");
|
|
250
|
+
const formatted = this.formatLine(`${symbol} ${colorize(message, "red")}`, "red");
|
|
251
|
+
if (args.length > 0) {
|
|
252
|
+
console.error(formatted, ...args);
|
|
253
|
+
} else {
|
|
254
|
+
console.error(formatted);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
retry(message, ...args) {
|
|
258
|
+
if (!this.shouldLog("normal")) return;
|
|
259
|
+
const symbol = colorize("\u21BB", "yellow");
|
|
260
|
+
const formatted = this.formatLine(`${symbol} ${colorize(message, "yellow")}`);
|
|
261
|
+
if (args.length > 0) {
|
|
262
|
+
console.log(formatted, ...args);
|
|
263
|
+
} else {
|
|
264
|
+
console.log(formatted);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
debug(message, ...args) {
|
|
268
|
+
if (!this.shouldLog("verbose")) return;
|
|
269
|
+
const label = colorize("[DEBUG]", "dim");
|
|
270
|
+
const formatted = this.formatLine(`${label} ${colorize(message, "dim")}`);
|
|
271
|
+
if (args.length > 0) {
|
|
272
|
+
console.debug(formatted, ...args);
|
|
273
|
+
} else {
|
|
274
|
+
console.debug(formatted);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// src/http-client.ts
|
|
280
|
+
function isStatusSuccessful(status, expected) {
|
|
281
|
+
if (typeof expected === "function") {
|
|
282
|
+
return expected(status);
|
|
283
|
+
}
|
|
284
|
+
if (Array.isArray(expected) && expected.length > 0) {
|
|
285
|
+
return expected.includes(status);
|
|
286
|
+
}
|
|
287
|
+
return status >= 200 && status < 300;
|
|
288
|
+
}
|
|
289
|
+
async function executePing(options) {
|
|
290
|
+
const {
|
|
291
|
+
serviceName,
|
|
292
|
+
url,
|
|
293
|
+
method = "GET",
|
|
294
|
+
headers = {},
|
|
295
|
+
body = null,
|
|
296
|
+
timeoutMs,
|
|
297
|
+
attempt = 1,
|
|
298
|
+
expectedStatusCodes,
|
|
299
|
+
externalSignal
|
|
300
|
+
} = options;
|
|
301
|
+
const controller = new AbortController();
|
|
302
|
+
let timeoutId = null;
|
|
303
|
+
let didTimeout = false;
|
|
304
|
+
const onExternalAbort = () => {
|
|
305
|
+
controller.abort(new Error("Operation cancelled"));
|
|
306
|
+
};
|
|
307
|
+
if (externalSignal) {
|
|
308
|
+
if (externalSignal.aborted) {
|
|
309
|
+
controller.abort(new Error("Operation cancelled"));
|
|
310
|
+
} else {
|
|
311
|
+
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
timeoutId = setTimeout(() => {
|
|
315
|
+
didTimeout = true;
|
|
316
|
+
controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));
|
|
317
|
+
}, timeoutMs);
|
|
318
|
+
const requestHeaders = {
|
|
319
|
+
"User-Agent": "service-keepalive/1.0.0",
|
|
320
|
+
Accept: "*/*",
|
|
321
|
+
...headers
|
|
322
|
+
};
|
|
323
|
+
const startTime = performance.now();
|
|
324
|
+
const timestamp = /* @__PURE__ */ new Date();
|
|
325
|
+
try {
|
|
326
|
+
const fetchOptions = {
|
|
327
|
+
method,
|
|
328
|
+
headers: requestHeaders,
|
|
329
|
+
signal: controller.signal
|
|
330
|
+
};
|
|
331
|
+
if (body && method !== "GET" && method !== "HEAD") {
|
|
332
|
+
fetchOptions.body = body;
|
|
333
|
+
}
|
|
334
|
+
const response = await fetch(url, fetchOptions);
|
|
335
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
336
|
+
const isOk = isStatusSuccessful(response.status, expectedStatusCodes);
|
|
337
|
+
try {
|
|
338
|
+
if (response.body) {
|
|
339
|
+
await response.text();
|
|
340
|
+
}
|
|
341
|
+
} catch {
|
|
342
|
+
}
|
|
343
|
+
const resHeaders = {};
|
|
344
|
+
response.headers.forEach((val, key) => {
|
|
345
|
+
resHeaders[key] = val;
|
|
346
|
+
});
|
|
347
|
+
return {
|
|
348
|
+
serviceName,
|
|
349
|
+
url,
|
|
350
|
+
method,
|
|
351
|
+
status: response.status,
|
|
352
|
+
statusText: response.statusText || `${response.status}`,
|
|
353
|
+
ok: isOk,
|
|
354
|
+
durationMs,
|
|
355
|
+
attempt,
|
|
356
|
+
timestamp,
|
|
357
|
+
headers: redactHeaders(resHeaders),
|
|
358
|
+
error: isOk ? void 0 : `HTTP ${response.status} ${response.statusText || "Error"}`
|
|
359
|
+
};
|
|
360
|
+
} catch (err) {
|
|
361
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
362
|
+
let errorMessage = "Unknown network error";
|
|
363
|
+
if (didTimeout) {
|
|
364
|
+
errorMessage = `Request timed out after ${timeoutMs}ms`;
|
|
365
|
+
} else if (err instanceof Error) {
|
|
366
|
+
if (err.name === "AbortError" || err.message.includes("aborted")) {
|
|
367
|
+
errorMessage = externalSignal?.aborted ? "Request cancelled by user" : `Request timed out after ${timeoutMs}ms`;
|
|
368
|
+
} else {
|
|
369
|
+
errorMessage = err.message;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
serviceName,
|
|
374
|
+
url,
|
|
375
|
+
method,
|
|
376
|
+
status: 0,
|
|
377
|
+
statusText: "Network Error",
|
|
378
|
+
ok: false,
|
|
379
|
+
durationMs,
|
|
380
|
+
attempt,
|
|
381
|
+
timestamp,
|
|
382
|
+
error: errorMessage
|
|
383
|
+
};
|
|
384
|
+
} finally {
|
|
385
|
+
if (timeoutId) {
|
|
386
|
+
clearTimeout(timeoutId);
|
|
387
|
+
}
|
|
388
|
+
if (externalSignal) {
|
|
389
|
+
externalSignal.removeEventListener("abort", onExternalAbort);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/retry.ts
|
|
395
|
+
function calculateBackoff(options) {
|
|
396
|
+
const {
|
|
397
|
+
attempt,
|
|
398
|
+
baseDelayMs,
|
|
399
|
+
strategy = "exponential",
|
|
400
|
+
maxDelayMs = 6e4,
|
|
401
|
+
jitter = true
|
|
402
|
+
} = options;
|
|
403
|
+
if (attempt <= 0) return 0;
|
|
404
|
+
let delay = baseDelayMs;
|
|
405
|
+
switch (strategy) {
|
|
406
|
+
case "exponential":
|
|
407
|
+
delay = baseDelayMs * Math.pow(2, attempt - 1);
|
|
408
|
+
break;
|
|
409
|
+
case "linear":
|
|
410
|
+
delay = baseDelayMs * attempt;
|
|
411
|
+
break;
|
|
412
|
+
case "fixed":
|
|
413
|
+
default:
|
|
414
|
+
delay = baseDelayMs;
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
delay = Math.min(delay, maxDelayMs);
|
|
418
|
+
if (jitter) {
|
|
419
|
+
const jitterFactor = 0.8 + Math.random() * 0.4;
|
|
420
|
+
delay = Math.round(delay * jitterFactor);
|
|
421
|
+
} else {
|
|
422
|
+
delay = Math.round(delay);
|
|
423
|
+
}
|
|
424
|
+
return Math.max(0, delay);
|
|
425
|
+
}
|
|
426
|
+
function sleep(ms, signal) {
|
|
427
|
+
return new Promise((resolve2, reject) => {
|
|
428
|
+
if (signal?.aborted) {
|
|
429
|
+
return reject(new Error("Operation aborted"));
|
|
430
|
+
}
|
|
431
|
+
let timer = null;
|
|
432
|
+
const onAbort = () => {
|
|
433
|
+
if (timer) clearTimeout(timer);
|
|
434
|
+
reject(new Error("Operation aborted"));
|
|
435
|
+
};
|
|
436
|
+
if (signal) {
|
|
437
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
438
|
+
}
|
|
439
|
+
timer = setTimeout(() => {
|
|
440
|
+
if (signal) {
|
|
441
|
+
signal.removeEventListener("abort", onAbort);
|
|
442
|
+
}
|
|
443
|
+
resolve2();
|
|
444
|
+
}, ms);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/keepalive.ts
|
|
449
|
+
var KeepAlive = class extends EventEmitter {
|
|
450
|
+
config;
|
|
451
|
+
running = false;
|
|
452
|
+
timer = null;
|
|
453
|
+
inFlightAbortController = null;
|
|
454
|
+
logger = null;
|
|
455
|
+
constructor(options) {
|
|
456
|
+
super();
|
|
457
|
+
this.config = this.resolveConfig(options);
|
|
458
|
+
}
|
|
459
|
+
resolveConfig(options) {
|
|
460
|
+
if (!options || typeof options !== "object") {
|
|
461
|
+
throw new TypeError("KeepAlive options must be an object.");
|
|
462
|
+
}
|
|
463
|
+
if (!options.url || typeof options.url !== "string") {
|
|
464
|
+
throw new TypeError("url is required and must be a string.");
|
|
465
|
+
}
|
|
466
|
+
let parsedUrl;
|
|
467
|
+
try {
|
|
468
|
+
parsedUrl = new URL(options.url);
|
|
469
|
+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
470
|
+
throw new Error("Protocol must be http or https");
|
|
471
|
+
}
|
|
472
|
+
} catch {
|
|
473
|
+
throw new Error(`Invalid URL: "${options.url}". Must be a valid http:// or https:// URL.`);
|
|
474
|
+
}
|
|
475
|
+
const name = options.name ?? parsedUrl.hostname;
|
|
476
|
+
const intervalMs = parseDuration(options.interval ?? "10m", "interval");
|
|
477
|
+
const timeoutMs = parseDuration(options.timeout ?? "30s", "timeout");
|
|
478
|
+
const method = options.method?.toUpperCase() ?? "GET";
|
|
479
|
+
const retries = Math.max(0, options.retries ?? 3);
|
|
480
|
+
const retryDelayMs = parseDuration(options.retryDelay ?? "5s", "retryDelay");
|
|
481
|
+
const maxRetryDelayMs = parseDuration(options.maxRetryDelay ?? "60s", "maxRetryDelay");
|
|
482
|
+
const retryStrategy = options.retryStrategy ?? "exponential";
|
|
483
|
+
const retryJitter = options.retryJitter ?? true;
|
|
484
|
+
const unrefTimer = options.unrefTimer ?? false;
|
|
485
|
+
const logLevel = options.logLevel ?? "normal";
|
|
486
|
+
let loggerInstance = null;
|
|
487
|
+
if (options.logger !== false) {
|
|
488
|
+
if (options.logger && typeof options.logger === "object") {
|
|
489
|
+
loggerInstance = options.logger;
|
|
490
|
+
} else {
|
|
491
|
+
loggerInstance = new Logger({ logLevel, prefix: name });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
this.logger = loggerInstance;
|
|
495
|
+
return {
|
|
496
|
+
name,
|
|
497
|
+
url: options.url,
|
|
498
|
+
intervalMs,
|
|
499
|
+
timeoutMs,
|
|
500
|
+
method,
|
|
501
|
+
headers: options.headers ?? {},
|
|
502
|
+
body: options.body ?? null,
|
|
503
|
+
retries,
|
|
504
|
+
retryDelayMs,
|
|
505
|
+
retryStrategy,
|
|
506
|
+
retryJitter,
|
|
507
|
+
maxRetryDelayMs,
|
|
508
|
+
expectedStatusCodes: options.expectedStatusCodes ?? ((s) => s >= 200 && s < 300),
|
|
509
|
+
logger: loggerInstance,
|
|
510
|
+
logLevel,
|
|
511
|
+
unrefTimer
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Returns whether the keep-alive scheduler is actively running.
|
|
516
|
+
*/
|
|
517
|
+
isRunning() {
|
|
518
|
+
return this.running;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Returns the resolved service configuration.
|
|
522
|
+
*/
|
|
523
|
+
getConfig() {
|
|
524
|
+
return Object.freeze({ ...this.config });
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Starts the keep-alive scheduler.
|
|
528
|
+
* Performs an immediate ping, then continues at the configured interval.
|
|
529
|
+
*/
|
|
530
|
+
start() {
|
|
531
|
+
if (this.running) {
|
|
532
|
+
this.logger?.debug(`Service [${this.config.name}] is already running.`);
|
|
533
|
+
return this;
|
|
534
|
+
}
|
|
535
|
+
this.running = true;
|
|
536
|
+
this.inFlightAbortController = new AbortController();
|
|
537
|
+
this.logger?.info(
|
|
538
|
+
`Starting keep-alive for ${redactUrl(this.config.url)} (interval: ${formatDuration(this.config.intervalMs)}, timeout: ${formatDuration(this.config.timeoutMs)})`
|
|
539
|
+
);
|
|
540
|
+
this.emit("start", this.config.name);
|
|
541
|
+
void this.runPingLoop();
|
|
542
|
+
return this;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Stops the keep-alive scheduler and aborts any active requests or wait timers.
|
|
546
|
+
*/
|
|
547
|
+
async stop() {
|
|
548
|
+
if (!this.running) return;
|
|
549
|
+
this.running = false;
|
|
550
|
+
if (this.timer) {
|
|
551
|
+
clearTimeout(this.timer);
|
|
552
|
+
this.timer = null;
|
|
553
|
+
}
|
|
554
|
+
if (this.inFlightAbortController) {
|
|
555
|
+
this.inFlightAbortController.abort();
|
|
556
|
+
this.inFlightAbortController = null;
|
|
557
|
+
}
|
|
558
|
+
this.logger?.info(`Stopped keep-alive for ${this.config.name}.`);
|
|
559
|
+
this.emit("stop", this.config.name);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Executes a single ping cycle with retries without starting the recurring scheduler.
|
|
563
|
+
*/
|
|
564
|
+
async pingOnce() {
|
|
565
|
+
const abortController = new AbortController();
|
|
566
|
+
return this.executePingWithRetries(abortController.signal);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Core scheduling loop. Executes a ping cycle, then schedules the next interval.
|
|
570
|
+
*/
|
|
571
|
+
async runPingLoop() {
|
|
572
|
+
if (!this.running) return;
|
|
573
|
+
try {
|
|
574
|
+
this.inFlightAbortController = new AbortController();
|
|
575
|
+
await this.executePingWithRetries(this.inFlightAbortController.signal);
|
|
576
|
+
} catch (err) {
|
|
577
|
+
if (this.running) {
|
|
578
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)), this.config.name);
|
|
579
|
+
}
|
|
580
|
+
} finally {
|
|
581
|
+
this.inFlightAbortController = null;
|
|
582
|
+
}
|
|
583
|
+
if (!this.running) return;
|
|
584
|
+
this.timer = setTimeout(() => {
|
|
585
|
+
void this.runPingLoop();
|
|
586
|
+
}, this.config.intervalMs);
|
|
587
|
+
if (this.config.unrefTimer && this.timer && typeof this.timer.unref === "function") {
|
|
588
|
+
this.timer.unref();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Executes the ping request, retrying on failure up to configured retry attempts.
|
|
593
|
+
*/
|
|
594
|
+
async executePingWithRetries(signal) {
|
|
595
|
+
const maxAttempts = 1 + this.config.retries;
|
|
596
|
+
let lastResult = null;
|
|
597
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
598
|
+
if (signal?.aborted) {
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
this.logger?.info(`Pinging ${redactUrl(this.config.url)}`);
|
|
602
|
+
this.emit("ping", {
|
|
603
|
+
name: this.config.name,
|
|
604
|
+
url: this.config.url,
|
|
605
|
+
attempt
|
|
606
|
+
});
|
|
607
|
+
const result = await executePing({
|
|
608
|
+
serviceName: this.config.name,
|
|
609
|
+
url: this.config.url,
|
|
610
|
+
method: this.config.method,
|
|
611
|
+
headers: this.config.headers,
|
|
612
|
+
body: this.config.body,
|
|
613
|
+
timeoutMs: this.config.timeoutMs,
|
|
614
|
+
attempt,
|
|
615
|
+
expectedStatusCodes: this.config.expectedStatusCodes,
|
|
616
|
+
externalSignal: signal
|
|
617
|
+
});
|
|
618
|
+
lastResult = result;
|
|
619
|
+
if (result.ok) {
|
|
620
|
+
this.logger?.success(`${result.status} ${result.statusText} - ${result.durationMs}ms`);
|
|
621
|
+
if (this.config.logLevel === "verbose" && result.headers) {
|
|
622
|
+
this.logger?.debug(`Response headers: ${JSON.stringify(redactHeaders(result.headers))}`);
|
|
623
|
+
}
|
|
624
|
+
this.emit("success", result);
|
|
625
|
+
return result;
|
|
626
|
+
}
|
|
627
|
+
const errorDetail = result.error || `HTTP ${result.status}`;
|
|
628
|
+
this.logger?.error(`Request failed - ${errorDetail}`);
|
|
629
|
+
if (attempt < maxAttempts && (!signal || !signal.aborted)) {
|
|
630
|
+
const delayMs = calculateBackoff({
|
|
631
|
+
attempt,
|
|
632
|
+
baseDelayMs: this.config.retryDelayMs,
|
|
633
|
+
strategy: this.config.retryStrategy,
|
|
634
|
+
maxDelayMs: this.config.maxRetryDelayMs,
|
|
635
|
+
jitter: this.config.retryJitter
|
|
636
|
+
});
|
|
637
|
+
const retryInfo = {
|
|
638
|
+
serviceName: this.config.name,
|
|
639
|
+
url: this.config.url,
|
|
640
|
+
attempt,
|
|
641
|
+
maxRetries: this.config.retries,
|
|
642
|
+
delayMs,
|
|
643
|
+
error: errorDetail
|
|
644
|
+
};
|
|
645
|
+
if (this.logger && "retry" in this.logger && typeof this.logger.retry === "function") {
|
|
646
|
+
this.logger.retry(
|
|
647
|
+
`Retrying ${attempt}/${this.config.retries} in ${formatDuration(delayMs)}...`
|
|
648
|
+
);
|
|
649
|
+
} else {
|
|
650
|
+
this.logger?.warn(
|
|
651
|
+
`Retrying ${attempt}/${this.config.retries} in ${formatDuration(delayMs)}...`
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
this.emit("retry", retryInfo);
|
|
655
|
+
try {
|
|
656
|
+
await sleep(delayMs, signal);
|
|
657
|
+
} catch {
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const finalResult = lastResult ?? {
|
|
663
|
+
serviceName: this.config.name,
|
|
664
|
+
url: this.config.url,
|
|
665
|
+
method: this.config.method,
|
|
666
|
+
status: 0,
|
|
667
|
+
statusText: "Failed",
|
|
668
|
+
ok: false,
|
|
669
|
+
durationMs: 0,
|
|
670
|
+
attempt: maxAttempts,
|
|
671
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
672
|
+
error: "All retry attempts exhausted"
|
|
673
|
+
};
|
|
674
|
+
this.emit("failure", finalResult);
|
|
675
|
+
return finalResult;
|
|
676
|
+
}
|
|
677
|
+
// Type-safe event emitter methods
|
|
678
|
+
on(event, listener) {
|
|
679
|
+
return super.on(event, listener);
|
|
680
|
+
}
|
|
681
|
+
once(event, listener) {
|
|
682
|
+
return super.once(event, listener);
|
|
683
|
+
}
|
|
684
|
+
off(event, listener) {
|
|
685
|
+
return super.off(event, listener);
|
|
686
|
+
}
|
|
687
|
+
emit(event, ...args) {
|
|
688
|
+
return super.emit(event, ...args);
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
var MultiKeepAlive = class extends EventEmitter {
|
|
692
|
+
services = [];
|
|
693
|
+
serviceMap = /* @__PURE__ */ new Map();
|
|
694
|
+
logger = null;
|
|
695
|
+
constructor(options) {
|
|
696
|
+
super();
|
|
697
|
+
if (!options || !Array.isArray(options.services) || options.services.length === 0) {
|
|
698
|
+
throw new TypeError(
|
|
699
|
+
'MultiKeepAlive requires a "services" array containing at least one service.'
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
const { defaults = {}, logger, logLevel = "normal" } = options;
|
|
703
|
+
if (logger !== false) {
|
|
704
|
+
if (logger && typeof logger === "object") {
|
|
705
|
+
this.logger = logger;
|
|
706
|
+
} else {
|
|
707
|
+
this.logger = new Logger({ logLevel, prefix: "MultiKeepAlive" });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
for (const serviceConfig of options.services) {
|
|
711
|
+
const mergedConfig = {
|
|
712
|
+
...defaults,
|
|
713
|
+
...serviceConfig,
|
|
714
|
+
headers: {
|
|
715
|
+
...defaults.headers,
|
|
716
|
+
...serviceConfig.headers
|
|
717
|
+
},
|
|
718
|
+
logLevel: serviceConfig.logLevel ?? defaults.logLevel ?? logLevel,
|
|
719
|
+
logger: serviceConfig.logger !== void 0 ? serviceConfig.logger : logger
|
|
720
|
+
};
|
|
721
|
+
const instance = new KeepAlive(mergedConfig);
|
|
722
|
+
const name = instance.getConfig().name;
|
|
723
|
+
if (this.serviceMap.has(name)) {
|
|
724
|
+
throw new Error(
|
|
725
|
+
`Duplicate service name detected: "${name}". Each service must have a unique name.`
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
this.services.push(instance);
|
|
729
|
+
this.serviceMap.set(name, instance);
|
|
730
|
+
this.forwardEvents(instance);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
forwardEvents(instance) {
|
|
734
|
+
instance.on("start", (name) => this.emit("start", name));
|
|
735
|
+
instance.on("stop", (name) => this.emit("stop", name));
|
|
736
|
+
instance.on(
|
|
737
|
+
"ping",
|
|
738
|
+
(info) => this.emit("ping", info)
|
|
739
|
+
);
|
|
740
|
+
instance.on("success", (result) => this.emit("success", result));
|
|
741
|
+
instance.on("failure", (result) => this.emit("failure", result));
|
|
742
|
+
instance.on("retry", (info) => this.emit("retry", info));
|
|
743
|
+
instance.on("error", (err, name) => this.emit("error", err, name));
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Starts all configured services.
|
|
747
|
+
*/
|
|
748
|
+
start() {
|
|
749
|
+
this.logger?.info(`Starting keep-alive scheduler for ${this.services.length} services...`);
|
|
750
|
+
for (const service of this.services) {
|
|
751
|
+
service.start();
|
|
752
|
+
}
|
|
753
|
+
return this;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Stops all running services.
|
|
757
|
+
*/
|
|
758
|
+
async stop() {
|
|
759
|
+
this.logger?.info(`Stopping all services...`);
|
|
760
|
+
await Promise.all(this.services.map((s) => s.stop()));
|
|
761
|
+
this.logger?.info(`All services stopped successfully.`);
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Pings all services once in parallel and returns their results.
|
|
765
|
+
*/
|
|
766
|
+
async pingOnce() {
|
|
767
|
+
return Promise.all(this.services.map((s) => s.pingOnce()));
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Returns whether any of the services are currently running.
|
|
771
|
+
*/
|
|
772
|
+
isRunning() {
|
|
773
|
+
return this.services.some((s) => s.isRunning());
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Returns the list of all registered KeepAlive instances.
|
|
777
|
+
*/
|
|
778
|
+
getServices() {
|
|
779
|
+
return [...this.services];
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Returns a specific KeepAlive instance by service name.
|
|
783
|
+
*/
|
|
784
|
+
getService(name) {
|
|
785
|
+
return this.serviceMap.get(name);
|
|
786
|
+
}
|
|
787
|
+
// Type-safe event emitter methods
|
|
788
|
+
on(event, listener) {
|
|
789
|
+
return super.on(event, listener);
|
|
790
|
+
}
|
|
791
|
+
once(event, listener) {
|
|
792
|
+
return super.once(event, listener);
|
|
793
|
+
}
|
|
794
|
+
off(event, listener) {
|
|
795
|
+
return super.off(event, listener);
|
|
796
|
+
}
|
|
797
|
+
emit(event, ...args) {
|
|
798
|
+
return super.emit(event, ...args);
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
var CONFIG_CANDIDATES = [
|
|
802
|
+
"keepalive.config.json",
|
|
803
|
+
".keepaliverc.json",
|
|
804
|
+
".keepaliverc",
|
|
805
|
+
"keepalive.config.js",
|
|
806
|
+
"keepalive.config.mjs",
|
|
807
|
+
"keepalive.config.cjs"
|
|
808
|
+
];
|
|
809
|
+
function parseHeaderPairs(headers) {
|
|
810
|
+
if (!headers || !Array.isArray(headers)) return {};
|
|
811
|
+
const result = {};
|
|
812
|
+
for (const item of headers) {
|
|
813
|
+
const separatorIdx = item.indexOf(":") !== -1 ? item.indexOf(":") : item.indexOf("=");
|
|
814
|
+
if (separatorIdx === -1) {
|
|
815
|
+
throw new Error(
|
|
816
|
+
`Invalid header format "${item}". Expected format "Header-Name: Value" or "Header-Name=Value".`
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
const key = item.substring(0, separatorIdx).trim();
|
|
820
|
+
const val = item.substring(separatorIdx + 1).trim();
|
|
821
|
+
if (!key) {
|
|
822
|
+
throw new Error(`Header key cannot be empty in "${item}".`);
|
|
823
|
+
}
|
|
824
|
+
result[key] = val;
|
|
825
|
+
}
|
|
826
|
+
return result;
|
|
827
|
+
}
|
|
828
|
+
async function loadConfigFile(customPath) {
|
|
829
|
+
let targetPath = null;
|
|
830
|
+
if (customPath) {
|
|
831
|
+
const resolved = resolve(process.cwd(), customPath);
|
|
832
|
+
if (!existsSync(resolved)) {
|
|
833
|
+
throw new Error(`Config file not found at: ${resolved}`);
|
|
834
|
+
}
|
|
835
|
+
targetPath = resolved;
|
|
836
|
+
} else {
|
|
837
|
+
for (const candidate of CONFIG_CANDIDATES) {
|
|
838
|
+
const candidatePath = resolve(process.cwd(), candidate);
|
|
839
|
+
if (existsSync(candidatePath)) {
|
|
840
|
+
targetPath = candidatePath;
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (!targetPath) return null;
|
|
846
|
+
try {
|
|
847
|
+
if (targetPath.endsWith(".json") || targetPath.endsWith(".keepaliverc")) {
|
|
848
|
+
const raw = readFileSync(targetPath, "utf-8");
|
|
849
|
+
const parsed = JSON.parse(raw);
|
|
850
|
+
return normalizeRawConfig(parsed);
|
|
851
|
+
}
|
|
852
|
+
if (targetPath.endsWith(".js") || targetPath.endsWith(".mjs") || targetPath.endsWith(".cjs")) {
|
|
853
|
+
const moduleUrl = pathToFileURL(targetPath).href;
|
|
854
|
+
const imported = await import(moduleUrl);
|
|
855
|
+
const config = imported.default || imported;
|
|
856
|
+
return normalizeRawConfig(config);
|
|
857
|
+
}
|
|
858
|
+
throw new Error(`Unsupported configuration file format: ${targetPath}`);
|
|
859
|
+
} catch (err) {
|
|
860
|
+
throw new Error(
|
|
861
|
+
`Failed to parse configuration file "${targetPath}": ${err instanceof Error ? err.message : String(err)}`
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
function normalizeRawConfig(raw) {
|
|
866
|
+
if (!raw || typeof raw !== "object") {
|
|
867
|
+
throw new Error("Configuration must be a valid JSON or JS object.");
|
|
868
|
+
}
|
|
869
|
+
const obj = raw;
|
|
870
|
+
if (Array.isArray(obj["services"])) {
|
|
871
|
+
const services = obj["services"];
|
|
872
|
+
if (services.length === 0) {
|
|
873
|
+
throw new Error('Config "services" array cannot be empty.');
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
services,
|
|
877
|
+
defaults: obj["defaults"] || void 0,
|
|
878
|
+
logLevel: obj["logLevel"] || void 0
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
if (typeof obj["url"] === "string") {
|
|
882
|
+
return {
|
|
883
|
+
services: [obj],
|
|
884
|
+
logLevel: obj["logLevel"] || void 0
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
throw new Error(
|
|
888
|
+
'Configuration must contain either a "url" property or a "services" array of target endpoints.'
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
function loadEnvConfig() {
|
|
892
|
+
const env = process.env;
|
|
893
|
+
const config = {};
|
|
894
|
+
if (env["KEEPALIVE_URL"]) config.url = env["KEEPALIVE_URL"];
|
|
895
|
+
if (env["KEEPALIVE_INTERVAL"]) config.interval = env["KEEPALIVE_INTERVAL"];
|
|
896
|
+
if (env["KEEPALIVE_TIMEOUT"]) config.timeout = env["KEEPALIVE_TIMEOUT"];
|
|
897
|
+
if (env["KEEPALIVE_METHOD"]) config.method = env["KEEPALIVE_METHOD"].toUpperCase();
|
|
898
|
+
if (env["KEEPALIVE_RETRIES"]) config.retries = parseInt(env["KEEPALIVE_RETRIES"], 10);
|
|
899
|
+
if (env["KEEPALIVE_RETRY_DELAY"]) config.retryDelay = env["KEEPALIVE_RETRY_DELAY"];
|
|
900
|
+
if (env["KEEPALIVE_HEADERS"]) {
|
|
901
|
+
try {
|
|
902
|
+
config.headers = JSON.parse(env["KEEPALIVE_HEADERS"]);
|
|
903
|
+
} catch {
|
|
904
|
+
const pairs = env["KEEPALIVE_HEADERS"].split(",").map((s) => s.trim());
|
|
905
|
+
config.headers = parseHeaderPairs(pairs);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (env["KEEPALIVE_QUIET"] === "true" || env["KEEPALIVE_QUIET"] === "1") {
|
|
909
|
+
config.logLevel = "quiet";
|
|
910
|
+
} else if (env["KEEPALIVE_VERBOSE"] === "true" || env["KEEPALIVE_VERBOSE"] === "1") {
|
|
911
|
+
config.logLevel = "verbose";
|
|
912
|
+
}
|
|
913
|
+
return config;
|
|
914
|
+
}
|
|
915
|
+
async function resolveRuntimeConfig(cliOptions) {
|
|
916
|
+
const fileConfig = await loadConfigFile(cliOptions.config || process.env["KEEPALIVE_CONFIG"]);
|
|
917
|
+
const envConfig = loadEnvConfig();
|
|
918
|
+
let logLevel = "normal";
|
|
919
|
+
if (cliOptions.quiet) {
|
|
920
|
+
logLevel = "quiet";
|
|
921
|
+
} else if (cliOptions.verbose) {
|
|
922
|
+
logLevel = "verbose";
|
|
923
|
+
} else if (fileConfig?.logLevel) {
|
|
924
|
+
logLevel = fileConfig.logLevel;
|
|
925
|
+
} else if (envConfig.logLevel) {
|
|
926
|
+
logLevel = envConfig.logLevel;
|
|
927
|
+
}
|
|
928
|
+
const cliHeaders = parseHeaderPairs(cliOptions.headers);
|
|
929
|
+
if (fileConfig && fileConfig.services.length > 1 && !cliOptions.url) {
|
|
930
|
+
return {
|
|
931
|
+
services: fileConfig.services,
|
|
932
|
+
defaults: {
|
|
933
|
+
...fileConfig.defaults,
|
|
934
|
+
logLevel
|
|
935
|
+
},
|
|
936
|
+
logLevel
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
const primaryServiceFromFile = fileConfig?.services[0];
|
|
940
|
+
const targetUrl = cliOptions.url || primaryServiceFromFile?.url || envConfig.url;
|
|
941
|
+
if (!targetUrl) {
|
|
942
|
+
throw new Error(
|
|
943
|
+
"Missing target URL. Provide --url <url>, set KEEPALIVE_URL, or define a config file."
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
const interval = cliOptions.interval || primaryServiceFromFile?.interval || envConfig.interval || "10m";
|
|
947
|
+
const timeout = cliOptions.timeout || primaryServiceFromFile?.timeout || envConfig.timeout || "30s";
|
|
948
|
+
const method = (cliOptions.method || primaryServiceFromFile?.method || envConfig.method || "GET").toUpperCase();
|
|
949
|
+
const retries = cliOptions.retries !== void 0 ? typeof cliOptions.retries === "string" ? parseInt(cliOptions.retries, 10) : cliOptions.retries : primaryServiceFromFile?.retries !== void 0 ? primaryServiceFromFile.retries : envConfig.retries !== void 0 ? envConfig.retries : 3;
|
|
950
|
+
const retryDelay = cliOptions.retryDelay || primaryServiceFromFile?.retryDelay || envConfig.retryDelay || "5s";
|
|
951
|
+
const headers = {
|
|
952
|
+
...envConfig.headers ?? {},
|
|
953
|
+
...fileConfig?.defaults?.headers ?? {},
|
|
954
|
+
...primaryServiceFromFile?.headers ?? {},
|
|
955
|
+
...cliHeaders
|
|
956
|
+
};
|
|
957
|
+
const name = primaryServiceFromFile?.name;
|
|
958
|
+
return {
|
|
959
|
+
url: targetUrl,
|
|
960
|
+
name,
|
|
961
|
+
interval,
|
|
962
|
+
timeout,
|
|
963
|
+
method,
|
|
964
|
+
headers,
|
|
965
|
+
retries,
|
|
966
|
+
retryDelay,
|
|
967
|
+
logLevel
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/cli.ts
|
|
972
|
+
var VERSION = "1.0.0";
|
|
973
|
+
function parseArgs(argv) {
|
|
974
|
+
const options = {
|
|
975
|
+
headers: []
|
|
976
|
+
};
|
|
977
|
+
const args = argv.slice(2);
|
|
978
|
+
for (let i = 0; i < args.length; i++) {
|
|
979
|
+
const arg = args[i];
|
|
980
|
+
if (!arg) continue;
|
|
981
|
+
if (arg === "-h" || arg === "--help") {
|
|
982
|
+
options.help = true;
|
|
983
|
+
} else if (arg === "-V" || arg === "--version") {
|
|
984
|
+
options.version = true;
|
|
985
|
+
} else if (arg === "-q" || arg === "--quiet") {
|
|
986
|
+
options.quiet = true;
|
|
987
|
+
} else if (arg === "-v" || arg === "--verbose") {
|
|
988
|
+
options.verbose = true;
|
|
989
|
+
} else if (arg === "--once") {
|
|
990
|
+
options.once = true;
|
|
991
|
+
} else if (arg === "-u" || arg === "--url") {
|
|
992
|
+
options.url = args[++i];
|
|
993
|
+
} else if (arg.startsWith("--url=")) {
|
|
994
|
+
options.url = arg.split("=")[1];
|
|
995
|
+
} else if (arg === "-i" || arg === "--interval") {
|
|
996
|
+
options.interval = args[++i];
|
|
997
|
+
} else if (arg.startsWith("--interval=")) {
|
|
998
|
+
options.interval = arg.split("=")[1];
|
|
999
|
+
} else if (arg === "-t" || arg === "--timeout") {
|
|
1000
|
+
options.timeout = args[++i];
|
|
1001
|
+
} else if (arg.startsWith("--timeout=")) {
|
|
1002
|
+
options.timeout = arg.split("=")[1];
|
|
1003
|
+
} else if (arg === "-m" || arg === "--method") {
|
|
1004
|
+
options.method = args[++i];
|
|
1005
|
+
} else if (arg.startsWith("--method=")) {
|
|
1006
|
+
options.method = arg.split("=")[1];
|
|
1007
|
+
} else if (arg === "-r" || arg === "--retries") {
|
|
1008
|
+
options.retries = args[++i];
|
|
1009
|
+
} else if (arg.startsWith("--retries=")) {
|
|
1010
|
+
options.retries = arg.split("=")[1];
|
|
1011
|
+
} else if (arg === "--retry-delay") {
|
|
1012
|
+
options.retryDelay = args[++i];
|
|
1013
|
+
} else if (arg.startsWith("--retry-delay=")) {
|
|
1014
|
+
options.retryDelay = arg.split("=")[1];
|
|
1015
|
+
} else if (arg === "-H" || arg === "--header") {
|
|
1016
|
+
const headerVal = args[++i];
|
|
1017
|
+
if (headerVal) options.headers?.push(headerVal);
|
|
1018
|
+
} else if (arg.startsWith("--header=")) {
|
|
1019
|
+
const headerVal = arg.split("=")[1];
|
|
1020
|
+
if (headerVal) options.headers?.push(headerVal);
|
|
1021
|
+
} else if (arg === "-c" || arg === "--config") {
|
|
1022
|
+
options.config = args[++i];
|
|
1023
|
+
} else if (arg.startsWith("--config=")) {
|
|
1024
|
+
options.config = arg.split("=")[1];
|
|
1025
|
+
} else if (!arg.startsWith("-") && !options.url) {
|
|
1026
|
+
options.url = arg;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return options;
|
|
1030
|
+
}
|
|
1031
|
+
function printHelp() {
|
|
1032
|
+
console.log(`
|
|
1033
|
+
\x1B[1m\x1B[36mservice-keepalive\x1B[0m v${VERSION}
|
|
1034
|
+
Lightweight HTTP keep-alive utility to prevent idle spin-down on services with inbound traffic wakeups.
|
|
1035
|
+
|
|
1036
|
+
\x1B[1mUSAGE:\x1B[0m
|
|
1037
|
+
$ npx service-keepalive [options]
|
|
1038
|
+
$ npx service-keepalive <url> [options]
|
|
1039
|
+
|
|
1040
|
+
\x1B[1mOPTIONS:\x1B[0m
|
|
1041
|
+
-u, --url <url> Target service URL (e.g. https://example.onrender.com/health)
|
|
1042
|
+
-i, --interval <duration> Interval between pings (e.g. 10s, 1m, 5m, 10m, 1h) [default: 10m]
|
|
1043
|
+
-t, --timeout <duration> Request timeout duration (e.g. 10s, 30s, 1m) [default: 30s]
|
|
1044
|
+
-m, --method <method> HTTP method (GET, POST, HEAD, etc.) [default: GET]
|
|
1045
|
+
-r, --retries <number> Number of retry attempts on failure [default: 3]
|
|
1046
|
+
--retry-delay <duration> Base delay between retries [default: 5s]
|
|
1047
|
+
-H, --header <key:value> Custom request header (can be used multiple times)
|
|
1048
|
+
-c, --config <path> Path to JSON or JS config file [default: keepalive.config.json]
|
|
1049
|
+
--once Execute a single ping and exit (useful for cron jobs / CI)
|
|
1050
|
+
-q, --quiet Suppress standard output; only log failures
|
|
1051
|
+
-v, --verbose Enable verbose debug logging
|
|
1052
|
+
-V, --version Output version number
|
|
1053
|
+
-h, --help Display this help message
|
|
1054
|
+
|
|
1055
|
+
\x1B[1mEXAMPLES:\x1B[0m
|
|
1056
|
+
$ npx service-keepalive --url https://api.example.com/health --interval 10m
|
|
1057
|
+
$ npx service-keepalive -u https://api.example.com/health -H "Authorization: Bearer mytoken"
|
|
1058
|
+
$ npx service-keepalive --config keepalive.config.json
|
|
1059
|
+
$ npx service-keepalive --url https://api.example.com/health --once
|
|
1060
|
+
|
|
1061
|
+
\x1B[1mENVIRONMENT VARIABLES:\x1B[0m
|
|
1062
|
+
KEEPALIVE_URL, KEEPALIVE_INTERVAL, KEEPALIVE_TIMEOUT, KEEPALIVE_METHOD,
|
|
1063
|
+
KEEPALIVE_RETRIES, KEEPALIVE_HEADERS, KEEPALIVE_CONFIG, KEEPALIVE_QUIET, KEEPALIVE_VERBOSE
|
|
1064
|
+
|
|
1065
|
+
\x1B[1mNOTE:\x1B[0m
|
|
1066
|
+
This tool runs externally (on your machine, VPS, Docker container, or CI runner).
|
|
1067
|
+
Always ensure compliance with your hosting provider's Terms of Service.
|
|
1068
|
+
`);
|
|
1069
|
+
}
|
|
1070
|
+
async function runCli(argv = process.argv) {
|
|
1071
|
+
const options = parseArgs(argv);
|
|
1072
|
+
if (options.help) {
|
|
1073
|
+
printHelp();
|
|
1074
|
+
return 0;
|
|
1075
|
+
}
|
|
1076
|
+
if (options.version) {
|
|
1077
|
+
console.log(`service-keepalive v${VERSION}`);
|
|
1078
|
+
return 0;
|
|
1079
|
+
}
|
|
1080
|
+
try {
|
|
1081
|
+
const config = await resolveRuntimeConfig(options);
|
|
1082
|
+
let runner;
|
|
1083
|
+
if ("services" in config) {
|
|
1084
|
+
runner = new MultiKeepAlive(config);
|
|
1085
|
+
} else {
|
|
1086
|
+
runner = new KeepAlive(config);
|
|
1087
|
+
}
|
|
1088
|
+
let isShuttingDown = false;
|
|
1089
|
+
const shutdown = async (signal) => {
|
|
1090
|
+
if (isShuttingDown) return;
|
|
1091
|
+
isShuttingDown = true;
|
|
1092
|
+
console.log(`
|
|
1093
|
+
Received ${signal}. Stopping keep-alive...`);
|
|
1094
|
+
await runner.stop();
|
|
1095
|
+
console.log("Stopped successfully.");
|
|
1096
|
+
process.exit(0);
|
|
1097
|
+
};
|
|
1098
|
+
process.once("SIGINT", () => void shutdown("SIGINT"));
|
|
1099
|
+
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
1100
|
+
if (options.once) {
|
|
1101
|
+
if (runner instanceof MultiKeepAlive) {
|
|
1102
|
+
const results = await runner.pingOnce();
|
|
1103
|
+
const allOk = results.every((r) => r.ok);
|
|
1104
|
+
return allOk ? 0 : 1;
|
|
1105
|
+
} else {
|
|
1106
|
+
const result = await runner.pingOnce();
|
|
1107
|
+
return result.ok ? 0 : 1;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
runner.start();
|
|
1111
|
+
return new Promise(() => {
|
|
1112
|
+
});
|
|
1113
|
+
} catch (err) {
|
|
1114
|
+
console.error(`\x1B[31mError:\x1B[0m ${err instanceof Error ? err.message : String(err)}`);
|
|
1115
|
+
return 1;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
export { KeepAlive, Logger, MultiKeepAlive, calculateBackoff, executePing, formatDuration, formatTimestamp, isStatusSuccessful, loadConfigFile, loadEnvConfig, normalizeRawConfig, parseArgs, parseDuration, parseHeaderPairs, printHelp, redactHeaders, redactUrl, resolveRuntimeConfig, runCli, sleep };
|
|
1120
|
+
//# sourceMappingURL=index.js.map
|
|
1121
|
+
//# sourceMappingURL=index.js.map
|