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/src/SMSClient.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import HttpClient from "./HttpClient.js";
|
|
2
|
+
import RetryHandler, { type RetryOptions } from "./RetryHandler.js";
|
|
3
|
+
import CircuitBreaker, {
|
|
4
|
+
type CircuitBreakerOptions,
|
|
5
|
+
} from "./CircuitBreaker.js";
|
|
6
|
+
import MessageFormatter from "./MessageFormatter.js";
|
|
7
|
+
import PluginManager, { type Plugin } from "./PluginManager.js";
|
|
8
|
+
import { ValidationError } from "./errors.js";
|
|
9
|
+
import { chunkArray } from "./helpers.js";
|
|
10
|
+
import type {
|
|
11
|
+
Result,
|
|
12
|
+
ErrorLog,
|
|
13
|
+
SendParams,
|
|
14
|
+
SendObjectParams,
|
|
15
|
+
BatchResult,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
18
|
+
export interface SMSClientOptions {
|
|
19
|
+
baseUrl: string;
|
|
20
|
+
apiKey: string;
|
|
21
|
+
projectKey: string;
|
|
22
|
+
retry?: RetryOptions;
|
|
23
|
+
circuitBreaker?: CircuitBreakerOptions;
|
|
24
|
+
/** Max concurrent requests when sending in bulk (default: 5) */
|
|
25
|
+
concurrency?: number;
|
|
26
|
+
/** Chunk size for sendMany (default: 50) */
|
|
27
|
+
batchSize?: number;
|
|
28
|
+
/** Plugins to attach at construction time */
|
|
29
|
+
plugins?: Plugin[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toErrorLog(error: unknown): ErrorLog {
|
|
33
|
+
if (error instanceof Error) {
|
|
34
|
+
const e = error as any;
|
|
35
|
+
return {
|
|
36
|
+
name: e.name ?? "Error",
|
|
37
|
+
message: e.message,
|
|
38
|
+
code: e.code ?? "UNKNOWN",
|
|
39
|
+
isClientError: e.isClientError ?? false,
|
|
40
|
+
isServerError: e.isServerError ?? false,
|
|
41
|
+
details: e.details,
|
|
42
|
+
timestamp: new Date().toISOString(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
name: "UnknownError",
|
|
47
|
+
message: String(error),
|
|
48
|
+
code: "UNKNOWN",
|
|
49
|
+
isClientError: false,
|
|
50
|
+
isServerError: false,
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ok<T>(data: T): Result<T> {
|
|
56
|
+
return { ok: true, data, error: null };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fail<T>(error: unknown): Result<T> {
|
|
60
|
+
return { ok: false, data: null, error: toErrorLog(error) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default class SMSClient {
|
|
64
|
+
private http: HttpClient;
|
|
65
|
+
private retry: RetryHandler;
|
|
66
|
+
private circuit: CircuitBreaker;
|
|
67
|
+
private formatter: MessageFormatter;
|
|
68
|
+
readonly plugins: PluginManager;
|
|
69
|
+
private concurrency: number;
|
|
70
|
+
private batchSize: number;
|
|
71
|
+
|
|
72
|
+
constructor(private options: SMSClientOptions) {
|
|
73
|
+
this.validateOptions(options);
|
|
74
|
+
|
|
75
|
+
this.http = new HttpClient({
|
|
76
|
+
baseUrl: options.baseUrl,
|
|
77
|
+
apiKey: options.apiKey,
|
|
78
|
+
projectKey: options.projectKey,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
this.retry = new RetryHandler(options.retry);
|
|
82
|
+
this.circuit = new CircuitBreaker(options.circuitBreaker);
|
|
83
|
+
this.formatter = new MessageFormatter();
|
|
84
|
+
this.plugins = new PluginManager();
|
|
85
|
+
this.concurrency = options.concurrency ?? 5;
|
|
86
|
+
this.batchSize = options.batchSize ?? 50;
|
|
87
|
+
|
|
88
|
+
options.plugins?.forEach((p) => this.plugins.use(p));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Attach a plugin at any time. */
|
|
92
|
+
use(plugin: Plugin): this {
|
|
93
|
+
this.plugins.use(plugin);
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
98
|
+
// Core internal send — all public methods funnel through here
|
|
99
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
private async _send(
|
|
102
|
+
to: string,
|
|
103
|
+
message: string,
|
|
104
|
+
tags: string[] = [],
|
|
105
|
+
): Promise<any> {
|
|
106
|
+
const start = Date.now();
|
|
107
|
+
await this.plugins.fire("onSend", { to, message, tags });
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const data = await this.circuit.execute(() =>
|
|
111
|
+
this.retry.execute(() => this.http.post("/sms/send", { to, message })),
|
|
112
|
+
);
|
|
113
|
+
await this.plugins.fire("onSuccess", {
|
|
114
|
+
to,
|
|
115
|
+
message,
|
|
116
|
+
tags,
|
|
117
|
+
data,
|
|
118
|
+
durationMs: Date.now() - start,
|
|
119
|
+
});
|
|
120
|
+
return data;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
await this.plugins.fire("onError", {
|
|
123
|
+
to,
|
|
124
|
+
message,
|
|
125
|
+
tags,
|
|
126
|
+
error: toErrorLog(err),
|
|
127
|
+
durationMs: Date.now() - start,
|
|
128
|
+
});
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
134
|
+
// Public API
|
|
135
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Send a single SMS message.
|
|
139
|
+
* Returns a Result — never throws.
|
|
140
|
+
*/
|
|
141
|
+
async send(params: SendParams & { _meta?: any }): Promise<Result<any>> {
|
|
142
|
+
const { to, template, variables = {} } = params;
|
|
143
|
+
const tags = params._meta?.tags ?? [];
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
if (!to) throw new ValidationError("Recipient phone number is required");
|
|
147
|
+
const message = this.formatter.format(template, variables);
|
|
148
|
+
const data = await this._send(to, message, tags);
|
|
149
|
+
return ok(data);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return fail(error);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Send the same template to many recipients.
|
|
157
|
+
* Each recipient can supply its own variables; shared variables are the fallback.
|
|
158
|
+
*/
|
|
159
|
+
async sendMany(
|
|
160
|
+
recipients: Array<{ to: string; variables?: Record<string, unknown> }>,
|
|
161
|
+
template: string,
|
|
162
|
+
sharedVariables: Record<string, unknown> = {},
|
|
163
|
+
): Promise<BatchResult<any>> {
|
|
164
|
+
const result: BatchResult<any> = {
|
|
165
|
+
succeeded: [],
|
|
166
|
+
failed: [],
|
|
167
|
+
total: recipients.length,
|
|
168
|
+
successCount: 0,
|
|
169
|
+
failureCount: 0,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const chunks = chunkArray(recipients, this.batchSize);
|
|
173
|
+
|
|
174
|
+
for (const chunk of chunks) {
|
|
175
|
+
const queue = [...chunk.entries()];
|
|
176
|
+
|
|
177
|
+
await Promise.all(
|
|
178
|
+
new Array(this.concurrency).fill(null).map(async () => {
|
|
179
|
+
while (queue.length > 0) {
|
|
180
|
+
const entry = queue.shift();
|
|
181
|
+
if (!entry) break;
|
|
182
|
+
|
|
183
|
+
const [chunkIndex, recipient] = entry;
|
|
184
|
+
const globalIndex =
|
|
185
|
+
chunks.indexOf(chunk) * this.batchSize + chunkIndex;
|
|
186
|
+
const variables = { ...sharedVariables, ...recipient.variables };
|
|
187
|
+
const res = await this.send({
|
|
188
|
+
to: recipient.to,
|
|
189
|
+
template,
|
|
190
|
+
variables,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (res.ok) {
|
|
194
|
+
result.succeeded.push({
|
|
195
|
+
index: globalIndex,
|
|
196
|
+
to: recipient.to,
|
|
197
|
+
data: res.data,
|
|
198
|
+
});
|
|
199
|
+
result.successCount++;
|
|
200
|
+
} else {
|
|
201
|
+
result.failed.push({
|
|
202
|
+
index: globalIndex,
|
|
203
|
+
to: recipient.to,
|
|
204
|
+
error: res.error,
|
|
205
|
+
});
|
|
206
|
+
result.failureCount++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Send a message derived from a plain object.
|
|
218
|
+
* Auto-detects message from message/body/text/content fields,
|
|
219
|
+
* or formats via an optional template using the object's keys.
|
|
220
|
+
*/
|
|
221
|
+
async sendObject<T extends Record<string, unknown>>(
|
|
222
|
+
params: SendObjectParams<T> & { _meta?: any },
|
|
223
|
+
): Promise<Result<any>> {
|
|
224
|
+
const { to, object, template } = params;
|
|
225
|
+
const tags = params._meta?.tags ?? [];
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
if (!to) throw new ValidationError("Recipient phone number is required");
|
|
229
|
+
const { message } = this.formatter.fromObject(object, template);
|
|
230
|
+
const data = await this._send(to, message, tags);
|
|
231
|
+
return ok(data);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return fail(error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Send object-derived messages to many recipients.
|
|
239
|
+
*/
|
|
240
|
+
async sendObjectMany<T extends Record<string, unknown>>(
|
|
241
|
+
items: Array<SendObjectParams<T> & { _meta?: any }>,
|
|
242
|
+
): Promise<BatchResult<any>> {
|
|
243
|
+
const result: BatchResult<any> = {
|
|
244
|
+
succeeded: [],
|
|
245
|
+
failed: [],
|
|
246
|
+
total: items.length,
|
|
247
|
+
successCount: 0,
|
|
248
|
+
failureCount: 0,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const chunks = chunkArray(items, this.batchSize);
|
|
252
|
+
|
|
253
|
+
for (const chunk of chunks) {
|
|
254
|
+
const queue = [...chunk.entries()];
|
|
255
|
+
|
|
256
|
+
await Promise.all(
|
|
257
|
+
new Array(this.concurrency).fill(null).map(async () => {
|
|
258
|
+
while (queue.length > 0) {
|
|
259
|
+
const entry = queue.shift();
|
|
260
|
+
if (!entry) break;
|
|
261
|
+
|
|
262
|
+
const [chunkIndex, item] = entry;
|
|
263
|
+
const globalIndex =
|
|
264
|
+
chunks.indexOf(chunk) * this.batchSize + chunkIndex;
|
|
265
|
+
const res = await this.sendObject(item);
|
|
266
|
+
|
|
267
|
+
if (res.ok) {
|
|
268
|
+
result.succeeded.push({
|
|
269
|
+
index: globalIndex,
|
|
270
|
+
to: item.to,
|
|
271
|
+
data: res.data,
|
|
272
|
+
});
|
|
273
|
+
result.successCount++;
|
|
274
|
+
} else {
|
|
275
|
+
result.failed.push({
|
|
276
|
+
index: globalIndex,
|
|
277
|
+
to: item.to,
|
|
278
|
+
error: res.error,
|
|
279
|
+
});
|
|
280
|
+
result.failureCount++;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Current circuit breaker state. */
|
|
291
|
+
get circuitState() {
|
|
292
|
+
return this.circuit.currentState;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Manually reset the circuit breaker (e.g. after fixing a downstream issue). */
|
|
296
|
+
resetCircuit() {
|
|
297
|
+
this.circuit.reset();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
private validateOptions(options: SMSClientOptions): void {
|
|
303
|
+
const { baseUrl, apiKey, projectKey } = options;
|
|
304
|
+
if (!baseUrl) throw new ValidationError("baseUrl is required");
|
|
305
|
+
if (!apiKey) throw new ValidationError("apiKey is required");
|
|
306
|
+
if (!projectKey) throw new ValidationError("projectKey is required");
|
|
307
|
+
}
|
|
308
|
+
}
|
package/src/SMSQueue.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import type SMSClient from "./SMSClient.js";
|
|
2
|
+
import type PluginManager from "./PluginManager.js";
|
|
3
|
+
import type { SendParams, Result } from "./types.js";
|
|
4
|
+
import type { SendMeta } from "./SendConfig.js";
|
|
5
|
+
|
|
6
|
+
type Priority = "low" | "normal" | "high";
|
|
7
|
+
const PRIORITY_WEIGHT: Record<Priority, number> = {
|
|
8
|
+
high: 3,
|
|
9
|
+
normal: 2,
|
|
10
|
+
low: 1,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface QueueJob {
|
|
14
|
+
id: string;
|
|
15
|
+
params: SendParams;
|
|
16
|
+
meta: SendMeta["_meta"];
|
|
17
|
+
scheduledAt: number; // epoch ms — 0 = immediately
|
|
18
|
+
enqueuedAt: number;
|
|
19
|
+
attempts: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface QueueOptions {
|
|
23
|
+
/** Max concurrent workers processing the queue (default: 3) */
|
|
24
|
+
concurrency?: number;
|
|
25
|
+
/** How often (ms) the queue polls for scheduled jobs (default: 1000) */
|
|
26
|
+
pollInterval?: number;
|
|
27
|
+
/** Whether to start processing automatically on first enqueue (default: true) */
|
|
28
|
+
autoStart?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface QueueStats {
|
|
32
|
+
pending: number;
|
|
33
|
+
running: number;
|
|
34
|
+
completed: number;
|
|
35
|
+
dropped: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* SMSQueue — an in-process job queue for fire-and-forget or scheduled SMS.
|
|
40
|
+
*
|
|
41
|
+
* Features:
|
|
42
|
+
* - Priority lanes (high / normal / low)
|
|
43
|
+
* - Deduplication via dedupKey
|
|
44
|
+
* - TTL — drops stale jobs before sending
|
|
45
|
+
* - Scheduled sends (send at a future timestamp)
|
|
46
|
+
* - Concurrency-limited workers
|
|
47
|
+
* - Plugin hooks (onDrop, onSuccess, onError, onRetry)
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* const queue = new SMSQueue(client, { concurrency: 5 });
|
|
51
|
+
* queue.start();
|
|
52
|
+
*
|
|
53
|
+
* const cfg = SendConfig.otp().dedupKey(`otp:${userId}`);
|
|
54
|
+
* await queue.enqueue(cfg.for(phone, { code }));
|
|
55
|
+
*
|
|
56
|
+
* // Schedule for later
|
|
57
|
+
* await queue.enqueueAt(cfg.for(phone, { code }), Date.now() + 60_000);
|
|
58
|
+
*/
|
|
59
|
+
export default class SMSQueue {
|
|
60
|
+
private jobs: QueueJob[] = [];
|
|
61
|
+
private dedupSet = new Set<string>();
|
|
62
|
+
private running = 0;
|
|
63
|
+
private completed = 0;
|
|
64
|
+
private dropped = 0;
|
|
65
|
+
private pollTimer?: ReturnType<typeof setInterval>;
|
|
66
|
+
private readonly concurrency: number;
|
|
67
|
+
private readonly pollInterval: number;
|
|
68
|
+
private readonly autoStart: boolean;
|
|
69
|
+
private plugins?: PluginManager;
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
private client: SMSClient,
|
|
73
|
+
options: QueueOptions = {},
|
|
74
|
+
plugins?: PluginManager,
|
|
75
|
+
) {
|
|
76
|
+
this.concurrency = options.concurrency ?? 3;
|
|
77
|
+
this.pollInterval = options.pollInterval ?? 1000;
|
|
78
|
+
this.autoStart = options.autoStart ?? true;
|
|
79
|
+
this.plugins = plugins;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Enqueue ──────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/** Enqueue a job for immediate (or next-available) processing. */
|
|
85
|
+
enqueue(params: SendParams & Partial<SendMeta>): string {
|
|
86
|
+
return this._enqueue(params, 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Enqueue a job to send at a specific future timestamp (epoch ms). */
|
|
90
|
+
enqueueAt(params: SendParams & Partial<SendMeta>, timestamp: number): string {
|
|
91
|
+
return this._enqueue(params, timestamp);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Enqueue a job to send after a delay (ms). */
|
|
95
|
+
enqueueAfter(
|
|
96
|
+
params: SendParams & Partial<SendMeta>,
|
|
97
|
+
delayMs: number,
|
|
98
|
+
): string {
|
|
99
|
+
return this._enqueue(params, Date.now() + delayMs);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private _enqueue(
|
|
103
|
+
params: SendParams & Partial<SendMeta>,
|
|
104
|
+
scheduledAt: number,
|
|
105
|
+
): string {
|
|
106
|
+
const meta = (params as any)._meta ?? {
|
|
107
|
+
tags: [],
|
|
108
|
+
priority: "normal" as Priority,
|
|
109
|
+
createdAt: Date.now(),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Deduplication
|
|
113
|
+
if (meta.dedupKey) {
|
|
114
|
+
if (this.dedupSet.has(meta.dedupKey)) {
|
|
115
|
+
this.dropped++;
|
|
116
|
+
this.plugins?.fire("onDrop", {
|
|
117
|
+
to: params.to,
|
|
118
|
+
tags: meta.tags,
|
|
119
|
+
reason: "dedup",
|
|
120
|
+
});
|
|
121
|
+
return meta.dedupKey; // return key so caller can identify it
|
|
122
|
+
}
|
|
123
|
+
this.dedupSet.add(meta.dedupKey);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const id =
|
|
127
|
+
meta.dedupKey ??
|
|
128
|
+
`job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
129
|
+
|
|
130
|
+
const job: QueueJob = {
|
|
131
|
+
id,
|
|
132
|
+
params,
|
|
133
|
+
meta,
|
|
134
|
+
scheduledAt,
|
|
135
|
+
enqueuedAt: Date.now(),
|
|
136
|
+
attempts: 0,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
this._insertByPriority(job);
|
|
140
|
+
|
|
141
|
+
if (this.autoStart && !this.pollTimer) {
|
|
142
|
+
this.start();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return id;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private _insertByPriority(job: QueueJob) {
|
|
149
|
+
const w = PRIORITY_WEIGHT[job.meta.priority as Priority] ?? 2;
|
|
150
|
+
// Find insertion point: higher weight jobs go first
|
|
151
|
+
let i = 0;
|
|
152
|
+
while (
|
|
153
|
+
i < this.jobs.length &&
|
|
154
|
+
PRIORITY_WEIGHT[(this.jobs[i].meta.priority as Priority) ?? "normal"] >= w
|
|
155
|
+
)
|
|
156
|
+
i++;
|
|
157
|
+
this.jobs.splice(i, 0, job);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/** Start the queue workers. */
|
|
163
|
+
start(): this {
|
|
164
|
+
if (this.pollTimer) return this;
|
|
165
|
+
this.pollTimer = setInterval(() => this._tick(), this.pollInterval);
|
|
166
|
+
this._tick(); // immediate first tick
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Drain the queue (process remaining jobs) then stop. */
|
|
171
|
+
async drain(): Promise<void> {
|
|
172
|
+
this.stop();
|
|
173
|
+
while (this.jobs.length > 0 || this.running > 0) {
|
|
174
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
175
|
+
this._tick();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Stop polling. In-flight jobs still complete. */
|
|
180
|
+
stop(): this {
|
|
181
|
+
if (this.pollTimer) {
|
|
182
|
+
clearInterval(this.pollTimer);
|
|
183
|
+
this.pollTimer = undefined;
|
|
184
|
+
}
|
|
185
|
+
return this;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Processing ───────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
private _tick() {
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
|
|
193
|
+
while (this.running < this.concurrency && this.jobs.length > 0) {
|
|
194
|
+
// Find next ready job (scheduledAt <= now)
|
|
195
|
+
const idx = this.jobs.findIndex((j) => j.scheduledAt <= now);
|
|
196
|
+
if (idx === -1) break;
|
|
197
|
+
|
|
198
|
+
const [job] = this.jobs.splice(idx, 1);
|
|
199
|
+
this._process(job);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async _process(job: QueueJob) {
|
|
204
|
+
const { params, meta } = job;
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
|
|
207
|
+
// TTL check
|
|
208
|
+
if (meta.ttl && now - meta.createdAt > meta.ttl) {
|
|
209
|
+
this.dropped++;
|
|
210
|
+
if (meta.dedupKey) this.dedupSet.delete(meta.dedupKey);
|
|
211
|
+
await this.plugins?.fire("onDrop", {
|
|
212
|
+
to: params.to,
|
|
213
|
+
tags: meta.tags,
|
|
214
|
+
reason: "ttl",
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this.running++;
|
|
220
|
+
const start = Date.now();
|
|
221
|
+
await this.plugins?.fire("onSend", {
|
|
222
|
+
to: params.to,
|
|
223
|
+
tags: meta.tags,
|
|
224
|
+
template: params.template,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const result = (await (this.client as any).send(params)) as Result<any>;
|
|
228
|
+
|
|
229
|
+
this.running--;
|
|
230
|
+
const durationMs = Date.now() - start;
|
|
231
|
+
|
|
232
|
+
if (result.ok) {
|
|
233
|
+
this.completed++;
|
|
234
|
+
if (meta.dedupKey) this.dedupSet.delete(meta.dedupKey);
|
|
235
|
+
await this.plugins?.fire("onSuccess", {
|
|
236
|
+
to: params.to,
|
|
237
|
+
tags: meta.tags,
|
|
238
|
+
data: result.data,
|
|
239
|
+
durationMs,
|
|
240
|
+
});
|
|
241
|
+
} else {
|
|
242
|
+
if (meta.dedupKey) this.dedupSet.delete(meta.dedupKey);
|
|
243
|
+
await this.plugins?.fire("onError", {
|
|
244
|
+
to: params.to,
|
|
245
|
+
tags: meta.tags,
|
|
246
|
+
error: result.error,
|
|
247
|
+
durationMs,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Stats ────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
stats(): QueueStats {
|
|
255
|
+
return {
|
|
256
|
+
pending: this.jobs.length,
|
|
257
|
+
running: this.running,
|
|
258
|
+
completed: this.completed,
|
|
259
|
+
dropped: this.dropped,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Cancel a job by id. Returns true if found and removed. */
|
|
264
|
+
cancel(id: string): boolean {
|
|
265
|
+
const idx = this.jobs.findIndex((j) => j.id === id);
|
|
266
|
+
if (idx === -1) return false;
|
|
267
|
+
const [job] = this.jobs.splice(idx, 1);
|
|
268
|
+
if (job.meta.dedupKey) this.dedupSet.delete(job.meta.dedupKey);
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Clear all pending (not in-flight) jobs. */
|
|
273
|
+
clear(): number {
|
|
274
|
+
const count = this.jobs.length;
|
|
275
|
+
this.jobs.forEach(
|
|
276
|
+
(j) => j.meta.dedupKey && this.dedupSet.delete(j.meta.dedupKey),
|
|
277
|
+
);
|
|
278
|
+
this.jobs = [];
|
|
279
|
+
return count;
|
|
280
|
+
}
|
|
281
|
+
}
|