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/MessageFormatter.js
CHANGED
|
@@ -1,21 +1,45 @@
|
|
|
1
1
|
import { TemplateError, ValidationError } from "./errors.js";
|
|
2
|
-
import { isObject } from "./
|
|
2
|
+
import { isObject, flattenObject } from "./helpers.js";
|
|
3
3
|
export default class MessageFormatter {
|
|
4
|
+
/**
|
|
5
|
+
* Format a template string with variables.
|
|
6
|
+
* Supports {key} and {nested.key} (for nested objects) placeholders.
|
|
7
|
+
*/
|
|
4
8
|
format(template, params = {}) {
|
|
5
9
|
if (typeof template !== "string" || template.trim() === "") {
|
|
6
10
|
throw new ValidationError("Message template must be a non-empty string");
|
|
7
11
|
}
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
const flat = flattenObject(params);
|
|
13
|
+
return template.replace(/\{([\w.]+)\}/g, (_, key) => {
|
|
14
|
+
if (!(key in flat)) {
|
|
10
15
|
throw new TemplateError(`Missing template variable: ${key}`);
|
|
11
16
|
}
|
|
12
|
-
return
|
|
17
|
+
return flat[key];
|
|
13
18
|
});
|
|
14
19
|
}
|
|
15
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Extract a message from a plain object by looking for common message fields,
|
|
22
|
+
* then fall back to JSON serialisation.
|
|
23
|
+
*/
|
|
24
|
+
fromObject(obj, template) {
|
|
16
25
|
if (!isObject(obj)) {
|
|
17
26
|
throw new ValidationError("Message object must be a valid object");
|
|
18
27
|
}
|
|
19
|
-
|
|
28
|
+
const variables = flattenObject(obj);
|
|
29
|
+
if (template) {
|
|
30
|
+
return { message: this.format(template, obj), variables };
|
|
31
|
+
}
|
|
32
|
+
// Auto-detect a message field from the object
|
|
33
|
+
const messageCandidates = ["message", "body", "text", "content", "sms"];
|
|
34
|
+
for (const candidate of messageCandidates) {
|
|
35
|
+
if (typeof obj[candidate] === "string" && obj[candidate]) {
|
|
36
|
+
return { message: obj[candidate], variables };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Fall back to serialised JSON
|
|
40
|
+
return { message: JSON.stringify(obj), variables };
|
|
41
|
+
}
|
|
42
|
+
fromObject2String(obj) {
|
|
43
|
+
return this.fromObject(obj).message;
|
|
20
44
|
}
|
|
21
45
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ErrorLog } from "./types.js";
|
|
2
|
+
export interface HookContext {
|
|
3
|
+
to: string;
|
|
4
|
+
message?: string;
|
|
5
|
+
template?: string;
|
|
6
|
+
tags?: string[];
|
|
7
|
+
attempt?: number;
|
|
8
|
+
durationMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface PluginHooks {
|
|
11
|
+
/** Called before every send attempt */
|
|
12
|
+
onSend?: (ctx: HookContext) => void | Promise<void>;
|
|
13
|
+
/** Called after a successful send */
|
|
14
|
+
onSuccess?: (ctx: HookContext & {
|
|
15
|
+
data: unknown;
|
|
16
|
+
}) => void | Promise<void>;
|
|
17
|
+
/** Called after a failed send (after all retries) */
|
|
18
|
+
onError?: (ctx: HookContext & {
|
|
19
|
+
error: ErrorLog;
|
|
20
|
+
}) => void | Promise<void>;
|
|
21
|
+
/** Called on each retry attempt */
|
|
22
|
+
onRetry?: (ctx: HookContext & {
|
|
23
|
+
attempt: number;
|
|
24
|
+
error: ErrorLog;
|
|
25
|
+
}) => void | Promise<void>;
|
|
26
|
+
/** Called when a job is dropped (TTL expired or dedup) */
|
|
27
|
+
onDrop?: (ctx: HookContext & {
|
|
28
|
+
reason: "ttl" | "dedup";
|
|
29
|
+
}) => void | Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export type Plugin = PluginHooks & {
|
|
32
|
+
name: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* PluginManager — registers plugins and fires lifecycle hooks.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const plugins = new PluginManager();
|
|
39
|
+
* plugins.use(LoggerPlugin("my-service"));
|
|
40
|
+
* plugins.use(MetricsPlugin(prometheusRegistry));
|
|
41
|
+
*/
|
|
42
|
+
export default class PluginManager {
|
|
43
|
+
private plugins;
|
|
44
|
+
use(plugin: Plugin): this;
|
|
45
|
+
remove(name: string): this;
|
|
46
|
+
fire<K extends keyof PluginHooks>(hook: K, ctx: Parameters<NonNullable<PluginHooks[K]>>[0]): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Console logger plugin.
|
|
50
|
+
* @example plugins.use(LoggerPlugin("order-service"));
|
|
51
|
+
*/
|
|
52
|
+
export declare function LoggerPlugin(serviceName?: string): Plugin;
|
|
53
|
+
/**
|
|
54
|
+
* In-memory metrics plugin. Exposes `.snapshot()` for health-check endpoints.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* const metrics = MetricsPlugin();
|
|
58
|
+
* plugins.use(metrics);
|
|
59
|
+
* // later:
|
|
60
|
+
* console.log(metrics.snapshot());
|
|
61
|
+
*/
|
|
62
|
+
export declare function MetricsPlugin(): Plugin & {
|
|
63
|
+
snapshot: () => MetricsSnapshot;
|
|
64
|
+
reset: () => void;
|
|
65
|
+
};
|
|
66
|
+
export interface MetricsSnapshot {
|
|
67
|
+
sent: number;
|
|
68
|
+
succeeded: number;
|
|
69
|
+
failed: number;
|
|
70
|
+
dropped: number;
|
|
71
|
+
retries: number;
|
|
72
|
+
totalDurationMs: number;
|
|
73
|
+
errorCodes: Record<string, number>;
|
|
74
|
+
successRate: number;
|
|
75
|
+
avgDurationMs: number;
|
|
76
|
+
capturedAt: string;
|
|
77
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PluginManager — registers plugins and fires lifecycle hooks.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* const plugins = new PluginManager();
|
|
6
|
+
* plugins.use(LoggerPlugin("my-service"));
|
|
7
|
+
* plugins.use(MetricsPlugin(prometheusRegistry));
|
|
8
|
+
*/
|
|
9
|
+
export default class PluginManager {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.plugins = [];
|
|
12
|
+
}
|
|
13
|
+
use(plugin) {
|
|
14
|
+
this.plugins.push(plugin);
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
17
|
+
remove(name) {
|
|
18
|
+
this.plugins = this.plugins.filter((p) => p.name !== name);
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
async fire(hook, ctx) {
|
|
22
|
+
for (const plugin of this.plugins) {
|
|
23
|
+
const fn = plugin[hook];
|
|
24
|
+
if (fn) {
|
|
25
|
+
try {
|
|
26
|
+
await fn(ctx);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
// Plugins must never crash the main flow
|
|
30
|
+
console.error(`[SMSPlugin:${plugin.name}] hook "${hook}" threw:`, err);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// ── Built-in plugins ────────────────────────────────────────────────────────
|
|
37
|
+
/**
|
|
38
|
+
* Console logger plugin.
|
|
39
|
+
* @example plugins.use(LoggerPlugin("order-service"));
|
|
40
|
+
*/
|
|
41
|
+
export function LoggerPlugin(serviceName = "sms-sdk") {
|
|
42
|
+
return {
|
|
43
|
+
name: "logger",
|
|
44
|
+
onSend: ({ to, tags }) => console.log(`[${serviceName}] → sending to ${to}${tags?.length ? ` [${tags.join(", ")}]` : ""}`),
|
|
45
|
+
onSuccess: ({ to, durationMs }) => console.log(`[${serviceName}] ✓ delivered to ${to} in ${durationMs}ms`),
|
|
46
|
+
onError: ({ to, error }) => console.error(`[${serviceName}] ✗ failed to ${to}: [${error.code}] ${error.message}`),
|
|
47
|
+
onRetry: ({ to, attempt, error }) => console.warn(`[${serviceName}] ↻ retry #${attempt} for ${to}: ${error.message}`),
|
|
48
|
+
onDrop: ({ to, reason }) => console.warn(`[${serviceName}] ⊘ dropped job for ${to} (${reason})`),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* In-memory metrics plugin. Exposes `.snapshot()` for health-check endpoints.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* const metrics = MetricsPlugin();
|
|
56
|
+
* plugins.use(metrics);
|
|
57
|
+
* // later:
|
|
58
|
+
* console.log(metrics.snapshot());
|
|
59
|
+
*/
|
|
60
|
+
export function MetricsPlugin() {
|
|
61
|
+
const state = {
|
|
62
|
+
sent: 0,
|
|
63
|
+
succeeded: 0,
|
|
64
|
+
failed: 0,
|
|
65
|
+
dropped: 0,
|
|
66
|
+
retries: 0,
|
|
67
|
+
totalDurationMs: 0,
|
|
68
|
+
errorCodes: {},
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
name: "metrics",
|
|
72
|
+
onSend: () => { state.sent++; },
|
|
73
|
+
onSuccess: ({ durationMs = 0 }) => {
|
|
74
|
+
state.succeeded++;
|
|
75
|
+
state.totalDurationMs += durationMs;
|
|
76
|
+
},
|
|
77
|
+
onError: ({ error }) => {
|
|
78
|
+
state.failed++;
|
|
79
|
+
state.errorCodes[error.code] = (state.errorCodes[error.code] ?? 0) + 1;
|
|
80
|
+
},
|
|
81
|
+
onRetry: () => { state.retries++; },
|
|
82
|
+
onDrop: () => { state.dropped++; },
|
|
83
|
+
snapshot() {
|
|
84
|
+
return {
|
|
85
|
+
...state,
|
|
86
|
+
successRate: state.sent > 0 ? state.succeeded / state.sent : 0,
|
|
87
|
+
avgDurationMs: state.succeeded > 0 ? state.totalDurationMs / state.succeeded : 0,
|
|
88
|
+
capturedAt: new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
reset() {
|
|
92
|
+
state.sent = 0;
|
|
93
|
+
state.succeeded = 0;
|
|
94
|
+
state.failed = 0;
|
|
95
|
+
state.dropped = 0;
|
|
96
|
+
state.retries = 0;
|
|
97
|
+
state.totalDurationMs = 0;
|
|
98
|
+
state.errorCodes = {};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
package/dist/RetryHandler.d.ts
CHANGED
|
@@ -2,9 +2,16 @@ export interface RetryOptions {
|
|
|
2
2
|
maxAttempts?: number;
|
|
3
3
|
delay?: number;
|
|
4
4
|
strategy?: "fixed" | "exponential";
|
|
5
|
+
jitter?: boolean;
|
|
6
|
+
/** Error codes that should NOT be retried (e.g. validation errors) */
|
|
7
|
+
nonRetryableCodes?: string[];
|
|
5
8
|
}
|
|
6
9
|
export default class RetryHandler {
|
|
7
|
-
private
|
|
10
|
+
private readonly maxAttempts;
|
|
11
|
+
private readonly delay;
|
|
12
|
+
private readonly strategy;
|
|
13
|
+
private readonly jitter;
|
|
14
|
+
private readonly nonRetryableCodes;
|
|
8
15
|
constructor(options?: RetryOptions);
|
|
9
16
|
execute<T>(operation: () => Promise<T>): Promise<T>;
|
|
10
17
|
}
|
package/dist/RetryHandler.js
CHANGED
|
@@ -1,24 +1,46 @@
|
|
|
1
1
|
import { setTimeout } from "node:timers/promises";
|
|
2
|
+
import { RateLimitError } from "./errors.js";
|
|
2
3
|
export default class RetryHandler {
|
|
3
4
|
constructor(options = {}) {
|
|
4
|
-
this.
|
|
5
|
+
this.maxAttempts = options.maxAttempts ?? 3;
|
|
6
|
+
this.delay = options.delay ?? 500;
|
|
7
|
+
this.strategy = options.strategy ?? "fixed";
|
|
8
|
+
this.jitter = options.jitter ?? true;
|
|
9
|
+
this.nonRetryableCodes = new Set(options.nonRetryableCodes ?? ["VALIDATION_ERROR", "TEMPLATE_ERROR"]);
|
|
5
10
|
}
|
|
6
11
|
async execute(operation) {
|
|
7
|
-
const { maxAttempts = 3, delay = 500, strategy = "fixed" } = this.options;
|
|
8
12
|
let attempt = 0;
|
|
9
|
-
|
|
13
|
+
let lastError;
|
|
14
|
+
while (attempt < this.maxAttempts) {
|
|
10
15
|
try {
|
|
11
16
|
return await operation();
|
|
12
17
|
}
|
|
13
18
|
catch (error) {
|
|
19
|
+
lastError = error;
|
|
14
20
|
attempt++;
|
|
15
|
-
|
|
21
|
+
// Don't retry client-side or non-retryable errors
|
|
22
|
+
if (error?.isClientError || this.nonRetryableCodes.has(error?.code)) {
|
|
16
23
|
throw error;
|
|
17
24
|
}
|
|
18
|
-
|
|
25
|
+
if (attempt >= this.maxAttempts)
|
|
26
|
+
break;
|
|
27
|
+
// Respect Retry-After for rate limit errors
|
|
28
|
+
let waitTime;
|
|
29
|
+
if (error instanceof RateLimitError && error.retryAfter) {
|
|
30
|
+
waitTime = error.retryAfter * 1000;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
waitTime =
|
|
34
|
+
this.strategy === "exponential"
|
|
35
|
+
? this.delay * 2 ** (attempt - 1)
|
|
36
|
+
: this.delay;
|
|
37
|
+
if (this.jitter) {
|
|
38
|
+
waitTime += Math.random() * waitTime * 0.2;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
19
41
|
await setTimeout(waitTime);
|
|
20
42
|
}
|
|
21
43
|
}
|
|
22
|
-
throw
|
|
44
|
+
throw lastError;
|
|
23
45
|
}
|
|
24
46
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type RetryOptions } from "./RetryHandler.js";
|
|
2
|
+
import { type CircuitBreakerOptions } from "./CircuitBreaker.js";
|
|
3
|
+
import PluginManager, { type Plugin } from "./PluginManager.js";
|
|
4
|
+
import type { Result, SendParams, SendObjectParams, BatchResult } from "./types.js";
|
|
5
|
+
export interface SMSClientOptions {
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
projectKey: string;
|
|
9
|
+
retry?: RetryOptions;
|
|
10
|
+
circuitBreaker?: CircuitBreakerOptions;
|
|
11
|
+
/** Max concurrent requests when sending in bulk (default: 5) */
|
|
12
|
+
concurrency?: number;
|
|
13
|
+
/** Chunk size for sendMany (default: 50) */
|
|
14
|
+
batchSize?: number;
|
|
15
|
+
/** Plugins to attach at construction time */
|
|
16
|
+
plugins?: Plugin[];
|
|
17
|
+
}
|
|
18
|
+
export default class SMSClient {
|
|
19
|
+
private options;
|
|
20
|
+
private http;
|
|
21
|
+
private retry;
|
|
22
|
+
private circuit;
|
|
23
|
+
private formatter;
|
|
24
|
+
readonly plugins: PluginManager;
|
|
25
|
+
private concurrency;
|
|
26
|
+
private batchSize;
|
|
27
|
+
constructor(options: SMSClientOptions);
|
|
28
|
+
/** Attach a plugin at any time. */
|
|
29
|
+
use(plugin: Plugin): this;
|
|
30
|
+
private _send;
|
|
31
|
+
/**
|
|
32
|
+
* Send a single SMS message.
|
|
33
|
+
* Returns a Result — never throws.
|
|
34
|
+
*/
|
|
35
|
+
send(params: SendParams & {
|
|
36
|
+
_meta?: any;
|
|
37
|
+
}): Promise<Result<any>>;
|
|
38
|
+
/**
|
|
39
|
+
* Send the same template to many recipients.
|
|
40
|
+
* Each recipient can supply its own variables; shared variables are the fallback.
|
|
41
|
+
*/
|
|
42
|
+
sendMany(recipients: Array<{
|
|
43
|
+
to: string;
|
|
44
|
+
variables?: Record<string, unknown>;
|
|
45
|
+
}>, template: string, sharedVariables?: Record<string, unknown>): Promise<BatchResult<any>>;
|
|
46
|
+
/**
|
|
47
|
+
* Send a message derived from a plain object.
|
|
48
|
+
* Auto-detects message from message/body/text/content fields,
|
|
49
|
+
* or formats via an optional template using the object's keys.
|
|
50
|
+
*/
|
|
51
|
+
sendObject<T extends Record<string, unknown>>(params: SendObjectParams<T> & {
|
|
52
|
+
_meta?: any;
|
|
53
|
+
}): Promise<Result<any>>;
|
|
54
|
+
/**
|
|
55
|
+
* Send object-derived messages to many recipients.
|
|
56
|
+
*/
|
|
57
|
+
sendObjectMany<T extends Record<string, unknown>>(items: Array<SendObjectParams<T> & {
|
|
58
|
+
_meta?: any;
|
|
59
|
+
}>): Promise<BatchResult<any>>;
|
|
60
|
+
/** Current circuit breaker state. */
|
|
61
|
+
get circuitState(): "CLOSED" | "OPEN" | "HALF_OPEN";
|
|
62
|
+
/** Manually reset the circuit breaker (e.g. after fixing a downstream issue). */
|
|
63
|
+
resetCircuit(): void;
|
|
64
|
+
private validateOptions;
|
|
65
|
+
}
|
|
@@ -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
|
+
}
|