bridgex 2.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.
@@ -0,0 +1,236 @@
1
+ import HttpClient from "./HttpClient.js";
2
+ import RetryHandler from "./RetryHandler.js";
3
+ import CircuitBreaker from "./CircuitBreaker.js";
4
+ import MessageFormatter from "./MessageFormatter.js";
5
+ import PluginManager from "./PluginManager.js";
6
+ import { ValidationError } from "./errors.js";
7
+ import { chunkArray } from "./helpers.js";
8
+ function toErrorLog(error) {
9
+ if (error instanceof Error) {
10
+ const e = error;
11
+ return {
12
+ name: e.name ?? "Error",
13
+ message: e.message,
14
+ code: e.code ?? "UNKNOWN",
15
+ isClientError: e.isClientError ?? false,
16
+ isServerError: e.isServerError ?? false,
17
+ details: e.details,
18
+ timestamp: new Date().toISOString(),
19
+ };
20
+ }
21
+ return {
22
+ name: "UnknownError",
23
+ message: String(error),
24
+ code: "UNKNOWN",
25
+ isClientError: false,
26
+ isServerError: false,
27
+ timestamp: new Date().toISOString(),
28
+ };
29
+ }
30
+ function ok(data) {
31
+ return { ok: true, data, error: null };
32
+ }
33
+ function fail(error) {
34
+ return { ok: false, data: null, error: toErrorLog(error) };
35
+ }
36
+ export default class SMSClient {
37
+ constructor(options) {
38
+ this.options = options;
39
+ this.validateOptions(options);
40
+ this.http = new HttpClient({
41
+ baseUrl: options.baseUrl,
42
+ apiKey: options.apiKey,
43
+ projectKey: options.projectKey,
44
+ });
45
+ this.retry = new RetryHandler(options.retry);
46
+ this.circuit = new CircuitBreaker(options.circuitBreaker);
47
+ this.formatter = new MessageFormatter();
48
+ this.plugins = new PluginManager();
49
+ this.concurrency = options.concurrency ?? 5;
50
+ this.batchSize = options.batchSize ?? 50;
51
+ options.plugins?.forEach((p) => this.plugins.use(p));
52
+ }
53
+ /** Attach a plugin at any time. */
54
+ use(plugin) {
55
+ this.plugins.use(plugin);
56
+ return this;
57
+ }
58
+ // ─────────────────────────────────────────────────────────────────────────
59
+ // Core internal send — all public methods funnel through here
60
+ // ─────────────────────────────────────────────────────────────────────────
61
+ async _send(to, message, tags = []) {
62
+ const start = Date.now();
63
+ await this.plugins.fire("onSend", { to, message, tags });
64
+ try {
65
+ const data = await this.circuit.execute(() => this.retry.execute(() => this.http.post("/sms/send", { to, message })));
66
+ await this.plugins.fire("onSuccess", {
67
+ to,
68
+ message,
69
+ tags,
70
+ data,
71
+ durationMs: Date.now() - start,
72
+ });
73
+ return data;
74
+ }
75
+ catch (err) {
76
+ await this.plugins.fire("onError", {
77
+ to,
78
+ message,
79
+ tags,
80
+ error: toErrorLog(err),
81
+ durationMs: Date.now() - start,
82
+ });
83
+ throw err;
84
+ }
85
+ }
86
+ // ─────────────────────────────────────────────────────────────────────────
87
+ // Public API
88
+ // ─────────────────────────────────────────────────────────────────────────
89
+ /**
90
+ * Send a single SMS message.
91
+ * Returns a Result — never throws.
92
+ */
93
+ async send(params) {
94
+ const { to, template, variables = {} } = params;
95
+ const tags = params._meta?.tags ?? [];
96
+ try {
97
+ if (!to)
98
+ throw new ValidationError("Recipient phone number is required");
99
+ const message = this.formatter.format(template, variables);
100
+ const data = await this._send(to, message, tags);
101
+ return ok(data);
102
+ }
103
+ catch (error) {
104
+ return fail(error);
105
+ }
106
+ }
107
+ /**
108
+ * Send the same template to many recipients.
109
+ * Each recipient can supply its own variables; shared variables are the fallback.
110
+ */
111
+ async sendMany(recipients, template, sharedVariables = {}) {
112
+ const result = {
113
+ succeeded: [],
114
+ failed: [],
115
+ total: recipients.length,
116
+ successCount: 0,
117
+ failureCount: 0,
118
+ };
119
+ const chunks = chunkArray(recipients, this.batchSize);
120
+ for (const chunk of chunks) {
121
+ const queue = [...chunk.entries()];
122
+ await Promise.all(new Array(this.concurrency).fill(null).map(async () => {
123
+ while (queue.length > 0) {
124
+ const entry = queue.shift();
125
+ if (!entry)
126
+ break;
127
+ const [chunkIndex, recipient] = entry;
128
+ const globalIndex = chunks.indexOf(chunk) * this.batchSize + chunkIndex;
129
+ const variables = { ...sharedVariables, ...recipient.variables };
130
+ const res = await this.send({
131
+ to: recipient.to,
132
+ template,
133
+ variables,
134
+ });
135
+ if (res.ok) {
136
+ result.succeeded.push({
137
+ index: globalIndex,
138
+ to: recipient.to,
139
+ data: res.data,
140
+ });
141
+ result.successCount++;
142
+ }
143
+ else {
144
+ result.failed.push({
145
+ index: globalIndex,
146
+ to: recipient.to,
147
+ error: res.error,
148
+ });
149
+ result.failureCount++;
150
+ }
151
+ }
152
+ }));
153
+ }
154
+ return result;
155
+ }
156
+ /**
157
+ * Send a message derived from a plain object.
158
+ * Auto-detects message from message/body/text/content fields,
159
+ * or formats via an optional template using the object's keys.
160
+ */
161
+ async sendObject(params) {
162
+ const { to, object, template } = params;
163
+ const tags = params._meta?.tags ?? [];
164
+ try {
165
+ if (!to)
166
+ throw new ValidationError("Recipient phone number is required");
167
+ const { message } = this.formatter.fromObject(object, template);
168
+ const data = await this._send(to, message, tags);
169
+ return ok(data);
170
+ }
171
+ catch (error) {
172
+ return fail(error);
173
+ }
174
+ }
175
+ /**
176
+ * Send object-derived messages to many recipients.
177
+ */
178
+ async sendObjectMany(items) {
179
+ const result = {
180
+ succeeded: [],
181
+ failed: [],
182
+ total: items.length,
183
+ successCount: 0,
184
+ failureCount: 0,
185
+ };
186
+ const chunks = chunkArray(items, this.batchSize);
187
+ for (const chunk of chunks) {
188
+ const queue = [...chunk.entries()];
189
+ await Promise.all(new Array(this.concurrency).fill(null).map(async () => {
190
+ while (queue.length > 0) {
191
+ const entry = queue.shift();
192
+ if (!entry)
193
+ break;
194
+ const [chunkIndex, item] = entry;
195
+ const globalIndex = chunks.indexOf(chunk) * this.batchSize + chunkIndex;
196
+ const res = await this.sendObject(item);
197
+ if (res.ok) {
198
+ result.succeeded.push({
199
+ index: globalIndex,
200
+ to: item.to,
201
+ data: res.data,
202
+ });
203
+ result.successCount++;
204
+ }
205
+ else {
206
+ result.failed.push({
207
+ index: globalIndex,
208
+ to: item.to,
209
+ error: res.error,
210
+ });
211
+ result.failureCount++;
212
+ }
213
+ }
214
+ }));
215
+ }
216
+ return result;
217
+ }
218
+ /** Current circuit breaker state. */
219
+ get circuitState() {
220
+ return this.circuit.currentState;
221
+ }
222
+ /** Manually reset the circuit breaker (e.g. after fixing a downstream issue). */
223
+ resetCircuit() {
224
+ this.circuit.reset();
225
+ }
226
+ // ─────────────────────────────────────────────────────────────────────────
227
+ validateOptions(options) {
228
+ const { baseUrl, apiKey, projectKey } = options;
229
+ if (!baseUrl)
230
+ throw new ValidationError("baseUrl is required");
231
+ if (!apiKey)
232
+ throw new ValidationError("apiKey is required");
233
+ if (!projectKey)
234
+ throw new ValidationError("projectKey is required");
235
+ }
236
+ }
@@ -0,0 +1,82 @@
1
+ import type SMSClient from "./SMSClient.js";
2
+ import type PluginManager from "./PluginManager.js";
3
+ import type { SendParams } from "./types.js";
4
+ import type { SendMeta } from "./SendConfig.js";
5
+ export interface QueueJob {
6
+ id: string;
7
+ params: SendParams;
8
+ meta: SendMeta["_meta"];
9
+ scheduledAt: number;
10
+ enqueuedAt: number;
11
+ attempts: number;
12
+ }
13
+ export interface QueueOptions {
14
+ /** Max concurrent workers processing the queue (default: 3) */
15
+ concurrency?: number;
16
+ /** How often (ms) the queue polls for scheduled jobs (default: 1000) */
17
+ pollInterval?: number;
18
+ /** Whether to start processing automatically on first enqueue (default: true) */
19
+ autoStart?: boolean;
20
+ }
21
+ export interface QueueStats {
22
+ pending: number;
23
+ running: number;
24
+ completed: number;
25
+ dropped: number;
26
+ }
27
+ /**
28
+ * SMSQueue — an in-process job queue for fire-and-forget or scheduled SMS.
29
+ *
30
+ * Features:
31
+ * - Priority lanes (high / normal / low)
32
+ * - Deduplication via dedupKey
33
+ * - TTL — drops stale jobs before sending
34
+ * - Scheduled sends (send at a future timestamp)
35
+ * - Concurrency-limited workers
36
+ * - Plugin hooks (onDrop, onSuccess, onError, onRetry)
37
+ *
38
+ * @example
39
+ * const queue = new SMSQueue(client, { concurrency: 5 });
40
+ * queue.start();
41
+ *
42
+ * const cfg = SendConfig.otp().dedupKey(`otp:${userId}`);
43
+ * await queue.enqueue(cfg.for(phone, { code }));
44
+ *
45
+ * // Schedule for later
46
+ * await queue.enqueueAt(cfg.for(phone, { code }), Date.now() + 60_000);
47
+ */
48
+ export default class SMSQueue {
49
+ private client;
50
+ private jobs;
51
+ private dedupSet;
52
+ private running;
53
+ private completed;
54
+ private dropped;
55
+ private pollTimer?;
56
+ private readonly concurrency;
57
+ private readonly pollInterval;
58
+ private readonly autoStart;
59
+ private plugins?;
60
+ constructor(client: SMSClient, options?: QueueOptions, plugins?: PluginManager);
61
+ /** Enqueue a job for immediate (or next-available) processing. */
62
+ enqueue(params: SendParams & Partial<SendMeta>): string;
63
+ /** Enqueue a job to send at a specific future timestamp (epoch ms). */
64
+ enqueueAt(params: SendParams & Partial<SendMeta>, timestamp: number): string;
65
+ /** Enqueue a job to send after a delay (ms). */
66
+ enqueueAfter(params: SendParams & Partial<SendMeta>, delayMs: number): string;
67
+ private _enqueue;
68
+ private _insertByPriority;
69
+ /** Start the queue workers. */
70
+ start(): this;
71
+ /** Drain the queue (process remaining jobs) then stop. */
72
+ drain(): Promise<void>;
73
+ /** Stop polling. In-flight jobs still complete. */
74
+ stop(): this;
75
+ private _tick;
76
+ private _process;
77
+ stats(): QueueStats;
78
+ /** Cancel a job by id. Returns true if found and removed. */
79
+ cancel(id: string): boolean;
80
+ /** Clear all pending (not in-flight) jobs. */
81
+ clear(): number;
82
+ }
@@ -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
+ }