bridgex 1.0.0 → 2.0.0
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/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/README.md
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
# SMS Client SDK
|
|
2
|
+
|
|
3
|
+
A fault-tolerant TypeScript SDK for sending SMS messages — usable as a library, a standalone microservice, or a background job processor.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
1. [Installation](#installation)
|
|
10
|
+
2. [Quick Start](#quick-start)
|
|
11
|
+
3. [Auto-Provisioning Credentials](#auto-provisioning-credentials)
|
|
12
|
+
4. [SendConfig — Fluent Parameter Builder](#sendconfig--fluent-parameter-builder)
|
|
13
|
+
5. [Send Methods](#send-methods)
|
|
14
|
+
6. [Result & Error Types](#result--error-types)
|
|
15
|
+
7. [SMSQueue — Background Job Queue](#smsqueue--background-job-queue)
|
|
16
|
+
8. [SMSScheduler — Recurring & Scheduled Sends](#smsscheduler--recurring--scheduled-sends)
|
|
17
|
+
9. [SMSService — Run as a Microservice](#smsservice--run-as-a-microservice)
|
|
18
|
+
10. [Plugins & Hooks](#plugins--hooks)
|
|
19
|
+
11. [Fault Tolerance (Retry + Circuit Breaker)](#fault-tolerance)
|
|
20
|
+
12. [Full Configuration Reference](#full-configuration-reference)
|
|
21
|
+
13. [File Structure](#file-structure)
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install bridgex
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { SMSClient, SendConfig, LoggerPlugin } from "bridgex";
|
|
37
|
+
|
|
38
|
+
const client = new SMSClient({
|
|
39
|
+
baseUrl: "https://api.your-service.com",
|
|
40
|
+
apiKey: "your-access-token",
|
|
41
|
+
projectKey: "your-repo-token",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
client.use(LoggerPlugin("my-app"));
|
|
45
|
+
|
|
46
|
+
const result = await client.send({
|
|
47
|
+
to: "+15551234567",
|
|
48
|
+
template: "Hello {name}, your order {orderId} is confirmed!",
|
|
49
|
+
variables: { name: "Alice", orderId: "ORD-999" },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (result.ok) {
|
|
53
|
+
console.log("Sent!", result.data);
|
|
54
|
+
} else {
|
|
55
|
+
console.error(result.error); // typed ErrorLog — never throws
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Auto-Provisioning Credentials
|
|
62
|
+
|
|
63
|
+
Your service auto-generates a repo token, access token, and URL. Use `CredentialProvisioner` to fetch them automatically.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { CredentialProvisioner, SMSClient } from "bridgex";
|
|
67
|
+
|
|
68
|
+
const credentials = await CredentialProvisioner.provision({
|
|
69
|
+
provisionUrl: "https://api.your-service.com/provision",
|
|
70
|
+
adminKey: "your-admin-key",
|
|
71
|
+
projectName: "my-app",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// credentials = { baseUrl, apiKey, projectKey }
|
|
75
|
+
const client = new SMSClient(credentials);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Accepts `camelCase` or `snake_case` field names from your API (`apiKey`, `api_key`, `accessToken`, `access_token`, etc.).
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## SendConfig — Fluent Parameter Builder
|
|
83
|
+
|
|
84
|
+
`SendConfig` lets you define templates, defaults, tags, TTLs, and priority **once** and reuse them across many calls. No more copying the same template string everywhere.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { SendConfig } from "bridgex";
|
|
88
|
+
|
|
89
|
+
// Build a reusable config
|
|
90
|
+
const otpConfig = SendConfig.otp(10) // preset: OTP with 10-min expiry
|
|
91
|
+
.set("appName", "MyApp"); // add a shared default variable
|
|
92
|
+
|
|
93
|
+
// Use it
|
|
94
|
+
const result = await client.send(
|
|
95
|
+
otpConfig.for("+15551234567", { code: "482910" }),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Send to many recipients with per-recipient variables
|
|
99
|
+
const results = await client.sendMany(
|
|
100
|
+
otpConfig.forMany([
|
|
101
|
+
{ to: "+15550000001", variables: { code: "111111" } },
|
|
102
|
+
{ to: "+15550000002", variables: { code: "222222" } },
|
|
103
|
+
]),
|
|
104
|
+
otpConfig._template, // or pass template inline
|
|
105
|
+
);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Builder Methods
|
|
109
|
+
|
|
110
|
+
| Method | Description |
|
|
111
|
+
| ------------------------------------ | ---------------------------------------------- |
|
|
112
|
+
| `.template(str)` | Set the message template |
|
|
113
|
+
| `.defaults(vars)` | Default variable values (overridable per-call) |
|
|
114
|
+
| `.set(key, value)` | Add a single default variable |
|
|
115
|
+
| `.tag(...tags)` | Label this config (used in logs and hooks) |
|
|
116
|
+
| `.ttl(ms)` | Drop jobs older than this (queue only) |
|
|
117
|
+
| `.priority("high"\|"normal"\|"low")` | Queue priority |
|
|
118
|
+
| `.dedupKey(key)` | Suppress duplicate jobs in the queue |
|
|
119
|
+
| `.clone()` | Copy without mutating |
|
|
120
|
+
| `.merge(other)` | Combine two configs (other wins on conflicts) |
|
|
121
|
+
|
|
122
|
+
### Static Presets
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
SendConfig.otp(expiryMinutes?) // OTP — high priority, TTL-aware
|
|
126
|
+
SendConfig.orderNotification() // Order status updates
|
|
127
|
+
SendConfig.reminder() // Appointment / task reminders
|
|
128
|
+
SendConfig.promo() // Marketing messages — low priority
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Materialise Methods
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
config.for(to, variables?) // → SendParams (use with client.send)
|
|
135
|
+
config.forMany(recipients) // → SendParams[] (use with client.sendMany)
|
|
136
|
+
config.forObject(to, object) // → SendObjectParams (use with client.sendObject)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Send Methods
|
|
142
|
+
|
|
143
|
+
All methods return a `Result` and **never throw**.
|
|
144
|
+
|
|
145
|
+
### `client.send(params)` → `Promise<Result>`
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const result = await client.send({
|
|
149
|
+
to: "+15551234567",
|
|
150
|
+
template: "Hi {user.firstName}, your balance is {account.balance}",
|
|
151
|
+
variables: {
|
|
152
|
+
user: { firstName: "Bob" },
|
|
153
|
+
account: { balance: "$42.00" },
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Supports **nested variable paths** (`{nested.key}`).
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
### `client.sendMany(recipients, template, sharedVars?)` → `Promise<BatchResult>`
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const result = await client.sendMany(
|
|
166
|
+
[
|
|
167
|
+
{ to: "+15550000001", variables: { name: "Alice" } },
|
|
168
|
+
{ to: "+15550000002", variables: { name: "Bob" } },
|
|
169
|
+
],
|
|
170
|
+
"Hi {name}, your appointment is tomorrow at {time}.",
|
|
171
|
+
{ time: "10:00 AM" },
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
console.log(`${result.successCount}/${result.total} sent`);
|
|
175
|
+
result.failed.forEach((f) => console.error(`[${f.to}]`, f.error.message));
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
### `client.sendObject(params)` → `Promise<Result>`
|
|
181
|
+
|
|
182
|
+
Auto-extracts the message from `message`, `body`, `text`, or `content` fields. Or pass a template to use any field as a variable.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// Auto-detection
|
|
186
|
+
await client.sendObject({
|
|
187
|
+
to: "+15551234567",
|
|
188
|
+
object: { message: "Your package has shipped!", trackingId: "TRK-123" },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// With template
|
|
192
|
+
await client.sendObject({
|
|
193
|
+
to: "+15551234567",
|
|
194
|
+
object: { firstName: "Alice", trackingId: "TRK-123" },
|
|
195
|
+
template: "Hi {firstName}, your tracking ID is {trackingId}.",
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
### `client.sendObjectMany(items)` → `Promise<BatchResult>`
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
await client.sendObjectMany([
|
|
205
|
+
{
|
|
206
|
+
to: "+15550000001",
|
|
207
|
+
object: { firstName: "Alice", orderId: "ORD-1" },
|
|
208
|
+
template: "Hi {firstName}, order {orderId} is ready.",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
to: "+15550000002",
|
|
212
|
+
object: { firstName: "Bob", orderId: "ORD-2" },
|
|
213
|
+
template: "Hi {firstName}, order {orderId} is ready.",
|
|
214
|
+
},
|
|
215
|
+
]);
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Result & Error Types
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
type Result<T> =
|
|
224
|
+
| { ok: true; data: T; error: null }
|
|
225
|
+
| { ok: false; data: null; error: ErrorLog };
|
|
226
|
+
|
|
227
|
+
interface ErrorLog {
|
|
228
|
+
name: string;
|
|
229
|
+
message: string;
|
|
230
|
+
code: string; // see Error Codes table
|
|
231
|
+
isClientError: boolean; // your code caused it
|
|
232
|
+
isServerError: boolean; // server returned an error
|
|
233
|
+
details?: unknown;
|
|
234
|
+
timestamp: string; // ISO 8601
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### BatchResult
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
interface BatchResult<T> {
|
|
242
|
+
succeeded: Array<{ index: number; to: string; data: T }>;
|
|
243
|
+
failed: Array<{ index: number; to: string; error: ErrorLog }>;
|
|
244
|
+
total: number;
|
|
245
|
+
successCount: number;
|
|
246
|
+
failureCount: number;
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Error Codes
|
|
251
|
+
|
|
252
|
+
| Code | Client? | Server? | Cause |
|
|
253
|
+
| ------------------ | ------- | ------- | ------------------------------ |
|
|
254
|
+
| `VALIDATION_ERROR` | ✅ | ❌ | Missing required param |
|
|
255
|
+
| `TEMPLATE_ERROR` | ✅ | ❌ | Variable missing from template |
|
|
256
|
+
| `SERVER_ERROR` | ❌ | ✅ | Non-2xx HTTP response |
|
|
257
|
+
| `RATE_LIMIT_ERROR` | ❌ | ✅ | 429 — respects `Retry-After` |
|
|
258
|
+
| `NETWORK_ERROR` | ❌ | ❌ | Timeout / no connectivity |
|
|
259
|
+
| `CIRCUIT_OPEN` | ❌ | ❌ | Circuit breaker tripped |
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## SMSQueue — Background Job Queue
|
|
264
|
+
|
|
265
|
+
Fire-and-forget sending with priority lanes, deduplication, TTL, and delayed sends.
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import { SMSQueue, SendConfig, LoggerPlugin, PluginManager } from "bridgex";
|
|
269
|
+
|
|
270
|
+
const plugins = new PluginManager().use(LoggerPlugin("worker"));
|
|
271
|
+
const queue = new SMSQueue(client, { concurrency: 5 }, plugins);
|
|
272
|
+
queue.start();
|
|
273
|
+
|
|
274
|
+
// Immediate
|
|
275
|
+
const otpCfg = SendConfig.otp().dedupKey(`otp:${userId}`);
|
|
276
|
+
queue.enqueue(otpCfg.for(phone, { code: "482910" }));
|
|
277
|
+
|
|
278
|
+
// Delayed (send in 5 minutes)
|
|
279
|
+
queue.enqueueAfter(reminderCfg.for(phone), 5 * 60 * 1000);
|
|
280
|
+
|
|
281
|
+
// At a specific time
|
|
282
|
+
queue.enqueueAt(promoCfg.for(phone), new Date("2025-12-25T09:00:00").getTime());
|
|
283
|
+
|
|
284
|
+
// Stats
|
|
285
|
+
console.log(queue.stats());
|
|
286
|
+
// { pending: 3, running: 1, completed: 47, dropped: 2 }
|
|
287
|
+
|
|
288
|
+
// Cancel a specific job
|
|
289
|
+
queue.cancel(jobId);
|
|
290
|
+
|
|
291
|
+
// Drain all remaining jobs then stop
|
|
292
|
+
await queue.drain();
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Queue Options
|
|
296
|
+
|
|
297
|
+
| Option | Default | Description |
|
|
298
|
+
| -------------- | ------- | -------------------------------------------- |
|
|
299
|
+
| `concurrency` | `3` | Max parallel workers |
|
|
300
|
+
| `pollInterval` | `1000` | How often (ms) to check for ready jobs |
|
|
301
|
+
| `autoStart` | `true` | Start workers automatically on first enqueue |
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## SMSScheduler — Recurring & Scheduled Sends
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
import { SMSScheduler, SendConfig } from "bridgex";
|
|
309
|
+
|
|
310
|
+
const scheduler = new SMSScheduler(queue);
|
|
311
|
+
scheduler.start();
|
|
312
|
+
|
|
313
|
+
// Every 24 hours
|
|
314
|
+
scheduler.every(
|
|
315
|
+
24 * 60 * 60 * 1000,
|
|
316
|
+
"daily-report",
|
|
317
|
+
reportConfig.for(adminPhone),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Cron expression: weekdays at 9am
|
|
321
|
+
scheduler.cron("0 9 * * 1-5", "weekday-digest", digestConfig.for(adminPhone), {
|
|
322
|
+
maxRuns: 50,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// One-shot future send
|
|
326
|
+
scheduler.once(
|
|
327
|
+
new Date("2025-06-01T09:00:00"),
|
|
328
|
+
"summer-launch",
|
|
329
|
+
promoConfig.for(phone),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// Control
|
|
333
|
+
scheduler.pause("daily-report");
|
|
334
|
+
scheduler.resume("daily-report");
|
|
335
|
+
scheduler.remove("summer-launch");
|
|
336
|
+
|
|
337
|
+
// List all scheduled jobs
|
|
338
|
+
console.log(scheduler.list());
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**Cron format:** `minute hour day-of-month month day-of-week`
|
|
342
|
+
|
|
343
|
+
- `*` = every, `,` = list, `-` = range, `/` = step
|
|
344
|
+
- Example: `"30 8,12,18 * * *"` = 8:30, 12:30, and 18:30 every day
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## SMSService — Run as a Microservice
|
|
349
|
+
|
|
350
|
+
Deploy the SDK as a standalone REST service so any other service (Python, Go, Ruby, etc.) can send SMS without bundling this SDK.
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
import {
|
|
354
|
+
SMSClient,
|
|
355
|
+
SMSQueue,
|
|
356
|
+
SMSService,
|
|
357
|
+
MetricsPlugin,
|
|
358
|
+
PluginManager,
|
|
359
|
+
} from "bridgex";
|
|
360
|
+
|
|
361
|
+
const client = new SMSClient({ baseUrl, apiKey, projectKey });
|
|
362
|
+
const queue = new SMSQueue(client, { concurrency: 10 });
|
|
363
|
+
const metrics = MetricsPlugin();
|
|
364
|
+
|
|
365
|
+
client.use(metrics);
|
|
366
|
+
queue.start();
|
|
367
|
+
|
|
368
|
+
const service = new SMSService(client, {
|
|
369
|
+
port: 4000,
|
|
370
|
+
apiKey: "my-service-secret",
|
|
371
|
+
})
|
|
372
|
+
.withQueue(queue)
|
|
373
|
+
.withMetrics(metrics);
|
|
374
|
+
|
|
375
|
+
await service.start();
|
|
376
|
+
// [SMSService] Listening on http://0.0.0.0:4000
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### REST API
|
|
380
|
+
|
|
381
|
+
| Method | Path | Description |
|
|
382
|
+
| -------- | ------------------- | ------------------------------------ |
|
|
383
|
+
| `POST` | `/send` | Send a single message |
|
|
384
|
+
| `POST` | `/send/many` | Send to many recipients |
|
|
385
|
+
| `POST` | `/send/object` | Send from a plain object |
|
|
386
|
+
| `POST` | `/send/object/many` | Send objects to many recipients |
|
|
387
|
+
| `POST` | `/queue/enqueue` | Fire-and-forget via queue |
|
|
388
|
+
| `POST` | `/queue/schedule` | Schedule a future send |
|
|
389
|
+
| `GET` | `/queue/stats` | Queue stats |
|
|
390
|
+
| `DELETE` | `/queue/:id` | Cancel a queued job |
|
|
391
|
+
| `GET` | `/health` | Liveness + circuit state + uptime |
|
|
392
|
+
| `GET` | `/metrics` | Send rate, success rate, avg latency |
|
|
393
|
+
|
|
394
|
+
All requests must include `x-service-key: <your-service-secret>` if `apiKey` is configured.
|
|
395
|
+
|
|
396
|
+
#### Example: POST /send
|
|
397
|
+
|
|
398
|
+
```bash
|
|
399
|
+
curl -X POST http://localhost:4000/send \
|
|
400
|
+
-H "Content-Type: application/json" \
|
|
401
|
+
-H "x-service-key: my-service-secret" \
|
|
402
|
+
-d '{ "to": "+15551234567", "template": "Hello {name}", "variables": { "name": "Alice" } }'
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
#### Example: POST /queue/schedule
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
# Send at a specific time
|
|
409
|
+
curl -X POST http://localhost:4000/queue/schedule \
|
|
410
|
+
-H "x-service-key: my-service-secret" \
|
|
411
|
+
-H "Content-Type: application/json" \
|
|
412
|
+
-d '{ "at": "2025-12-25T09:00:00Z", "to": "+1555...", "template": "Merry Christmas {name}!", "variables": { "name": "Bob" } }'
|
|
413
|
+
|
|
414
|
+
# Send after a delay
|
|
415
|
+
curl -X POST http://localhost:4000/queue/schedule \
|
|
416
|
+
-H "x-service-key: my-service-secret" \
|
|
417
|
+
-H "Content-Type: application/json" \
|
|
418
|
+
-d '{ "delayMs": 300000, "to": "+1555...", "template": "Your reminder: {text}", "variables": { "text": "Call the dentist" } }'
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
#### GET /health response
|
|
422
|
+
|
|
423
|
+
```json
|
|
424
|
+
{
|
|
425
|
+
"status": "ok",
|
|
426
|
+
"circuitState": "CLOSED",
|
|
427
|
+
"uptime": 3600,
|
|
428
|
+
"startedAt": "2025-01-01T09:00:00.000Z",
|
|
429
|
+
"queue": { "pending": 0, "running": 2, "completed": 1024, "dropped": 3 }
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## Plugins & Hooks
|
|
436
|
+
|
|
437
|
+
Plugins run on every send lifecycle event. They're async and never crash the main flow.
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
import { SMSClient, LoggerPlugin, MetricsPlugin } from "bridgex";
|
|
441
|
+
|
|
442
|
+
// Built-in logger
|
|
443
|
+
client.use(LoggerPlugin("order-service"));
|
|
444
|
+
|
|
445
|
+
// Built-in metrics
|
|
446
|
+
const metrics = MetricsPlugin();
|
|
447
|
+
client.use(metrics);
|
|
448
|
+
|
|
449
|
+
// Later
|
|
450
|
+
console.log(metrics.snapshot());
|
|
451
|
+
// { sent: 100, succeeded: 97, failed: 3, retries: 5, successRate: 0.97, avgDurationMs: 212, ... }
|
|
452
|
+
metrics.reset();
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Custom Plugin
|
|
456
|
+
|
|
457
|
+
```ts
|
|
458
|
+
client.use({
|
|
459
|
+
name: "slack-alerts",
|
|
460
|
+
onError: async ({ to, error }) => {
|
|
461
|
+
await slackClient.send(
|
|
462
|
+
`SMS failed to ${to}: [${error.code}] ${error.message}`,
|
|
463
|
+
);
|
|
464
|
+
},
|
|
465
|
+
onRetry: async ({ to, attempt }) => {
|
|
466
|
+
if (attempt >= 2) console.warn(`High retry count for ${to}`);
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### Hook Reference
|
|
472
|
+
|
|
473
|
+
| Hook | When it fires |
|
|
474
|
+
| ----------- | --------------------------------------------- |
|
|
475
|
+
| `onSend` | Before every send attempt |
|
|
476
|
+
| `onSuccess` | After a successful delivery |
|
|
477
|
+
| `onError` | After all retries are exhausted |
|
|
478
|
+
| `onRetry` | On each retry attempt |
|
|
479
|
+
| `onDrop` | When a queued job is discarded (TTL or dedup) |
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
## Fault Tolerance
|
|
484
|
+
|
|
485
|
+
### Retry Handler
|
|
486
|
+
|
|
487
|
+
Automatically retries transient failures (network errors, 5xx, rate limits). Never retries client-side errors (`VALIDATION_ERROR`, `TEMPLATE_ERROR`). Respects `Retry-After` headers on 429s.
|
|
488
|
+
|
|
489
|
+
### Circuit Breaker
|
|
490
|
+
|
|
491
|
+
After N consecutive failures the circuit **opens** — all calls immediately fail with `CircuitOpenError` instead of hammering a down service. After a timeout the circuit goes **half-open** and probes before closing again.
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
console.log(client.circuitState); // "CLOSED" | "OPEN" | "HALF_OPEN"
|
|
495
|
+
client.resetCircuit(); // manual reset after fixing downstream
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
---
|
|
499
|
+
|
|
500
|
+
## Full Configuration Reference
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
const client = new SMSClient({
|
|
504
|
+
baseUrl: "https://api.your-service.com",
|
|
505
|
+
apiKey: "your-access-token",
|
|
506
|
+
projectKey: "your-repo-token",
|
|
507
|
+
|
|
508
|
+
retry: {
|
|
509
|
+
maxAttempts: 4,
|
|
510
|
+
delay: 300,
|
|
511
|
+
strategy: "exponential", // "fixed" | "exponential"
|
|
512
|
+
jitter: true, // ±20% randomness
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
circuitBreaker: {
|
|
516
|
+
threshold: 5, // failures to open circuit
|
|
517
|
+
timeout: 30_000, // ms before half-open probe
|
|
518
|
+
successThreshold: 2, // probes to fully close
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
concurrency: 5,
|
|
522
|
+
batchSize: 50,
|
|
523
|
+
plugins: [LoggerPlugin("app")],
|
|
524
|
+
});
|
|
525
|
+
```
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|