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.
- 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/main.ts +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface CircuitBreakerOptions {
|
|
2
|
+
/** Number of consecutive failures before opening the circuit */
|
|
3
|
+
threshold?: number;
|
|
4
|
+
/** Milliseconds to wait before transitioning to half-open */
|
|
5
|
+
timeout?: number;
|
|
6
|
+
/** Number of successful probes to close circuit from half-open */
|
|
7
|
+
successThreshold?: number;
|
|
8
|
+
}
|
|
9
|
+
type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
|
|
10
|
+
export default class CircuitBreaker {
|
|
11
|
+
private state;
|
|
12
|
+
private failures;
|
|
13
|
+
private successes;
|
|
14
|
+
private lastFailureTime;
|
|
15
|
+
private readonly threshold;
|
|
16
|
+
private readonly timeout;
|
|
17
|
+
private readonly successThreshold;
|
|
18
|
+
constructor(options?: CircuitBreakerOptions);
|
|
19
|
+
get currentState(): CircuitState;
|
|
20
|
+
execute<T>(operation: () => Promise<T>): Promise<T>;
|
|
21
|
+
private onSuccess;
|
|
22
|
+
private onFailure;
|
|
23
|
+
reset(): void;
|
|
24
|
+
}
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CircuitOpenError } from "./errors.js";
|
|
2
|
+
export default class CircuitBreaker {
|
|
3
|
+
constructor(options = {}) {
|
|
4
|
+
this.state = "CLOSED";
|
|
5
|
+
this.failures = 0;
|
|
6
|
+
this.successes = 0;
|
|
7
|
+
this.lastFailureTime = 0;
|
|
8
|
+
this.threshold = options.threshold ?? 5;
|
|
9
|
+
this.timeout = options.timeout ?? 30000;
|
|
10
|
+
this.successThreshold = options.successThreshold ?? 2;
|
|
11
|
+
}
|
|
12
|
+
get currentState() {
|
|
13
|
+
return this.state;
|
|
14
|
+
}
|
|
15
|
+
async execute(operation) {
|
|
16
|
+
if (this.state === "OPEN") {
|
|
17
|
+
if (Date.now() - this.lastFailureTime >= this.timeout) {
|
|
18
|
+
this.state = "HALF_OPEN";
|
|
19
|
+
this.successes = 0;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
throw new CircuitOpenError("Circuit is open — service is temporarily unavailable");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const result = await operation();
|
|
27
|
+
this.onSuccess();
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
this.onFailure();
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
onSuccess() {
|
|
36
|
+
if (this.state === "HALF_OPEN") {
|
|
37
|
+
this.successes++;
|
|
38
|
+
if (this.successes >= this.successThreshold) {
|
|
39
|
+
this.reset();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
this.failures = 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
onFailure() {
|
|
47
|
+
this.failures++;
|
|
48
|
+
this.lastFailureTime = Date.now();
|
|
49
|
+
if (this.state === "HALF_OPEN" || this.failures >= this.threshold) {
|
|
50
|
+
this.state = "OPEN";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
reset() {
|
|
54
|
+
this.state = "CLOSED";
|
|
55
|
+
this.failures = 0;
|
|
56
|
+
this.successes = 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface Credentials {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
apiKey: string;
|
|
4
|
+
projectKey: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ProvisionOptions {
|
|
7
|
+
/** Your service's provisioning endpoint */
|
|
8
|
+
provisionUrl: string;
|
|
9
|
+
/** Master/admin key used to call the provisioning API */
|
|
10
|
+
adminKey: string;
|
|
11
|
+
projectName?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* CredentialProvisioner
|
|
15
|
+
*
|
|
16
|
+
* Calls your service's provisioning endpoint to automatically:
|
|
17
|
+
* - Generate a new repo token (projectKey)
|
|
18
|
+
* - Generate an access token (apiKey)
|
|
19
|
+
* - Return the service URL
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* const creds = await CredentialProvisioner.provision({ provisionUrl, adminKey });
|
|
23
|
+
* const client = new SMSClient(creds);
|
|
24
|
+
*/
|
|
25
|
+
export default class CredentialProvisioner {
|
|
26
|
+
static provision(options: ProvisionOptions): Promise<Credentials>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ValidationError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* CredentialProvisioner
|
|
4
|
+
*
|
|
5
|
+
* Calls your service's provisioning endpoint to automatically:
|
|
6
|
+
* - Generate a new repo token (projectKey)
|
|
7
|
+
* - Generate an access token (apiKey)
|
|
8
|
+
* - Return the service URL
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const creds = await CredentialProvisioner.provision({ provisionUrl, adminKey });
|
|
12
|
+
* const client = new SMSClient(creds);
|
|
13
|
+
*/
|
|
14
|
+
export default class CredentialProvisioner {
|
|
15
|
+
static async provision(options) {
|
|
16
|
+
const { provisionUrl, adminKey, projectName } = options;
|
|
17
|
+
if (!provisionUrl)
|
|
18
|
+
throw new ValidationError("provisionUrl is required");
|
|
19
|
+
if (!adminKey)
|
|
20
|
+
throw new ValidationError("adminKey is required");
|
|
21
|
+
const response = await fetch(provisionUrl, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
Authorization: `Bearer ${adminKey}`,
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({ projectName }),
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
const text = await response.text();
|
|
31
|
+
throw new Error(`Provisioning failed (${response.status}): ${text}`);
|
|
32
|
+
}
|
|
33
|
+
const data = await response.json();
|
|
34
|
+
// Accept either snake_case or camelCase field names from your API
|
|
35
|
+
const baseUrl = data.baseUrl ?? data.base_url ?? data.url ?? data.serviceUrl;
|
|
36
|
+
const apiKey = data.apiKey ?? data.api_key ?? data.accessToken ?? data.access_token;
|
|
37
|
+
const projectKey = data.projectKey ?? data.project_key ?? data.repoToken ?? data.repo_token;
|
|
38
|
+
if (!baseUrl || !apiKey || !projectKey) {
|
|
39
|
+
throw new Error("Provisioning response is missing required fields (baseUrl, apiKey, projectKey)");
|
|
40
|
+
}
|
|
41
|
+
return { baseUrl, apiKey, projectKey };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
export default class MessageFormatter {
|
|
2
|
+
/**
|
|
3
|
+
* Format a template string with variables.
|
|
4
|
+
* Supports {key} and {nested.key} (for nested objects) placeholders.
|
|
5
|
+
*/
|
|
2
6
|
format(template: string, params?: Record<string, unknown>): string;
|
|
3
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Extract a message from a plain object by looking for common message fields,
|
|
9
|
+
* then fall back to JSON serialisation.
|
|
10
|
+
*/
|
|
11
|
+
fromObject(obj: Record<string, unknown>, template?: string): {
|
|
12
|
+
message: string;
|
|
13
|
+
variables: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
fromObject2String(obj: Record<string, unknown>): string;
|
|
4
16
|
}
|
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
|
+
}
|