bridgex 1.0.0 → 2.0.1
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/README.md +525 -0
- package/dist/CircuitBreaker.d.ts +25 -0
- package/dist/CircuitBreaker.js +58 -0
- package/dist/CredentialProvisioner.d.ts +27 -0
- package/dist/CredentialProvisioner.js +43 -0
- package/dist/MessageFormatter.d.ts +13 -1
- package/dist/MessageFormatter.js +30 -6
- package/dist/PluginManager.d.ts +77 -0
- package/dist/PluginManager.js +101 -0
- package/dist/RetryHandler.d.ts +8 -1
- package/dist/RetryHandler.js +28 -6
- package/dist/SMSClient.d.ts +65 -0
- package/dist/SMSClient.js +236 -0
- package/dist/SMSQueue.d.ts +82 -0
- package/dist/SMSQueue.js +207 -0
- package/dist/SMSScheduler.d.ts +75 -0
- package/dist/SMSScheduler.js +196 -0
- package/dist/SMSService.d.ts +41 -0
- package/dist/SMSService.js +214 -0
- package/dist/SendConfig.d.ts +90 -0
- package/dist/SendConfig.js +165 -0
- package/dist/errors.d.ts +18 -1
- package/dist/errors.js +28 -5
- package/dist/helpers.d.ts +3 -0
- package/dist/helpers.js +23 -0
- package/dist/main.d.ts +20 -1
- package/dist/main.js +19 -1
- package/dist/types.d.ts +43 -0
- package/dist/types.js +1 -0
- package/package.json +1 -1
- package/src/CircuitBreaker.ts +81 -0
- package/src/CredentialProvisioner.ts +68 -0
- package/src/MessageFormatter.ts +38 -7
- package/src/PluginManager.ts +155 -0
- package/src/RetryHandler.ts +42 -9
- package/src/SMSClient.ts +308 -0
- package/src/SMSQueue.ts +281 -0
- package/src/SMSScheduler.ts +250 -0
- package/src/SMSService.ts +254 -0
- package/src/SendConfig.ts +208 -0
- package/src/errors.ts +40 -6
- package/src/helpers.ts +31 -0
- package/src/main.ts +61 -1
- package/src/types.ts +33 -0
- package/src/client/SMSManager.ts +0 -67
- package/src/client/types.ts +0 -24
- package/src/help.ts +0 -3
package/dist/SMSQueue.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const PRIORITY_WEIGHT = {
|
|
2
|
+
high: 3,
|
|
3
|
+
normal: 2,
|
|
4
|
+
low: 1,
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* SMSQueue — an in-process job queue for fire-and-forget or scheduled SMS.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Priority lanes (high / normal / low)
|
|
11
|
+
* - Deduplication via dedupKey
|
|
12
|
+
* - TTL — drops stale jobs before sending
|
|
13
|
+
* - Scheduled sends (send at a future timestamp)
|
|
14
|
+
* - Concurrency-limited workers
|
|
15
|
+
* - Plugin hooks (onDrop, onSuccess, onError, onRetry)
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* const queue = new SMSQueue(client, { concurrency: 5 });
|
|
19
|
+
* queue.start();
|
|
20
|
+
*
|
|
21
|
+
* const cfg = SendConfig.otp().dedupKey(`otp:${userId}`);
|
|
22
|
+
* await queue.enqueue(cfg.for(phone, { code }));
|
|
23
|
+
*
|
|
24
|
+
* // Schedule for later
|
|
25
|
+
* await queue.enqueueAt(cfg.for(phone, { code }), Date.now() + 60_000);
|
|
26
|
+
*/
|
|
27
|
+
export default class SMSQueue {
|
|
28
|
+
constructor(client, options = {}, plugins) {
|
|
29
|
+
this.client = client;
|
|
30
|
+
this.jobs = [];
|
|
31
|
+
this.dedupSet = new Set();
|
|
32
|
+
this.running = 0;
|
|
33
|
+
this.completed = 0;
|
|
34
|
+
this.dropped = 0;
|
|
35
|
+
this.concurrency = options.concurrency ?? 3;
|
|
36
|
+
this.pollInterval = options.pollInterval ?? 1000;
|
|
37
|
+
this.autoStart = options.autoStart ?? true;
|
|
38
|
+
this.plugins = plugins;
|
|
39
|
+
}
|
|
40
|
+
// ── Enqueue ──────────────────────────────────────────────────────────────
|
|
41
|
+
/** Enqueue a job for immediate (or next-available) processing. */
|
|
42
|
+
enqueue(params) {
|
|
43
|
+
return this._enqueue(params, 0);
|
|
44
|
+
}
|
|
45
|
+
/** Enqueue a job to send at a specific future timestamp (epoch ms). */
|
|
46
|
+
enqueueAt(params, timestamp) {
|
|
47
|
+
return this._enqueue(params, timestamp);
|
|
48
|
+
}
|
|
49
|
+
/** Enqueue a job to send after a delay (ms). */
|
|
50
|
+
enqueueAfter(params, delayMs) {
|
|
51
|
+
return this._enqueue(params, Date.now() + delayMs);
|
|
52
|
+
}
|
|
53
|
+
_enqueue(params, scheduledAt) {
|
|
54
|
+
const meta = params._meta ?? {
|
|
55
|
+
tags: [],
|
|
56
|
+
priority: "normal",
|
|
57
|
+
createdAt: Date.now(),
|
|
58
|
+
};
|
|
59
|
+
// Deduplication
|
|
60
|
+
if (meta.dedupKey) {
|
|
61
|
+
if (this.dedupSet.has(meta.dedupKey)) {
|
|
62
|
+
this.dropped++;
|
|
63
|
+
this.plugins?.fire("onDrop", {
|
|
64
|
+
to: params.to,
|
|
65
|
+
tags: meta.tags,
|
|
66
|
+
reason: "dedup",
|
|
67
|
+
});
|
|
68
|
+
return meta.dedupKey; // return key so caller can identify it
|
|
69
|
+
}
|
|
70
|
+
this.dedupSet.add(meta.dedupKey);
|
|
71
|
+
}
|
|
72
|
+
const id = meta.dedupKey ??
|
|
73
|
+
`job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
74
|
+
const job = {
|
|
75
|
+
id,
|
|
76
|
+
params,
|
|
77
|
+
meta,
|
|
78
|
+
scheduledAt,
|
|
79
|
+
enqueuedAt: Date.now(),
|
|
80
|
+
attempts: 0,
|
|
81
|
+
};
|
|
82
|
+
this._insertByPriority(job);
|
|
83
|
+
if (this.autoStart && !this.pollTimer) {
|
|
84
|
+
this.start();
|
|
85
|
+
}
|
|
86
|
+
return id;
|
|
87
|
+
}
|
|
88
|
+
_insertByPriority(job) {
|
|
89
|
+
const w = PRIORITY_WEIGHT[job.meta.priority] ?? 2;
|
|
90
|
+
// Find insertion point: higher weight jobs go first
|
|
91
|
+
let i = 0;
|
|
92
|
+
while (i < this.jobs.length &&
|
|
93
|
+
PRIORITY_WEIGHT[this.jobs[i].meta.priority ?? "normal"] >= w)
|
|
94
|
+
i++;
|
|
95
|
+
this.jobs.splice(i, 0, job);
|
|
96
|
+
}
|
|
97
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
98
|
+
/** Start the queue workers. */
|
|
99
|
+
start() {
|
|
100
|
+
if (this.pollTimer)
|
|
101
|
+
return this;
|
|
102
|
+
this.pollTimer = setInterval(() => this._tick(), this.pollInterval);
|
|
103
|
+
this._tick(); // immediate first tick
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
/** Drain the queue (process remaining jobs) then stop. */
|
|
107
|
+
async drain() {
|
|
108
|
+
this.stop();
|
|
109
|
+
while (this.jobs.length > 0 || this.running > 0) {
|
|
110
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
111
|
+
this._tick();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Stop polling. In-flight jobs still complete. */
|
|
115
|
+
stop() {
|
|
116
|
+
if (this.pollTimer) {
|
|
117
|
+
clearInterval(this.pollTimer);
|
|
118
|
+
this.pollTimer = undefined;
|
|
119
|
+
}
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
// ── Processing ───────────────────────────────────────────────────────────
|
|
123
|
+
_tick() {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
while (this.running < this.concurrency && this.jobs.length > 0) {
|
|
126
|
+
// Find next ready job (scheduledAt <= now)
|
|
127
|
+
const idx = this.jobs.findIndex((j) => j.scheduledAt <= now);
|
|
128
|
+
if (idx === -1)
|
|
129
|
+
break;
|
|
130
|
+
const [job] = this.jobs.splice(idx, 1);
|
|
131
|
+
this._process(job);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async _process(job) {
|
|
135
|
+
const { params, meta } = job;
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
// TTL check
|
|
138
|
+
if (meta.ttl && now - meta.createdAt > meta.ttl) {
|
|
139
|
+
this.dropped++;
|
|
140
|
+
if (meta.dedupKey)
|
|
141
|
+
this.dedupSet.delete(meta.dedupKey);
|
|
142
|
+
await this.plugins?.fire("onDrop", {
|
|
143
|
+
to: params.to,
|
|
144
|
+
tags: meta.tags,
|
|
145
|
+
reason: "ttl",
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.running++;
|
|
150
|
+
const start = Date.now();
|
|
151
|
+
await this.plugins?.fire("onSend", {
|
|
152
|
+
to: params.to,
|
|
153
|
+
tags: meta.tags,
|
|
154
|
+
template: params.template,
|
|
155
|
+
});
|
|
156
|
+
const result = (await this.client.send(params));
|
|
157
|
+
this.running--;
|
|
158
|
+
const durationMs = Date.now() - start;
|
|
159
|
+
if (result.ok) {
|
|
160
|
+
this.completed++;
|
|
161
|
+
if (meta.dedupKey)
|
|
162
|
+
this.dedupSet.delete(meta.dedupKey);
|
|
163
|
+
await this.plugins?.fire("onSuccess", {
|
|
164
|
+
to: params.to,
|
|
165
|
+
tags: meta.tags,
|
|
166
|
+
data: result.data,
|
|
167
|
+
durationMs,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
if (meta.dedupKey)
|
|
172
|
+
this.dedupSet.delete(meta.dedupKey);
|
|
173
|
+
await this.plugins?.fire("onError", {
|
|
174
|
+
to: params.to,
|
|
175
|
+
tags: meta.tags,
|
|
176
|
+
error: result.error,
|
|
177
|
+
durationMs,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// ── Stats ────────────────────────────────────────────────────────────────
|
|
182
|
+
stats() {
|
|
183
|
+
return {
|
|
184
|
+
pending: this.jobs.length,
|
|
185
|
+
running: this.running,
|
|
186
|
+
completed: this.completed,
|
|
187
|
+
dropped: this.dropped,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** Cancel a job by id. Returns true if found and removed. */
|
|
191
|
+
cancel(id) {
|
|
192
|
+
const idx = this.jobs.findIndex((j) => j.id === id);
|
|
193
|
+
if (idx === -1)
|
|
194
|
+
return false;
|
|
195
|
+
const [job] = this.jobs.splice(idx, 1);
|
|
196
|
+
if (job.meta.dedupKey)
|
|
197
|
+
this.dedupSet.delete(job.meta.dedupKey);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
/** Clear all pending (not in-flight) jobs. */
|
|
201
|
+
clear() {
|
|
202
|
+
const count = this.jobs.length;
|
|
203
|
+
this.jobs.forEach((j) => j.meta.dedupKey && this.dedupSet.delete(j.meta.dedupKey));
|
|
204
|
+
this.jobs = [];
|
|
205
|
+
return count;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type SMSQueue from "./SMSQueue.js";
|
|
2
|
+
import type { SendParams } from "./types.js";
|
|
3
|
+
import type { SendMeta } from "./SendConfig.js";
|
|
4
|
+
export interface ScheduledJob {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
params: SendParams & Partial<SendMeta>;
|
|
8
|
+
/** Cron expression OR interval ms */
|
|
9
|
+
schedule: string | number;
|
|
10
|
+
nextRunAt: number;
|
|
11
|
+
runCount: number;
|
|
12
|
+
maxRuns?: number;
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* SMSScheduler — register recurring or one-shot SMS sends.
|
|
17
|
+
*
|
|
18
|
+
* Schedules can be:
|
|
19
|
+
* - A millisecond interval: `every(60_000, "ping", params)`
|
|
20
|
+
* - A cron expression: `cron("0 9 * * MON-FRI", "weekly-digest", params)`
|
|
21
|
+
* - A one-shot future send: `once(new Date("2025-01-01"), "new-year", params)`
|
|
22
|
+
*
|
|
23
|
+
* All jobs are dispatched through an SMSQueue (respects priority, TTL, dedup).
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const scheduler = new SMSScheduler(queue);
|
|
27
|
+
* scheduler.start();
|
|
28
|
+
*
|
|
29
|
+
* scheduler.every(
|
|
30
|
+
* 24 * 60 * 60 * 1000,
|
|
31
|
+
* "daily-digest",
|
|
32
|
+
* digestConfig.for(adminPhone)
|
|
33
|
+
* );
|
|
34
|
+
*
|
|
35
|
+
* scheduler.once(
|
|
36
|
+
* new Date("2025-06-01T09:00:00"),
|
|
37
|
+
* "summer-promo",
|
|
38
|
+
* promoConfig.for(phone)
|
|
39
|
+
* );
|
|
40
|
+
*/
|
|
41
|
+
export default class SMSScheduler {
|
|
42
|
+
private queue;
|
|
43
|
+
private jobs;
|
|
44
|
+
private timer?;
|
|
45
|
+
private readonly tickMs;
|
|
46
|
+
constructor(queue: SMSQueue, options?: {
|
|
47
|
+
tickMs?: number;
|
|
48
|
+
});
|
|
49
|
+
/** Repeat every `intervalMs` milliseconds. */
|
|
50
|
+
every(intervalMs: number, name: string, params: SendParams & Partial<SendMeta>, options?: {
|
|
51
|
+
maxRuns?: number;
|
|
52
|
+
runImmediately?: boolean;
|
|
53
|
+
}): string;
|
|
54
|
+
/** Run once at a specific date/time. Automatically removes itself after firing. */
|
|
55
|
+
once(at: Date | number, name: string, params: SendParams & Partial<SendMeta>): string;
|
|
56
|
+
/**
|
|
57
|
+
* Cron-style schedule using a tiny parser (supports minute/hour/day/month/weekday).
|
|
58
|
+
*
|
|
59
|
+
* Format: "minute hour day-of-month month day-of-week"
|
|
60
|
+
* Use "*" for "every", comma for lists, "/" for steps.
|
|
61
|
+
*
|
|
62
|
+
* @example scheduler.cron("0 9 * * 1-5", "weekday-morning", params)
|
|
63
|
+
* @example scheduler.cron("30 8,12,18 * * *", "thrice-daily", params)
|
|
64
|
+
*/
|
|
65
|
+
cron(expression: string, name: string, params: SendParams & Partial<SendMeta>, options?: {
|
|
66
|
+
maxRuns?: number;
|
|
67
|
+
}): string;
|
|
68
|
+
pause(id: string): boolean;
|
|
69
|
+
resume(id: string): boolean;
|
|
70
|
+
remove(id: string): boolean;
|
|
71
|
+
list(): ScheduledJob[];
|
|
72
|
+
start(): this;
|
|
73
|
+
stop(): this;
|
|
74
|
+
private _tick;
|
|
75
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMSScheduler — register recurring or one-shot SMS sends.
|
|
3
|
+
*
|
|
4
|
+
* Schedules can be:
|
|
5
|
+
* - A millisecond interval: `every(60_000, "ping", params)`
|
|
6
|
+
* - A cron expression: `cron("0 9 * * MON-FRI", "weekly-digest", params)`
|
|
7
|
+
* - A one-shot future send: `once(new Date("2025-01-01"), "new-year", params)`
|
|
8
|
+
*
|
|
9
|
+
* All jobs are dispatched through an SMSQueue (respects priority, TTL, dedup).
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const scheduler = new SMSScheduler(queue);
|
|
13
|
+
* scheduler.start();
|
|
14
|
+
*
|
|
15
|
+
* scheduler.every(
|
|
16
|
+
* 24 * 60 * 60 * 1000,
|
|
17
|
+
* "daily-digest",
|
|
18
|
+
* digestConfig.for(adminPhone)
|
|
19
|
+
* );
|
|
20
|
+
*
|
|
21
|
+
* scheduler.once(
|
|
22
|
+
* new Date("2025-06-01T09:00:00"),
|
|
23
|
+
* "summer-promo",
|
|
24
|
+
* promoConfig.for(phone)
|
|
25
|
+
* );
|
|
26
|
+
*/
|
|
27
|
+
export default class SMSScheduler {
|
|
28
|
+
constructor(queue, options = {}) {
|
|
29
|
+
this.queue = queue;
|
|
30
|
+
this.jobs = new Map();
|
|
31
|
+
this.tickMs = options.tickMs ?? 1000;
|
|
32
|
+
}
|
|
33
|
+
// ── Registration ─────────────────────────────────────────────────────────
|
|
34
|
+
/** Repeat every `intervalMs` milliseconds. */
|
|
35
|
+
every(intervalMs, name, params, options = {}) {
|
|
36
|
+
const id = `every:${name}`;
|
|
37
|
+
this.jobs.set(id, {
|
|
38
|
+
id,
|
|
39
|
+
name,
|
|
40
|
+
params,
|
|
41
|
+
schedule: intervalMs,
|
|
42
|
+
nextRunAt: options.runImmediately ? Date.now() : Date.now() + intervalMs,
|
|
43
|
+
runCount: 0,
|
|
44
|
+
maxRuns: options.maxRuns,
|
|
45
|
+
enabled: true,
|
|
46
|
+
});
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
49
|
+
/** Run once at a specific date/time. Automatically removes itself after firing. */
|
|
50
|
+
once(at, name, params) {
|
|
51
|
+
const ts = at instanceof Date ? at.getTime() : at;
|
|
52
|
+
const id = `once:${name}:${ts}`;
|
|
53
|
+
this.jobs.set(id, {
|
|
54
|
+
id,
|
|
55
|
+
name,
|
|
56
|
+
params,
|
|
57
|
+
schedule: 0,
|
|
58
|
+
nextRunAt: ts,
|
|
59
|
+
runCount: 0,
|
|
60
|
+
maxRuns: 1,
|
|
61
|
+
enabled: true,
|
|
62
|
+
});
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Cron-style schedule using a tiny parser (supports minute/hour/day/month/weekday).
|
|
67
|
+
*
|
|
68
|
+
* Format: "minute hour day-of-month month day-of-week"
|
|
69
|
+
* Use "*" for "every", comma for lists, "/" for steps.
|
|
70
|
+
*
|
|
71
|
+
* @example scheduler.cron("0 9 * * 1-5", "weekday-morning", params)
|
|
72
|
+
* @example scheduler.cron("30 8,12,18 * * *", "thrice-daily", params)
|
|
73
|
+
*/
|
|
74
|
+
cron(expression, name, params, options = {}) {
|
|
75
|
+
const id = `cron:${name}`;
|
|
76
|
+
const nextRunAt = nextCronTime(expression);
|
|
77
|
+
this.jobs.set(id, {
|
|
78
|
+
id,
|
|
79
|
+
name,
|
|
80
|
+
params,
|
|
81
|
+
schedule: expression,
|
|
82
|
+
nextRunAt,
|
|
83
|
+
runCount: 0,
|
|
84
|
+
maxRuns: options.maxRuns,
|
|
85
|
+
enabled: true,
|
|
86
|
+
});
|
|
87
|
+
return id;
|
|
88
|
+
}
|
|
89
|
+
// ── Control ──────────────────────────────────────────────────────────────
|
|
90
|
+
pause(id) {
|
|
91
|
+
const job = this.jobs.get(id);
|
|
92
|
+
if (!job)
|
|
93
|
+
return false;
|
|
94
|
+
job.enabled = false;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
resume(id) {
|
|
98
|
+
const job = this.jobs.get(id);
|
|
99
|
+
if (!job)
|
|
100
|
+
return false;
|
|
101
|
+
job.enabled = true;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
remove(id) {
|
|
105
|
+
return this.jobs.delete(id);
|
|
106
|
+
}
|
|
107
|
+
list() {
|
|
108
|
+
return Array.from(this.jobs.values());
|
|
109
|
+
}
|
|
110
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
111
|
+
start() {
|
|
112
|
+
if (this.timer)
|
|
113
|
+
return this;
|
|
114
|
+
this.timer = setInterval(() => this._tick(), this.tickMs);
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
117
|
+
stop() {
|
|
118
|
+
if (this.timer) {
|
|
119
|
+
clearInterval(this.timer);
|
|
120
|
+
this.timer = undefined;
|
|
121
|
+
}
|
|
122
|
+
return this;
|
|
123
|
+
}
|
|
124
|
+
_tick() {
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
for (const [id, job] of this.jobs) {
|
|
127
|
+
if (!job.enabled || job.nextRunAt > now)
|
|
128
|
+
continue;
|
|
129
|
+
// Dispatch to queue
|
|
130
|
+
this.queue.enqueue(job.params);
|
|
131
|
+
job.runCount++;
|
|
132
|
+
// Remove one-shots / exhausted jobs
|
|
133
|
+
if (job.maxRuns !== undefined && job.runCount >= job.maxRuns) {
|
|
134
|
+
this.jobs.delete(id);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Advance nextRunAt
|
|
138
|
+
if (typeof job.schedule === "number" && job.schedule > 0) {
|
|
139
|
+
job.nextRunAt = now + job.schedule;
|
|
140
|
+
}
|
|
141
|
+
else if (typeof job.schedule === "string") {
|
|
142
|
+
job.nextRunAt = nextCronTime(job.schedule);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ── Tiny cron parser ─────────────────────────────────────────────────────────
|
|
148
|
+
function nextCronTime(expression) {
|
|
149
|
+
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = expression.trim().split(/\s+/);
|
|
150
|
+
const now = new Date();
|
|
151
|
+
const candidate = new Date(now);
|
|
152
|
+
candidate.setSeconds(0, 0);
|
|
153
|
+
candidate.setMinutes(candidate.getMinutes() + 1); // start from next minute
|
|
154
|
+
// Try up to 366 days ahead
|
|
155
|
+
for (let days = 0; days < 366 * 24 * 60; days++) {
|
|
156
|
+
const m = candidate.getMinutes();
|
|
157
|
+
const h = candidate.getHours();
|
|
158
|
+
const dom = candidate.getDate();
|
|
159
|
+
const month = candidate.getMonth() + 1;
|
|
160
|
+
const dow = candidate.getDay();
|
|
161
|
+
if (matchField(minuteExpr, m, 0, 59) &&
|
|
162
|
+
matchField(hourExpr, h, 0, 23) &&
|
|
163
|
+
matchField(domExpr, dom, 1, 31) &&
|
|
164
|
+
matchField(monthExpr, month, 1, 12) &&
|
|
165
|
+
matchField(dowExpr, dow, 0, 6)) {
|
|
166
|
+
return candidate.getTime();
|
|
167
|
+
}
|
|
168
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`Cannot find next occurrence for cron expression: "${expression}"`);
|
|
171
|
+
}
|
|
172
|
+
function matchField(expr, value, min, max) {
|
|
173
|
+
if (expr === "*")
|
|
174
|
+
return true;
|
|
175
|
+
for (const part of expr.split(",")) {
|
|
176
|
+
if (part.includes("/")) {
|
|
177
|
+
const [range, step] = part.split("/");
|
|
178
|
+
const s = parseInt(step, 10);
|
|
179
|
+
const [lo, hi] = range === "*"
|
|
180
|
+
? [min, max]
|
|
181
|
+
: range.split("-").map(Number);
|
|
182
|
+
if (value >= lo && value <= hi && (value - lo) % s === 0)
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
else if (part.includes("-")) {
|
|
186
|
+
const [lo, hi] = part.split("-").map(Number);
|
|
187
|
+
if (value >= lo && value <= hi)
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
if (parseInt(part, 10) === value)
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type SMSClient from "./SMSClient.js";
|
|
2
|
+
import type SMSQueue from "./SMSQueue.js";
|
|
3
|
+
import type { MetricsSnapshot } from "./PluginManager.js";
|
|
4
|
+
export interface SMSServiceOptions {
|
|
5
|
+
port?: number;
|
|
6
|
+
host?: string;
|
|
7
|
+
/** Shared secret callers must send in the `x-service-key` header */
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
/** Request timeout ms (default: 10 000) */
|
|
10
|
+
timeout?: number;
|
|
11
|
+
}
|
|
12
|
+
export default class SMSService {
|
|
13
|
+
private client;
|
|
14
|
+
private options;
|
|
15
|
+
private server;
|
|
16
|
+
private queue?;
|
|
17
|
+
private metricsSnapshot?;
|
|
18
|
+
private startedAt;
|
|
19
|
+
constructor(client: SMSClient, options?: SMSServiceOptions);
|
|
20
|
+
/** Attach a queue so /queue/* endpoints are available. */
|
|
21
|
+
withQueue(queue: SMSQueue): this;
|
|
22
|
+
/** Attach a MetricsPlugin to expose /metrics. */
|
|
23
|
+
withMetrics(plugin: {
|
|
24
|
+
snapshot: () => MetricsSnapshot;
|
|
25
|
+
}): this;
|
|
26
|
+
start(): Promise<void>;
|
|
27
|
+
stop(): Promise<void>;
|
|
28
|
+
private _handle;
|
|
29
|
+
private _send;
|
|
30
|
+
private _sendMany;
|
|
31
|
+
private _sendObject;
|
|
32
|
+
private _sendObjectMany;
|
|
33
|
+
private _enqueue;
|
|
34
|
+
private _schedule;
|
|
35
|
+
private _queueStats;
|
|
36
|
+
private _queueCancel;
|
|
37
|
+
private _health;
|
|
38
|
+
private _metrics;
|
|
39
|
+
private _json;
|
|
40
|
+
private _readBody;
|
|
41
|
+
}
|