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.
Files changed (47) hide show
  1. package/README.md +525 -0
  2. package/dist/CircuitBreaker.d.ts +25 -0
  3. package/dist/CircuitBreaker.js +58 -0
  4. package/dist/CredentialProvisioner.d.ts +27 -0
  5. package/dist/CredentialProvisioner.js +43 -0
  6. package/dist/MessageFormatter.d.ts +13 -1
  7. package/dist/MessageFormatter.js +30 -6
  8. package/dist/PluginManager.d.ts +77 -0
  9. package/dist/PluginManager.js +101 -0
  10. package/dist/RetryHandler.d.ts +8 -1
  11. package/dist/RetryHandler.js +28 -6
  12. package/dist/SMSClient.d.ts +65 -0
  13. package/dist/SMSClient.js +236 -0
  14. package/dist/SMSQueue.d.ts +82 -0
  15. package/dist/SMSQueue.js +207 -0
  16. package/dist/SMSScheduler.d.ts +75 -0
  17. package/dist/SMSScheduler.js +196 -0
  18. package/dist/SMSService.d.ts +41 -0
  19. package/dist/SMSService.js +214 -0
  20. package/dist/SendConfig.d.ts +90 -0
  21. package/dist/SendConfig.js +165 -0
  22. package/dist/errors.d.ts +18 -1
  23. package/dist/errors.js +28 -5
  24. package/dist/helpers.d.ts +3 -0
  25. package/dist/helpers.js +23 -0
  26. package/dist/main.d.ts +20 -1
  27. package/dist/main.js +19 -1
  28. package/dist/types.d.ts +43 -0
  29. package/dist/types.js +1 -0
  30. package/package.json +1 -1
  31. package/src/CircuitBreaker.ts +81 -0
  32. package/src/CredentialProvisioner.ts +68 -0
  33. package/src/MessageFormatter.ts +38 -7
  34. package/src/PluginManager.ts +155 -0
  35. package/src/RetryHandler.ts +42 -9
  36. package/src/SMSClient.ts +308 -0
  37. package/src/SMSQueue.ts +281 -0
  38. package/src/SMSScheduler.ts +250 -0
  39. package/src/SMSService.ts +254 -0
  40. package/src/SendConfig.ts +208 -0
  41. package/src/errors.ts +40 -6
  42. package/src/helpers.ts +31 -0
  43. package/src/main.ts +61 -1
  44. package/src/types.ts +33 -0
  45. package/src/client/SMSManager.ts +0 -67
  46. package/src/client/types.ts +0 -24
  47. package/src/help.ts +0 -3
@@ -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
+ }