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
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type SMSQueue from "./SMSQueue.js";
|
|
2
|
+
import type { SendParams } from "./types.js";
|
|
3
|
+
import type { SendMeta } from "./SendConfig.js";
|
|
4
|
+
|
|
5
|
+
export interface ScheduledJob {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
params: SendParams & Partial<SendMeta>;
|
|
9
|
+
/** Cron expression OR interval ms */
|
|
10
|
+
schedule: string | number;
|
|
11
|
+
nextRunAt: number;
|
|
12
|
+
runCount: number;
|
|
13
|
+
maxRuns?: number;
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* SMSScheduler — register recurring or one-shot SMS sends.
|
|
19
|
+
*
|
|
20
|
+
* Schedules can be:
|
|
21
|
+
* - A millisecond interval: `every(60_000, "ping", params)`
|
|
22
|
+
* - A cron expression: `cron("0 9 * * MON-FRI", "weekly-digest", params)`
|
|
23
|
+
* - A one-shot future send: `once(new Date("2025-01-01"), "new-year", params)`
|
|
24
|
+
*
|
|
25
|
+
* All jobs are dispatched through an SMSQueue (respects priority, TTL, dedup).
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* const scheduler = new SMSScheduler(queue);
|
|
29
|
+
* scheduler.start();
|
|
30
|
+
*
|
|
31
|
+
* scheduler.every(
|
|
32
|
+
* 24 * 60 * 60 * 1000,
|
|
33
|
+
* "daily-digest",
|
|
34
|
+
* digestConfig.for(adminPhone)
|
|
35
|
+
* );
|
|
36
|
+
*
|
|
37
|
+
* scheduler.once(
|
|
38
|
+
* new Date("2025-06-01T09:00:00"),
|
|
39
|
+
* "summer-promo",
|
|
40
|
+
* promoConfig.for(phone)
|
|
41
|
+
* );
|
|
42
|
+
*/
|
|
43
|
+
export default class SMSScheduler {
|
|
44
|
+
private jobs = new Map<string, ScheduledJob>();
|
|
45
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
46
|
+
private readonly tickMs: number;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private queue: SMSQueue,
|
|
50
|
+
options: { tickMs?: number } = {}
|
|
51
|
+
) {
|
|
52
|
+
this.tickMs = options.tickMs ?? 1000;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Registration ─────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** Repeat every `intervalMs` milliseconds. */
|
|
58
|
+
every(
|
|
59
|
+
intervalMs: number,
|
|
60
|
+
name: string,
|
|
61
|
+
params: SendParams & Partial<SendMeta>,
|
|
62
|
+
options: { maxRuns?: number; runImmediately?: boolean } = {}
|
|
63
|
+
): string {
|
|
64
|
+
const id = `every:${name}`;
|
|
65
|
+
this.jobs.set(id, {
|
|
66
|
+
id,
|
|
67
|
+
name,
|
|
68
|
+
params,
|
|
69
|
+
schedule: intervalMs,
|
|
70
|
+
nextRunAt: options.runImmediately ? Date.now() : Date.now() + intervalMs,
|
|
71
|
+
runCount: 0,
|
|
72
|
+
maxRuns: options.maxRuns,
|
|
73
|
+
enabled: true,
|
|
74
|
+
});
|
|
75
|
+
return id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Run once at a specific date/time. Automatically removes itself after firing. */
|
|
79
|
+
once(
|
|
80
|
+
at: Date | number,
|
|
81
|
+
name: string,
|
|
82
|
+
params: SendParams & Partial<SendMeta>
|
|
83
|
+
): string {
|
|
84
|
+
const ts = at instanceof Date ? at.getTime() : at;
|
|
85
|
+
const id = `once:${name}:${ts}`;
|
|
86
|
+
this.jobs.set(id, {
|
|
87
|
+
id,
|
|
88
|
+
name,
|
|
89
|
+
params,
|
|
90
|
+
schedule: 0,
|
|
91
|
+
nextRunAt: ts,
|
|
92
|
+
runCount: 0,
|
|
93
|
+
maxRuns: 1,
|
|
94
|
+
enabled: true,
|
|
95
|
+
});
|
|
96
|
+
return id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Cron-style schedule using a tiny parser (supports minute/hour/day/month/weekday).
|
|
101
|
+
*
|
|
102
|
+
* Format: "minute hour day-of-month month day-of-week"
|
|
103
|
+
* Use "*" for "every", comma for lists, "/" for steps.
|
|
104
|
+
*
|
|
105
|
+
* @example scheduler.cron("0 9 * * 1-5", "weekday-morning", params)
|
|
106
|
+
* @example scheduler.cron("30 8,12,18 * * *", "thrice-daily", params)
|
|
107
|
+
*/
|
|
108
|
+
cron(
|
|
109
|
+
expression: string,
|
|
110
|
+
name: string,
|
|
111
|
+
params: SendParams & Partial<SendMeta>,
|
|
112
|
+
options: { maxRuns?: number } = {}
|
|
113
|
+
): string {
|
|
114
|
+
const id = `cron:${name}`;
|
|
115
|
+
const nextRunAt = nextCronTime(expression);
|
|
116
|
+
this.jobs.set(id, {
|
|
117
|
+
id,
|
|
118
|
+
name,
|
|
119
|
+
params,
|
|
120
|
+
schedule: expression,
|
|
121
|
+
nextRunAt,
|
|
122
|
+
runCount: 0,
|
|
123
|
+
maxRuns: options.maxRuns,
|
|
124
|
+
enabled: true,
|
|
125
|
+
});
|
|
126
|
+
return id;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Control ──────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
pause(id: string): boolean {
|
|
132
|
+
const job = this.jobs.get(id);
|
|
133
|
+
if (!job) return false;
|
|
134
|
+
job.enabled = false;
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
resume(id: string): boolean {
|
|
139
|
+
const job = this.jobs.get(id);
|
|
140
|
+
if (!job) return false;
|
|
141
|
+
job.enabled = true;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
remove(id: string): boolean {
|
|
146
|
+
return this.jobs.delete(id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
list(): ScheduledJob[] {
|
|
150
|
+
return Array.from(this.jobs.values());
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
start(): this {
|
|
156
|
+
if (this.timer) return this;
|
|
157
|
+
this.timer = setInterval(() => this._tick(), this.tickMs);
|
|
158
|
+
return this;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
stop(): this {
|
|
162
|
+
if (this.timer) {
|
|
163
|
+
clearInterval(this.timer);
|
|
164
|
+
this.timer = undefined;
|
|
165
|
+
}
|
|
166
|
+
return this;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private _tick() {
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
|
|
172
|
+
for (const [id, job] of this.jobs) {
|
|
173
|
+
if (!job.enabled || job.nextRunAt > now) continue;
|
|
174
|
+
|
|
175
|
+
// Dispatch to queue
|
|
176
|
+
this.queue.enqueue(job.params);
|
|
177
|
+
job.runCount++;
|
|
178
|
+
|
|
179
|
+
// Remove one-shots / exhausted jobs
|
|
180
|
+
if (job.maxRuns !== undefined && job.runCount >= job.maxRuns) {
|
|
181
|
+
this.jobs.delete(id);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Advance nextRunAt
|
|
186
|
+
if (typeof job.schedule === "number" && job.schedule > 0) {
|
|
187
|
+
job.nextRunAt = now + job.schedule;
|
|
188
|
+
} else if (typeof job.schedule === "string") {
|
|
189
|
+
job.nextRunAt = nextCronTime(job.schedule);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Tiny cron parser ─────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
function nextCronTime(expression: string): number {
|
|
198
|
+
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] =
|
|
199
|
+
expression.trim().split(/\s+/);
|
|
200
|
+
|
|
201
|
+
const now = new Date();
|
|
202
|
+
const candidate = new Date(now);
|
|
203
|
+
candidate.setSeconds(0, 0);
|
|
204
|
+
candidate.setMinutes(candidate.getMinutes() + 1); // start from next minute
|
|
205
|
+
|
|
206
|
+
// Try up to 366 days ahead
|
|
207
|
+
for (let days = 0; days < 366 * 24 * 60; days++) {
|
|
208
|
+
const m = candidate.getMinutes();
|
|
209
|
+
const h = candidate.getHours();
|
|
210
|
+
const dom = candidate.getDate();
|
|
211
|
+
const month = candidate.getMonth() + 1;
|
|
212
|
+
const dow = candidate.getDay();
|
|
213
|
+
|
|
214
|
+
if (
|
|
215
|
+
matchField(minuteExpr, m, 0, 59) &&
|
|
216
|
+
matchField(hourExpr, h, 0, 23) &&
|
|
217
|
+
matchField(domExpr, dom, 1, 31) &&
|
|
218
|
+
matchField(monthExpr, month, 1, 12) &&
|
|
219
|
+
matchField(dowExpr, dow, 0, 6)
|
|
220
|
+
) {
|
|
221
|
+
return candidate.getTime();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw new Error(`Cannot find next occurrence for cron expression: "${expression}"`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function matchField(expr: string, value: number, min: number, max: number): boolean {
|
|
231
|
+
if (expr === "*") return true;
|
|
232
|
+
|
|
233
|
+
for (const part of expr.split(",")) {
|
|
234
|
+
if (part.includes("/")) {
|
|
235
|
+
const [range, step] = part.split("/");
|
|
236
|
+
const s = parseInt(step, 10);
|
|
237
|
+
const [lo, hi] = range === "*"
|
|
238
|
+
? [min, max]
|
|
239
|
+
: range.split("-").map(Number);
|
|
240
|
+
if (value >= lo && value <= hi && (value - lo) % s === 0) return true;
|
|
241
|
+
} else if (part.includes("-")) {
|
|
242
|
+
const [lo, hi] = part.split("-").map(Number);
|
|
243
|
+
if (value >= lo && value <= hi) return true;
|
|
244
|
+
} else {
|
|
245
|
+
if (parseInt(part, 10) === value) return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMSService — run the SDK as a standalone HTTP microservice.
|
|
3
|
+
*
|
|
4
|
+
* Exposes the full SMS API over REST so any other service (Python, Go, Ruby, …)
|
|
5
|
+
* can send messages without bundling the SDK itself.
|
|
6
|
+
*
|
|
7
|
+
* Built on Node's built-in http module — zero extra dependencies.
|
|
8
|
+
*
|
|
9
|
+
* Routes
|
|
10
|
+
* ──────
|
|
11
|
+
* POST /send — send a single message
|
|
12
|
+
* POST /send/many — send to many recipients
|
|
13
|
+
* POST /send/object — send from a plain object
|
|
14
|
+
* POST /send/object/many — send objects to many recipients
|
|
15
|
+
* POST /queue/enqueue — fire-and-forget via queue
|
|
16
|
+
* POST /queue/schedule — schedule a future send
|
|
17
|
+
* GET /queue/stats — queue stats
|
|
18
|
+
* DELETE /queue/:id — cancel a queued job
|
|
19
|
+
* GET /health — liveness check
|
|
20
|
+
* GET /metrics — metrics snapshot (if MetricsPlugin attached)
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const service = new SMSService(client, { port: 4000, apiKey: "secret" });
|
|
24
|
+
* service.withQueue(queue).withMetrics(metricsPlugin);
|
|
25
|
+
* await service.start();
|
|
26
|
+
*
|
|
27
|
+
* // From any other service:
|
|
28
|
+
* // POST http://localhost:4000/send
|
|
29
|
+
* // Headers: x-service-key: secret
|
|
30
|
+
* // Body: { "to": "+1555…", "template": "Hello {name}", "variables": { "name": "Alice" } }
|
|
31
|
+
*/
|
|
32
|
+
import {
|
|
33
|
+
createServer,
|
|
34
|
+
type IncomingMessage,
|
|
35
|
+
type ServerResponse,
|
|
36
|
+
} from "node:http";
|
|
37
|
+
import type SMSClient from "./SMSClient.js";
|
|
38
|
+
import type SMSQueue from "./SMSQueue.js";
|
|
39
|
+
import type { MetricsSnapshot } from "./PluginManager.js";
|
|
40
|
+
|
|
41
|
+
export interface SMSServiceOptions {
|
|
42
|
+
port?: number;
|
|
43
|
+
host?: string;
|
|
44
|
+
/** Shared secret callers must send in the `x-service-key` header */
|
|
45
|
+
apiKey?: string;
|
|
46
|
+
/** Request timeout ms (default: 10 000) */
|
|
47
|
+
timeout?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default class SMSService {
|
|
51
|
+
private server = createServer((req, res) => this._handle(req, res));
|
|
52
|
+
private queue?: SMSQueue;
|
|
53
|
+
private metricsSnapshot?: () => MetricsSnapshot;
|
|
54
|
+
private startedAt = new Date();
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
private client: SMSClient,
|
|
58
|
+
private options: SMSServiceOptions = {},
|
|
59
|
+
) {}
|
|
60
|
+
|
|
61
|
+
/** Attach a queue so /queue/* endpoints are available. */
|
|
62
|
+
withQueue(queue: SMSQueue): this {
|
|
63
|
+
this.queue = queue;
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Attach a MetricsPlugin to expose /metrics. */
|
|
68
|
+
withMetrics(plugin: { snapshot: () => MetricsSnapshot }): this {
|
|
69
|
+
this.metricsSnapshot = () => plugin.snapshot();
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
start(): Promise<void> {
|
|
74
|
+
const { port = 3001, host = "0.0.0.0" } = this.options;
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
this.server.listen(port, host, () => {
|
|
77
|
+
console.log(`[SMSService] Listening on http://${host}:${port}`);
|
|
78
|
+
resolve();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
stop(): Promise<void> {
|
|
84
|
+
return new Promise((resolve, reject) =>
|
|
85
|
+
this.server.close((err) => (err ? reject(err) : resolve())),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Request handler ──────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
private async _handle(req: IncomingMessage, res: ServerResponse) {
|
|
92
|
+
const { apiKey, timeout = 10_000 } = this.options;
|
|
93
|
+
|
|
94
|
+
// Auth
|
|
95
|
+
if (apiKey && req.headers["x-service-key"] !== apiKey) {
|
|
96
|
+
return this._json(res, 401, { error: "Unauthorized" });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Timeout guard
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
res.writeHead(504);
|
|
102
|
+
res.end(JSON.stringify({ error: "Gateway timeout" }));
|
|
103
|
+
}, timeout);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const url = req.url?.split("?")[0] ?? "/";
|
|
107
|
+
const method = req.method ?? "GET";
|
|
108
|
+
|
|
109
|
+
// ── GET routes ──
|
|
110
|
+
if (method === "GET") {
|
|
111
|
+
if (url === "/health") return this._health(res);
|
|
112
|
+
if (url === "/metrics") return this._metrics(res);
|
|
113
|
+
if (url === "/queue/stats") return this._queueStats(res);
|
|
114
|
+
return this._json(res, 404, { error: "Not found" });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── DELETE routes ──
|
|
118
|
+
if (method === "DELETE") {
|
|
119
|
+
const match = url.match(/^\/queue\/(.+)$/);
|
|
120
|
+
if (match) return this._queueCancel(res, match[1]);
|
|
121
|
+
return this._json(res, 404, { error: "Not found" });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── POST routes ──
|
|
125
|
+
if (method === "POST") {
|
|
126
|
+
const body = await this._readBody(req);
|
|
127
|
+
|
|
128
|
+
if (url === "/send") return this._send(res, body);
|
|
129
|
+
if (url === "/send/many") return this._sendMany(res, body);
|
|
130
|
+
if (url === "/send/object") return this._sendObject(res, body);
|
|
131
|
+
if (url === "/send/object/many") return this._sendObjectMany(res, body);
|
|
132
|
+
if (url === "/queue/enqueue") return this._enqueue(res, body);
|
|
133
|
+
if (url === "/queue/schedule") return this._schedule(res, body);
|
|
134
|
+
|
|
135
|
+
return this._json(res, 404, { error: "Not found" });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this._json(res, 405, { error: "Method not allowed" });
|
|
139
|
+
} catch (err: any) {
|
|
140
|
+
this._json(res, 500, { error: err.message ?? "Internal error" });
|
|
141
|
+
} finally {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Route handlers ────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
private async _send(res: ServerResponse, body: any) {
|
|
149
|
+
const result = await this.client.send(body);
|
|
150
|
+
this._json(res, result.ok ? 200 : 422, result);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async _sendMany(res: ServerResponse, body: any) {
|
|
154
|
+
const { recipients, template, sharedVariables } = body;
|
|
155
|
+
const result = await this.client.sendMany(
|
|
156
|
+
recipients,
|
|
157
|
+
template,
|
|
158
|
+
sharedVariables,
|
|
159
|
+
);
|
|
160
|
+
this._json(res, 200, result);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async _sendObject(res: ServerResponse, body: any) {
|
|
164
|
+
const result = await this.client.sendObject(body);
|
|
165
|
+
this._json(res, result.ok ? 200 : 422, result);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async _sendObjectMany(res: ServerResponse, body: any) {
|
|
169
|
+
const result = await this.client.sendObjectMany(body.items ?? body);
|
|
170
|
+
this._json(res, 200, result);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private _enqueue(res: ServerResponse, body: any) {
|
|
174
|
+
if (!this.queue)
|
|
175
|
+
return this._json(res, 503, { error: "Queue not configured" });
|
|
176
|
+
const id = this.queue.enqueue(body);
|
|
177
|
+
this._json(res, 202, { id, status: "queued" });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private _schedule(res: ServerResponse, body: any) {
|
|
181
|
+
if (!this.queue)
|
|
182
|
+
return this._json(res, 503, { error: "Queue not configured" });
|
|
183
|
+
const { at, delayMs, ...params } = body;
|
|
184
|
+
let id: string;
|
|
185
|
+
if (at) {
|
|
186
|
+
id = this.queue.enqueueAt(params, new Date(at).getTime());
|
|
187
|
+
} else if (delayMs) {
|
|
188
|
+
id = this.queue.enqueueAfter(params, delayMs);
|
|
189
|
+
} else {
|
|
190
|
+
return this._json(res, 400, {
|
|
191
|
+
error: "Provide 'at' (ISO date) or 'delayMs'",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
this._json(res, 202, { id, status: "scheduled" });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private _queueStats(res: ServerResponse) {
|
|
198
|
+
if (!this.queue)
|
|
199
|
+
return this._json(res, 503, { error: "Queue not configured" });
|
|
200
|
+
this._json(res, 200, this.queue.stats());
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private _queueCancel(res: ServerResponse, id: string) {
|
|
204
|
+
if (!this.queue)
|
|
205
|
+
return this._json(res, 503, { error: "Queue not configured" });
|
|
206
|
+
const ok = this.queue.cancel(id);
|
|
207
|
+
this._json(res, ok ? 200 : 404, { id, cancelled: ok });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private _health(res: ServerResponse) {
|
|
211
|
+
this._json(res, 200, {
|
|
212
|
+
status: "ok",
|
|
213
|
+
circuitState: this.client.circuitState,
|
|
214
|
+
uptime: Math.floor((Date.now() - this.startedAt.getTime()) / 1000),
|
|
215
|
+
startedAt: this.startedAt.toISOString(),
|
|
216
|
+
queue: this.queue?.stats() ?? null,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private _metrics(res: ServerResponse) {
|
|
221
|
+
if (!this.metricsSnapshot) {
|
|
222
|
+
return this._json(res, 503, {
|
|
223
|
+
error: "Metrics plugin not attached. Call .withMetrics()",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
this._json(res, 200, this.metricsSnapshot());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
private _json(res: ServerResponse, status: number, body: unknown) {
|
|
232
|
+
const payload = JSON.stringify(body);
|
|
233
|
+
res.writeHead(status, {
|
|
234
|
+
"Content-Type": "application/json",
|
|
235
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
236
|
+
});
|
|
237
|
+
res.end(payload);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private _readBody(req: IncomingMessage): Promise<any> {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const chunks: Buffer[] = [];
|
|
243
|
+
req.on("data", (c) => chunks.push(c));
|
|
244
|
+
req.on("end", () => {
|
|
245
|
+
try {
|
|
246
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
|
|
247
|
+
} catch {
|
|
248
|
+
reject(new Error("Invalid JSON body"));
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
req.on("error", reject);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { SendParams, SendObjectParams } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SendConfig — fluent builder for reusable send settings.
|
|
5
|
+
*
|
|
6
|
+
* Build once, reuse everywhere. Eliminates repeating template/variables
|
|
7
|
+
* across multiple send calls. Supports overrides per-call.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const otpConfig = new SendConfig()
|
|
11
|
+
* .template("Your verification code is {code}. Expires in {expiryMinutes} minutes.")
|
|
12
|
+
* .defaults({ expiryMinutes: 10 })
|
|
13
|
+
* .tag("otp");
|
|
14
|
+
*
|
|
15
|
+
* await client.send(otpConfig.for("+15551234567", { code: "482910" }));
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* // Object config with auto-extraction
|
|
19
|
+
* const orderConfig = new SendConfig()
|
|
20
|
+
* .template("Hi {customer.name}, your order {orderId} is {status}.")
|
|
21
|
+
* .tag("order-notification");
|
|
22
|
+
*
|
|
23
|
+
* await client.sendObject(orderConfig.forObject("+15551234567", order));
|
|
24
|
+
*/
|
|
25
|
+
export default class SendConfig {
|
|
26
|
+
private _template?: string;
|
|
27
|
+
private _defaults: Record<string, unknown> = {};
|
|
28
|
+
private _tags: string[] = [];
|
|
29
|
+
private _ttl?: number;
|
|
30
|
+
private _priority: "low" | "normal" | "high" = "normal";
|
|
31
|
+
private _dedupKey?: string;
|
|
32
|
+
|
|
33
|
+
// ── Builder methods ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** Set the message template. Supports {variable} and {nested.path} placeholders. */
|
|
36
|
+
template(tpl: string): this {
|
|
37
|
+
this._template = tpl;
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Default variable values merged under per-call variables. */
|
|
42
|
+
defaults(vars: Record<string, unknown>): this {
|
|
43
|
+
this._defaults = { ...this._defaults, ...vars };
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Add a single default variable. */
|
|
48
|
+
set(key: string, value: unknown): this {
|
|
49
|
+
this._defaults[key] = value;
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Tag this config (useful for logging, filtering, hooks). */
|
|
54
|
+
tag(...tags: string[]): this {
|
|
55
|
+
this._tags.push(...tags);
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Time-to-live in milliseconds.
|
|
61
|
+
* Messages scheduled/queued past this age will be dropped.
|
|
62
|
+
*/
|
|
63
|
+
ttl(ms: number): this {
|
|
64
|
+
this._ttl = ms;
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Queue priority. High-priority jobs jump the queue. */
|
|
69
|
+
priority(p: "low" | "normal" | "high"): this {
|
|
70
|
+
this._priority = p;
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Deduplication key.
|
|
76
|
+
* If two messages with the same dedupKey are enqueued within the TTL window,
|
|
77
|
+
* the second is silently dropped.
|
|
78
|
+
*/
|
|
79
|
+
dedupKey(key: string): this {
|
|
80
|
+
this._dedupKey = key;
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Materialise ──────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/** Build a SendParams for a single recipient. Variables override defaults. */
|
|
87
|
+
for(to: string, variables?: Record<string, unknown>): SendParams & SendMeta {
|
|
88
|
+
if (!this._template) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
"SendConfig: template is required. Call .template() first.",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
to,
|
|
95
|
+
template: this._template,
|
|
96
|
+
variables: { ...this._defaults, ...variables },
|
|
97
|
+
_meta: this.meta(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Build a SendObjectParams for a single recipient. */
|
|
102
|
+
forObject<T extends Record<string, unknown>>(
|
|
103
|
+
to: string,
|
|
104
|
+
object: T,
|
|
105
|
+
): SendObjectParams<T> & SendMeta {
|
|
106
|
+
return {
|
|
107
|
+
to,
|
|
108
|
+
object,
|
|
109
|
+
template: this._template,
|
|
110
|
+
_meta: this.meta(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build SendParams for many recipients from an array.
|
|
116
|
+
* Each recipient entry can supply per-recipient variables.
|
|
117
|
+
*/
|
|
118
|
+
forMany(
|
|
119
|
+
recipients: Array<{ to: string; variables?: Record<string, unknown> }>,
|
|
120
|
+
): Array<SendParams & SendMeta> {
|
|
121
|
+
return recipients.map((r) => this.for(r.to, r.variables));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Produce the metadata snapshot for queuing / logging. */
|
|
125
|
+
meta(): SendMeta["_meta"] {
|
|
126
|
+
return {
|
|
127
|
+
tags: [...this._tags],
|
|
128
|
+
ttl: this._ttl,
|
|
129
|
+
priority: this._priority,
|
|
130
|
+
dedupKey: this._dedupKey,
|
|
131
|
+
createdAt: Date.now(),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Presets ──────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/** Clone this config so you can branch without mutating the original. */
|
|
138
|
+
clone(): SendConfig {
|
|
139
|
+
const c = new SendConfig();
|
|
140
|
+
c._template = this._template;
|
|
141
|
+
c._defaults = { ...this._defaults };
|
|
142
|
+
c._tags = [...this._tags];
|
|
143
|
+
c._ttl = this._ttl;
|
|
144
|
+
c._priority = this._priority;
|
|
145
|
+
c._dedupKey = this._dedupKey;
|
|
146
|
+
return c;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Merge another config on top of this one (other wins on conflicts).
|
|
151
|
+
* Returns a new config — neither is mutated.
|
|
152
|
+
*/
|
|
153
|
+
merge(other: SendConfig): SendConfig {
|
|
154
|
+
return this.clone()
|
|
155
|
+
.template(other._template ?? this._template ?? "")
|
|
156
|
+
.defaults({ ...this._defaults, ...other._defaults })
|
|
157
|
+
.tag(...other._tags)
|
|
158
|
+
.priority(other._priority);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Static factories ─────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/** Create an OTP config. */
|
|
164
|
+
static otp(expiryMinutes = 10): SendConfig {
|
|
165
|
+
return new SendConfig()
|
|
166
|
+
.template(
|
|
167
|
+
"Your verification code is {code}. Expires in {expiryMinutes} minutes.",
|
|
168
|
+
)
|
|
169
|
+
.defaults({ expiryMinutes })
|
|
170
|
+
.tag("otp")
|
|
171
|
+
.ttl(expiryMinutes * 60 * 1000)
|
|
172
|
+
.priority("high");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Create an order-notification config. */
|
|
176
|
+
static orderNotification(): SendConfig {
|
|
177
|
+
return new SendConfig()
|
|
178
|
+
.template("Hi {name}, your order #{orderId} is now {status}.")
|
|
179
|
+
.tag("order")
|
|
180
|
+
.priority("normal");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Create a reminder config. */
|
|
184
|
+
static reminder(): SendConfig {
|
|
185
|
+
return new SendConfig()
|
|
186
|
+
.template("Reminder: {message}")
|
|
187
|
+
.tag("reminder")
|
|
188
|
+
.priority("low");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Create a marketing/promo config. */
|
|
192
|
+
static promo(): SendConfig {
|
|
193
|
+
return new SendConfig()
|
|
194
|
+
.template("{promoMessage}")
|
|
195
|
+
.tag("promo", "marketing")
|
|
196
|
+
.priority("low");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface SendMeta {
|
|
201
|
+
_meta: {
|
|
202
|
+
tags: string[];
|
|
203
|
+
ttl?: number;
|
|
204
|
+
priority: "low" | "normal" | "high";
|
|
205
|
+
dedupKey?: string;
|
|
206
|
+
createdAt: number;
|
|
207
|
+
};
|
|
208
|
+
}
|