bridgex 1.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +525 -0
  2. package/dist/CircuitBreaker.d.ts +25 -0
  3. package/dist/CircuitBreaker.js +58 -0
  4. package/dist/CredentialProvisioner.d.ts +27 -0
  5. package/dist/CredentialProvisioner.js +43 -0
  6. package/dist/MessageFormatter.d.ts +13 -1
  7. package/dist/MessageFormatter.js +30 -6
  8. package/dist/PluginManager.d.ts +77 -0
  9. package/dist/PluginManager.js +101 -0
  10. package/dist/RetryHandler.d.ts +8 -1
  11. package/dist/RetryHandler.js +28 -6
  12. package/dist/SMSClient.d.ts +65 -0
  13. package/dist/SMSClient.js +236 -0
  14. package/dist/SMSQueue.d.ts +82 -0
  15. package/dist/SMSQueue.js +207 -0
  16. package/dist/SMSScheduler.d.ts +75 -0
  17. package/dist/SMSScheduler.js +196 -0
  18. package/dist/SMSService.d.ts +41 -0
  19. package/dist/SMSService.js +214 -0
  20. package/dist/SendConfig.d.ts +90 -0
  21. package/dist/SendConfig.js +165 -0
  22. package/dist/errors.d.ts +18 -1
  23. package/dist/errors.js +28 -5
  24. package/dist/helpers.d.ts +3 -0
  25. package/dist/helpers.js +23 -0
  26. package/dist/main.d.ts +20 -1
  27. package/dist/main.js +19 -1
  28. package/dist/types.d.ts +43 -0
  29. package/dist/types.js +1 -0
  30. package/package.json +1 -1
  31. package/src/CircuitBreaker.ts +81 -0
  32. package/src/CredentialProvisioner.ts +68 -0
  33. package/src/MessageFormatter.ts +38 -7
  34. package/src/PluginManager.ts +155 -0
  35. package/src/RetryHandler.ts +42 -9
  36. package/src/SMSClient.ts +308 -0
  37. package/src/SMSQueue.ts +281 -0
  38. package/src/SMSScheduler.ts +250 -0
  39. package/src/SMSService.ts +254 -0
  40. package/src/SendConfig.ts +208 -0
  41. package/src/errors.ts +40 -6
  42. package/src/helpers.ts +31 -0
  43. package/src/main.ts +61 -1
  44. package/src/types.ts +33 -0
  45. package/src/client/SMSManager.ts +0 -67
  46. package/src/client/types.ts +0 -24
  47. package/src/help.ts +0 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bridgex",
3
- "version": "1.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "a library for mazz app or a bridge for messaging that allow and automate the use of our service",
5
5
  "keywords": [
6
6
  "messaging",
@@ -0,0 +1,81 @@
1
+ import { CircuitOpenError } from "./errors.js";
2
+
3
+ export interface CircuitBreakerOptions {
4
+ /** Number of consecutive failures before opening the circuit */
5
+ threshold?: number;
6
+ /** Milliseconds to wait before transitioning to half-open */
7
+ timeout?: number;
8
+ /** Number of successful probes to close circuit from half-open */
9
+ successThreshold?: number;
10
+ }
11
+
12
+ type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
13
+
14
+ export default class CircuitBreaker {
15
+ private state: CircuitState = "CLOSED";
16
+ private failures = 0;
17
+ private successes = 0;
18
+ private lastFailureTime = 0;
19
+
20
+ private readonly threshold: number;
21
+ private readonly timeout: number;
22
+ private readonly successThreshold: number;
23
+
24
+ constructor(options: CircuitBreakerOptions = {}) {
25
+ this.threshold = options.threshold ?? 5;
26
+ this.timeout = options.timeout ?? 30_000;
27
+ this.successThreshold = options.successThreshold ?? 2;
28
+ }
29
+
30
+ get currentState(): CircuitState {
31
+ return this.state;
32
+ }
33
+
34
+ async execute<T>(operation: () => Promise<T>): Promise<T> {
35
+ if (this.state === "OPEN") {
36
+ if (Date.now() - this.lastFailureTime >= this.timeout) {
37
+ this.state = "HALF_OPEN";
38
+ this.successes = 0;
39
+ } else {
40
+ throw new CircuitOpenError(
41
+ "Circuit is open — service is temporarily unavailable",
42
+ );
43
+ }
44
+ }
45
+
46
+ try {
47
+ const result = await operation();
48
+ this.onSuccess();
49
+ return result;
50
+ } catch (error) {
51
+ this.onFailure();
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ private onSuccess() {
57
+ if (this.state === "HALF_OPEN") {
58
+ this.successes++;
59
+ if (this.successes >= this.successThreshold) {
60
+ this.reset();
61
+ }
62
+ } else {
63
+ this.failures = 0;
64
+ }
65
+ }
66
+
67
+ private onFailure() {
68
+ this.failures++;
69
+ this.lastFailureTime = Date.now();
70
+
71
+ if (this.state === "HALF_OPEN" || this.failures >= this.threshold) {
72
+ this.state = "OPEN";
73
+ }
74
+ }
75
+
76
+ reset() {
77
+ this.state = "CLOSED";
78
+ this.failures = 0;
79
+ this.successes = 0;
80
+ }
81
+ }
@@ -0,0 +1,68 @@
1
+ import { ValidationError } from "./errors.js";
2
+
3
+ export interface Credentials {
4
+ baseUrl: string;
5
+ apiKey: string;
6
+ projectKey: string;
7
+ }
8
+
9
+ export interface ProvisionOptions {
10
+ /** Your service's provisioning endpoint */
11
+ provisionUrl: string;
12
+ /** Master/admin key used to call the provisioning API */
13
+ adminKey: string;
14
+ projectName?: string;
15
+ }
16
+
17
+ /**
18
+ * CredentialProvisioner
19
+ *
20
+ * Calls your service's provisioning endpoint to automatically:
21
+ * - Generate a new repo token (projectKey)
22
+ * - Generate an access token (apiKey)
23
+ * - Return the service URL
24
+ *
25
+ * Usage:
26
+ * const creds = await CredentialProvisioner.provision({ provisionUrl, adminKey });
27
+ * const client = new SMSClient(creds);
28
+ */
29
+ export default class CredentialProvisioner {
30
+ static async provision(options: ProvisionOptions): Promise<Credentials> {
31
+ const { provisionUrl, adminKey, projectName } = options;
32
+
33
+ if (!provisionUrl) throw new ValidationError("provisionUrl is required");
34
+ if (!adminKey) throw new ValidationError("adminKey is required");
35
+
36
+ const response = await fetch(provisionUrl, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ Authorization: `Bearer ${adminKey}`,
41
+ },
42
+ body: JSON.stringify({ projectName }),
43
+ });
44
+
45
+ if (!response.ok) {
46
+ const text = await response.text();
47
+ throw new Error(`Provisioning failed (${response.status}): ${text}`);
48
+ }
49
+
50
+ const data = await response.json();
51
+
52
+ // Accept either snake_case or camelCase field names from your API
53
+ const baseUrl =
54
+ data.baseUrl ?? data.base_url ?? data.url ?? data.serviceUrl;
55
+ const apiKey =
56
+ data.apiKey ?? data.api_key ?? data.accessToken ?? data.access_token;
57
+ const projectKey =
58
+ data.projectKey ?? data.project_key ?? data.repoToken ?? data.repo_token;
59
+
60
+ if (!baseUrl || !apiKey || !projectKey) {
61
+ throw new Error(
62
+ "Provisioning response is missing required fields (baseUrl, apiKey, projectKey)",
63
+ );
64
+ }
65
+
66
+ return { baseUrl, apiKey, projectKey };
67
+ }
68
+ }
@@ -1,26 +1,57 @@
1
1
  import { TemplateError, ValidationError } from "./errors.js";
2
- import { isObject } from "./help.js";
2
+ import { isObject, flattenObject } from "./helpers.js";
3
3
 
4
4
  export default class MessageFormatter {
5
+ /**
6
+ * Format a template string with variables.
7
+ * Supports {key} and {nested.key} (for nested objects) placeholders.
8
+ */
5
9
  format(template: string, params: Record<string, unknown> = {}): string {
6
10
  if (typeof template !== "string" || template.trim() === "") {
7
11
  throw new ValidationError("Message template must be a non-empty string");
8
12
  }
9
13
 
10
- return template.replace(/\{(\w+)\}/g, (_, key) => {
11
- if (!(key in params)) {
14
+ const flat = flattenObject(params as Record<string, unknown>);
15
+
16
+ return template.replace(/\{([\w.]+)\}/g, (_, key) => {
17
+ if (!(key in flat)) {
12
18
  throw new TemplateError(`Missing template variable: ${key}`);
13
19
  }
14
-
15
- return String(params[key]);
20
+ return flat[key];
16
21
  });
17
22
  }
18
23
 
19
- fromObject(obj: Record<string, unknown>): string {
24
+ /**
25
+ * Extract a message from a plain object by looking for common message fields,
26
+ * then fall back to JSON serialisation.
27
+ */
28
+ fromObject(
29
+ obj: Record<string, unknown>,
30
+ template?: string,
31
+ ): { message: string; variables: Record<string, string> } {
20
32
  if (!isObject(obj)) {
21
33
  throw new ValidationError("Message object must be a valid object");
22
34
  }
23
35
 
24
- return JSON.stringify(obj);
36
+ const variables = flattenObject(obj);
37
+
38
+ if (template) {
39
+ return { message: this.format(template, obj), variables };
40
+ }
41
+
42
+ // Auto-detect a message field from the object
43
+ const messageCandidates = ["message", "body", "text", "content", "sms"];
44
+ for (const candidate of messageCandidates) {
45
+ if (typeof obj[candidate] === "string" && obj[candidate]) {
46
+ return { message: obj[candidate] as string, variables };
47
+ }
48
+ }
49
+
50
+ // Fall back to serialised JSON
51
+ return { message: JSON.stringify(obj), variables };
52
+ }
53
+
54
+ fromObject2String(obj: Record<string, unknown>): string {
55
+ return this.fromObject(obj).message;
25
56
  }
26
57
  }
@@ -0,0 +1,155 @@
1
+ import type { ErrorLog, SendParams } from "./types.js";
2
+
3
+ export interface HookContext {
4
+ to: string;
5
+ message?: string;
6
+ template?: string;
7
+ tags?: string[];
8
+ attempt?: number;
9
+ durationMs?: number;
10
+ }
11
+
12
+ export interface PluginHooks {
13
+ /** Called before every send attempt */
14
+ onSend?: (ctx: HookContext) => void | Promise<void>;
15
+ /** Called after a successful send */
16
+ onSuccess?: (ctx: HookContext & { data: unknown }) => void | Promise<void>;
17
+ /** Called after a failed send (after all retries) */
18
+ onError?: (ctx: HookContext & { error: ErrorLog }) => void | Promise<void>;
19
+ /** Called on each retry attempt */
20
+ onRetry?: (ctx: HookContext & { attempt: number; error: ErrorLog }) => void | Promise<void>;
21
+ /** Called when a job is dropped (TTL expired or dedup) */
22
+ onDrop?: (ctx: HookContext & { reason: "ttl" | "dedup" }) => void | Promise<void>;
23
+ }
24
+
25
+ export type Plugin = PluginHooks & { name: string };
26
+
27
+ /**
28
+ * PluginManager — registers plugins and fires lifecycle hooks.
29
+ *
30
+ * @example
31
+ * const plugins = new PluginManager();
32
+ * plugins.use(LoggerPlugin("my-service"));
33
+ * plugins.use(MetricsPlugin(prometheusRegistry));
34
+ */
35
+ export default class PluginManager {
36
+ private plugins: Plugin[] = [];
37
+
38
+ use(plugin: Plugin): this {
39
+ this.plugins.push(plugin);
40
+ return this;
41
+ }
42
+
43
+ remove(name: string): this {
44
+ this.plugins = this.plugins.filter((p) => p.name !== name);
45
+ return this;
46
+ }
47
+
48
+ async fire<K extends keyof PluginHooks>(
49
+ hook: K,
50
+ ctx: Parameters<NonNullable<PluginHooks[K]>>[0]
51
+ ): Promise<void> {
52
+ for (const plugin of this.plugins) {
53
+ const fn = plugin[hook] as ((c: typeof ctx) => void | Promise<void>) | undefined;
54
+ if (fn) {
55
+ try {
56
+ await fn(ctx);
57
+ } catch (err) {
58
+ // Plugins must never crash the main flow
59
+ console.error(`[SMSPlugin:${plugin.name}] hook "${hook}" threw:`, err);
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ // ── Built-in plugins ────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Console logger plugin.
70
+ * @example plugins.use(LoggerPlugin("order-service"));
71
+ */
72
+ export function LoggerPlugin(serviceName = "sms-sdk"): Plugin {
73
+ return {
74
+ name: "logger",
75
+ onSend: ({ to, tags }) =>
76
+ console.log(`[${serviceName}] → sending to ${to}${tags?.length ? ` [${tags.join(", ")}]` : ""}`),
77
+ onSuccess: ({ to, durationMs }) =>
78
+ console.log(`[${serviceName}] ✓ delivered to ${to} in ${durationMs}ms`),
79
+ onError: ({ to, error }) =>
80
+ console.error(`[${serviceName}] ✗ failed to ${to}: [${error.code}] ${error.message}`),
81
+ onRetry: ({ to, attempt, error }) =>
82
+ console.warn(`[${serviceName}] ↻ retry #${attempt} for ${to}: ${error.message}`),
83
+ onDrop: ({ to, reason }) =>
84
+ console.warn(`[${serviceName}] ⊘ dropped job for ${to} (${reason})`),
85
+ };
86
+ }
87
+
88
+ /**
89
+ * In-memory metrics plugin. Exposes `.snapshot()` for health-check endpoints.
90
+ *
91
+ * @example
92
+ * const metrics = MetricsPlugin();
93
+ * plugins.use(metrics);
94
+ * // later:
95
+ * console.log(metrics.snapshot());
96
+ */
97
+ export function MetricsPlugin(): Plugin & {
98
+ snapshot: () => MetricsSnapshot;
99
+ reset: () => void;
100
+ } {
101
+ const state = {
102
+ sent: 0,
103
+ succeeded: 0,
104
+ failed: 0,
105
+ dropped: 0,
106
+ retries: 0,
107
+ totalDurationMs: 0,
108
+ errorCodes: {} as Record<string, number>,
109
+ };
110
+
111
+ return {
112
+ name: "metrics",
113
+ onSend: () => { state.sent++; },
114
+ onSuccess: ({ durationMs = 0 }) => {
115
+ state.succeeded++;
116
+ state.totalDurationMs += durationMs;
117
+ },
118
+ onError: ({ error }) => {
119
+ state.failed++;
120
+ state.errorCodes[error.code] = (state.errorCodes[error.code] ?? 0) + 1;
121
+ },
122
+ onRetry: () => { state.retries++; },
123
+ onDrop: () => { state.dropped++; },
124
+ snapshot(): MetricsSnapshot {
125
+ return {
126
+ ...state,
127
+ successRate: state.sent > 0 ? state.succeeded / state.sent : 0,
128
+ avgDurationMs: state.succeeded > 0 ? state.totalDurationMs / state.succeeded : 0,
129
+ capturedAt: new Date().toISOString(),
130
+ };
131
+ },
132
+ reset() {
133
+ state.sent = 0;
134
+ state.succeeded = 0;
135
+ state.failed = 0;
136
+ state.dropped = 0;
137
+ state.retries = 0;
138
+ state.totalDurationMs = 0;
139
+ state.errorCodes = {};
140
+ },
141
+ };
142
+ }
143
+
144
+ export interface MetricsSnapshot {
145
+ sent: number;
146
+ succeeded: number;
147
+ failed: number;
148
+ dropped: number;
149
+ retries: number;
150
+ totalDurationMs: number;
151
+ errorCodes: Record<string, number>;
152
+ successRate: number;
153
+ avgDurationMs: number;
154
+ capturedAt: string;
155
+ }
@@ -1,36 +1,69 @@
1
1
  import { setTimeout } from "node:timers/promises";
2
+ import { RateLimitError } from "./errors.js";
2
3
 
3
4
  export interface RetryOptions {
4
5
  maxAttempts?: number;
5
6
  delay?: number;
6
7
  strategy?: "fixed" | "exponential";
8
+ jitter?: boolean;
9
+ /** Error codes that should NOT be retried (e.g. validation errors) */
10
+ nonRetryableCodes?: string[];
7
11
  }
8
12
 
9
13
  export default class RetryHandler {
10
- constructor(private options: RetryOptions = {}) {}
14
+ private readonly maxAttempts: number;
15
+ private readonly delay: number;
16
+ private readonly strategy: "fixed" | "exponential";
17
+ private readonly jitter: boolean;
18
+ private readonly nonRetryableCodes: Set<string>;
11
19
 
12
- async execute<T>(operation: () => Promise<T>): Promise<T> {
13
- const { maxAttempts = 3, delay = 500, strategy = "fixed" } = this.options;
20
+ constructor(options: RetryOptions = {}) {
21
+ this.maxAttempts = options.maxAttempts ?? 3;
22
+ this.delay = options.delay ?? 500;
23
+ this.strategy = options.strategy ?? "fixed";
24
+ this.jitter = options.jitter ?? true;
25
+ this.nonRetryableCodes = new Set(
26
+ options.nonRetryableCodes ?? ["VALIDATION_ERROR", "TEMPLATE_ERROR"],
27
+ );
28
+ }
14
29
 
30
+ async execute<T>(operation: () => Promise<T>): Promise<T> {
15
31
  let attempt = 0;
32
+ let lastError: unknown;
16
33
 
17
- while (attempt < maxAttempts) {
34
+ while (attempt < this.maxAttempts) {
18
35
  try {
19
36
  return await operation();
20
- } catch (error) {
37
+ } catch (error: any) {
38
+ lastError = error;
21
39
  attempt++;
22
40
 
23
- if (attempt >= maxAttempts) {
41
+ // Don't retry client-side or non-retryable errors
42
+ if (error?.isClientError || this.nonRetryableCodes.has(error?.code)) {
24
43
  throw error;
25
44
  }
26
45
 
27
- const waitTime =
28
- strategy === "exponential" ? delay * 2 ** (attempt - 1) : delay;
46
+ if (attempt >= this.maxAttempts) break;
47
+
48
+ // Respect Retry-After for rate limit errors
49
+ let waitTime: number;
50
+ if (error instanceof RateLimitError && error.retryAfter) {
51
+ waitTime = error.retryAfter * 1000;
52
+ } else {
53
+ waitTime =
54
+ this.strategy === "exponential"
55
+ ? this.delay * 2 ** (attempt - 1)
56
+ : this.delay;
57
+
58
+ if (this.jitter) {
59
+ waitTime += Math.random() * waitTime * 0.2;
60
+ }
61
+ }
29
62
 
30
63
  await setTimeout(waitTime);
31
64
  }
32
65
  }
33
66
 
34
- throw new Error("Retry failed unexpectedly");
67
+ throw lastError;
35
68
  }
36
69
  }