@gravito/flare 3.0.3 → 3.3.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 +74 -135
- package/README.zh-TW.md +126 -11
- package/dist/index.cjs +694 -92
- package/dist/index.d.cts +242 -17
- package/dist/index.d.ts +242 -17
- package/dist/index.js +680 -91
- package/package.json +4 -3
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __export = (target, all) => {
|
|
7
9
|
for (var name in all)
|
|
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
15
17
|
}
|
|
16
18
|
return to;
|
|
17
19
|
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
18
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
29
|
|
|
20
30
|
// src/index.ts
|
|
@@ -25,9 +35,12 @@ __export(index_exports, {
|
|
|
25
35
|
MailChannel: () => MailChannel,
|
|
26
36
|
Notification: () => Notification,
|
|
27
37
|
NotificationManager: () => NotificationManager,
|
|
38
|
+
NotificationMetricsCollector: () => NotificationMetricsCollector,
|
|
28
39
|
OrbitFlare: () => OrbitFlare,
|
|
29
40
|
SlackChannel: () => SlackChannel,
|
|
30
|
-
SmsChannel: () => SmsChannel
|
|
41
|
+
SmsChannel: () => SmsChannel,
|
|
42
|
+
TemplatedNotification: () => TemplatedNotification,
|
|
43
|
+
toPrometheusFormat: () => toPrometheusFormat
|
|
31
44
|
});
|
|
32
45
|
module.exports = __toCommonJS(index_exports);
|
|
33
46
|
|
|
@@ -167,48 +180,145 @@ var SmsChannel = class {
|
|
|
167
180
|
/**
|
|
168
181
|
* Send SMS via AWS SNS.
|
|
169
182
|
*/
|
|
170
|
-
async sendViaAwsSns(
|
|
171
|
-
|
|
183
|
+
async sendViaAwsSns(message) {
|
|
184
|
+
let SNSClient;
|
|
185
|
+
let PublishCommand;
|
|
186
|
+
try {
|
|
187
|
+
const awsSns = await import("@aws-sdk/client-sns");
|
|
188
|
+
SNSClient = awsSns.SNSClient;
|
|
189
|
+
PublishCommand = awsSns.PublishCommand;
|
|
190
|
+
} catch {
|
|
191
|
+
throw new Error(
|
|
192
|
+
"AWS SNS SMS requires @aws-sdk/client-sns. Install it with: bun add @aws-sdk/client-sns"
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const client = new SNSClient({
|
|
196
|
+
region: this.config.region || "us-east-1",
|
|
197
|
+
credentials: this.config.accessKeyId && this.config.secretAccessKey ? {
|
|
198
|
+
accessKeyId: this.config.accessKeyId,
|
|
199
|
+
secretAccessKey: this.config.secretAccessKey
|
|
200
|
+
} : void 0
|
|
201
|
+
// Use environment variables or IAM role
|
|
202
|
+
});
|
|
203
|
+
const command = new PublishCommand({
|
|
204
|
+
PhoneNumber: message.to,
|
|
205
|
+
Message: message.message,
|
|
206
|
+
MessageAttributes: {
|
|
207
|
+
"AWS.SNS.SMS.SenderID": {
|
|
208
|
+
DataType: "String",
|
|
209
|
+
StringValue: message.from || this.config.from || "GRAVITO"
|
|
210
|
+
},
|
|
211
|
+
"AWS.SNS.SMS.SMSType": {
|
|
212
|
+
DataType: "String",
|
|
213
|
+
StringValue: "Transactional"
|
|
214
|
+
// Or 'Promotional'
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
try {
|
|
219
|
+
await client.send(command);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
222
|
+
throw new Error(`Failed to send SMS via AWS SNS: ${err.message}`);
|
|
223
|
+
}
|
|
172
224
|
}
|
|
173
225
|
};
|
|
174
226
|
|
|
175
|
-
// src/
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
227
|
+
// src/metrics/exporters/PrometheusExporter.ts
|
|
228
|
+
function toPrometheusFormat(summary) {
|
|
229
|
+
const lines = [];
|
|
230
|
+
lines.push(`# HELP notification_total Total notifications sent`);
|
|
231
|
+
lines.push(`# TYPE notification_total counter`);
|
|
232
|
+
lines.push(`notification_total ${summary.totalSent}`);
|
|
233
|
+
lines.push(`# HELP notification_success_total Successful notifications`);
|
|
234
|
+
lines.push(`# TYPE notification_success_total counter`);
|
|
235
|
+
lines.push(`notification_success_total ${summary.totalSuccess}`);
|
|
236
|
+
lines.push(`# HELP notification_failed_total Failed notifications`);
|
|
237
|
+
lines.push(`# TYPE notification_failed_total counter`);
|
|
238
|
+
lines.push(`notification_failed_total ${summary.totalFailed}`);
|
|
239
|
+
lines.push(`# HELP notification_channel_total Notifications by channel`);
|
|
240
|
+
lines.push(`# TYPE notification_channel_total counter`);
|
|
241
|
+
for (const [channel, stats] of Object.entries(summary.byChannel)) {
|
|
242
|
+
lines.push(`notification_channel_total{channel="${channel}",status="success"} ${stats.success}`);
|
|
243
|
+
lines.push(`notification_channel_total{channel="${channel}",status="failed"} ${stats.failed}`);
|
|
183
244
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
245
|
+
lines.push(`# HELP notification_duration_avg Average notification duration in ms`);
|
|
246
|
+
lines.push(`# TYPE notification_duration_avg gauge`);
|
|
247
|
+
lines.push(`notification_duration_avg ${summary.avgDuration.toFixed(2)}`);
|
|
248
|
+
return lines.join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/metrics/NotificationMetrics.ts
|
|
252
|
+
var NotificationMetricsCollector = class {
|
|
253
|
+
metrics = [];
|
|
254
|
+
maxHistory;
|
|
255
|
+
constructor(maxHistory = 1e4) {
|
|
256
|
+
this.maxHistory = maxHistory;
|
|
190
257
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
throw new Error("toBroadcast method not implemented");
|
|
258
|
+
record(metric) {
|
|
259
|
+
this.metrics.push(metric);
|
|
260
|
+
if (this.metrics.length > this.maxHistory) {
|
|
261
|
+
this.metrics = this.metrics.slice(-this.maxHistory);
|
|
262
|
+
}
|
|
197
263
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
264
|
+
getSummary(since) {
|
|
265
|
+
let filtered = this.metrics;
|
|
266
|
+
if (since) {
|
|
267
|
+
filtered = this.metrics.filter((m) => m.timestamp >= since);
|
|
268
|
+
}
|
|
269
|
+
const byChannel = {};
|
|
270
|
+
const byNotification = {};
|
|
271
|
+
for (const metric of filtered) {
|
|
272
|
+
if (!byChannel[metric.channel]) {
|
|
273
|
+
byChannel[metric.channel] = { sent: 0, success: 0, failed: 0, avgDuration: 0 };
|
|
274
|
+
}
|
|
275
|
+
byChannel[metric.channel].sent++;
|
|
276
|
+
if (metric.success) {
|
|
277
|
+
byChannel[metric.channel].success++;
|
|
278
|
+
} else {
|
|
279
|
+
byChannel[metric.channel].failed++;
|
|
280
|
+
}
|
|
281
|
+
if (!byNotification[metric.notification]) {
|
|
282
|
+
byNotification[metric.notification] = { sent: 0, success: 0, failed: 0, avgDuration: 0 };
|
|
283
|
+
}
|
|
284
|
+
byNotification[metric.notification].sent++;
|
|
285
|
+
if (metric.success) {
|
|
286
|
+
byNotification[metric.notification].success++;
|
|
287
|
+
} else {
|
|
288
|
+
byNotification[metric.notification].failed++;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
for (const channel of Object.keys(byChannel)) {
|
|
292
|
+
const channelMetrics = filtered.filter((m) => m.channel === channel);
|
|
293
|
+
byChannel[channel].avgDuration = channelMetrics.reduce((sum, m) => sum + m.duration, 0) / channelMetrics.length;
|
|
294
|
+
}
|
|
295
|
+
for (const notification of Object.keys(byNotification)) {
|
|
296
|
+
const notificationMetrics = filtered.filter((m) => m.notification === notification);
|
|
297
|
+
byNotification[notification].avgDuration = notificationMetrics.reduce((sum, m) => sum + m.duration, 0) / notificationMetrics.length;
|
|
298
|
+
}
|
|
299
|
+
const successMetrics = filtered.filter((m) => m.success);
|
|
300
|
+
return {
|
|
301
|
+
totalSent: filtered.length,
|
|
302
|
+
totalSuccess: successMetrics.length,
|
|
303
|
+
totalFailed: filtered.length - successMetrics.length,
|
|
304
|
+
avgDuration: filtered.length > 0 ? filtered.reduce((sum, m) => sum + m.duration, 0) / filtered.length : 0,
|
|
305
|
+
byChannel,
|
|
306
|
+
byNotification
|
|
307
|
+
};
|
|
204
308
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
309
|
+
getRecentFailures(limit = 10) {
|
|
310
|
+
return this.metrics.filter((m) => !m.success).slice(-limit);
|
|
311
|
+
}
|
|
312
|
+
getSlowNotifications(threshold, limit = 10) {
|
|
313
|
+
return this.metrics.filter((m) => m.duration > threshold).slice(-limit);
|
|
314
|
+
}
|
|
315
|
+
clear() {
|
|
316
|
+
this.metrics = [];
|
|
211
317
|
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// src/Notification.ts
|
|
321
|
+
var Notification = class {
|
|
212
322
|
/**
|
|
213
323
|
* Check whether this notification should be queued.
|
|
214
324
|
*/
|
|
@@ -231,6 +341,107 @@ var Notification = class {
|
|
|
231
341
|
}
|
|
232
342
|
};
|
|
233
343
|
|
|
344
|
+
// src/utils/retry.ts
|
|
345
|
+
var DEFAULT_RETRY_OPTIONS = {
|
|
346
|
+
maxAttempts: 3,
|
|
347
|
+
baseDelay: 1e3,
|
|
348
|
+
backoff: "exponential",
|
|
349
|
+
maxDelay: 3e4
|
|
350
|
+
};
|
|
351
|
+
async function withRetry(fn, options = {}) {
|
|
352
|
+
const config = { ...DEFAULT_RETRY_OPTIONS, ...options };
|
|
353
|
+
let lastError;
|
|
354
|
+
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
|
|
355
|
+
try {
|
|
356
|
+
return await fn();
|
|
357
|
+
} catch (error) {
|
|
358
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
359
|
+
if (config.shouldRetry && !config.shouldRetry(lastError, attempt)) {
|
|
360
|
+
throw lastError;
|
|
361
|
+
}
|
|
362
|
+
if (attempt === config.maxAttempts) {
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
const delay = calculateDelay(attempt, config);
|
|
366
|
+
config.onRetry?.(lastError, attempt, delay);
|
|
367
|
+
await sleep(delay);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
throw lastError;
|
|
371
|
+
}
|
|
372
|
+
function calculateDelay(attempt, config) {
|
|
373
|
+
let delay;
|
|
374
|
+
switch (config.backoff) {
|
|
375
|
+
case "fixed":
|
|
376
|
+
delay = config.baseDelay;
|
|
377
|
+
break;
|
|
378
|
+
case "linear":
|
|
379
|
+
delay = config.baseDelay * attempt;
|
|
380
|
+
break;
|
|
381
|
+
case "exponential":
|
|
382
|
+
delay = config.baseDelay * 2 ** (attempt - 1);
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
const jitter = delay * 0.1 * Math.random();
|
|
386
|
+
delay = delay + jitter;
|
|
387
|
+
return Math.min(delay, config.maxDelay);
|
|
388
|
+
}
|
|
389
|
+
function sleep(ms) {
|
|
390
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
391
|
+
}
|
|
392
|
+
function isRetryableError(error) {
|
|
393
|
+
const message = error.message.toLowerCase();
|
|
394
|
+
if (message.includes("network") || message.includes("timeout")) {
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
if (message.includes("rate limit") || message.includes("too many requests")) {
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
if (message.includes("503") || message.includes("service unavailable")) {
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/utils/serialization.ts
|
|
407
|
+
function deepSerialize(obj, seen = /* @__PURE__ */ new WeakSet()) {
|
|
408
|
+
if (obj === null || typeof obj !== "object") {
|
|
409
|
+
return obj;
|
|
410
|
+
}
|
|
411
|
+
if (seen.has(obj)) {
|
|
412
|
+
return "[Circular]";
|
|
413
|
+
}
|
|
414
|
+
seen.add(obj);
|
|
415
|
+
if (obj instanceof Date) {
|
|
416
|
+
return { __type: "Date", value: obj.toISOString() };
|
|
417
|
+
}
|
|
418
|
+
if (obj instanceof Map) {
|
|
419
|
+
return {
|
|
420
|
+
__type: "Map",
|
|
421
|
+
value: Array.from(obj.entries()).map(([k, v]) => [
|
|
422
|
+
deepSerialize(k, seen),
|
|
423
|
+
deepSerialize(v, seen)
|
|
424
|
+
])
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (obj instanceof Set) {
|
|
428
|
+
return {
|
|
429
|
+
__type: "Set",
|
|
430
|
+
value: Array.from(obj).map((v) => deepSerialize(v, seen))
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
if (Array.isArray(obj)) {
|
|
434
|
+
return obj.map((item) => deepSerialize(item, seen));
|
|
435
|
+
}
|
|
436
|
+
const result = {};
|
|
437
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
438
|
+
if (!key.startsWith("_") && typeof value !== "function") {
|
|
439
|
+
result[key] = deepSerialize(value, seen);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
|
|
234
445
|
// src/NotificationManager.ts
|
|
235
446
|
var NotificationManager = class {
|
|
236
447
|
constructor(core) {
|
|
@@ -244,6 +455,25 @@ var NotificationManager = class {
|
|
|
244
455
|
* Queue manager (optional, injected by `orbit-queue`).
|
|
245
456
|
*/
|
|
246
457
|
queueManager;
|
|
458
|
+
metrics;
|
|
459
|
+
/**
|
|
460
|
+
* Enable metrics collection.
|
|
461
|
+
*/
|
|
462
|
+
enableMetrics(maxHistory = 1e4) {
|
|
463
|
+
this.metrics = new NotificationMetricsCollector(maxHistory);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Get metrics summary.
|
|
467
|
+
*/
|
|
468
|
+
getMetrics(since) {
|
|
469
|
+
return this.metrics?.getSummary(since);
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Get recent failures.
|
|
473
|
+
*/
|
|
474
|
+
getRecentFailures(limit = 10) {
|
|
475
|
+
return this.metrics?.getRecentFailures(limit) ?? [];
|
|
476
|
+
}
|
|
247
477
|
/**
|
|
248
478
|
* Register a notification channel.
|
|
249
479
|
*
|
|
@@ -266,16 +496,29 @@ var NotificationManager = class {
|
|
|
266
496
|
*
|
|
267
497
|
* @param notifiable - The recipient of the notification.
|
|
268
498
|
* @param notification - The notification instance.
|
|
269
|
-
* @
|
|
499
|
+
* @param options - Options for sending.
|
|
500
|
+
* @returns A promise that resolves to the notification result.
|
|
270
501
|
*
|
|
271
502
|
* @example
|
|
272
503
|
* ```typescript
|
|
273
|
-
* await notificationManager.send(user, new InvoicePaid(invoice))
|
|
504
|
+
* const result = await notificationManager.send(user, new InvoicePaid(invoice))
|
|
505
|
+
* if (!result.allSuccess) { ... }
|
|
274
506
|
* ```
|
|
275
507
|
*/
|
|
276
|
-
async send(notifiable, notification) {
|
|
508
|
+
async send(notifiable, notification, options = {}) {
|
|
277
509
|
const channels = notification.via(notifiable);
|
|
510
|
+
const startTime = Date.now();
|
|
511
|
+
await this.core.hooks.emit("notification:sending", {
|
|
512
|
+
notification,
|
|
513
|
+
notifiable,
|
|
514
|
+
channels
|
|
515
|
+
});
|
|
278
516
|
if (notification.shouldQueue() && this.queueManager) {
|
|
517
|
+
await this.core.hooks.emit("notification:queued", {
|
|
518
|
+
notification,
|
|
519
|
+
notifiable,
|
|
520
|
+
channels
|
|
521
|
+
});
|
|
279
522
|
const queueConfig = notification.getQueueConfig();
|
|
280
523
|
const queueJob = {
|
|
281
524
|
type: "notification",
|
|
@@ -294,33 +537,281 @@ var NotificationManager = class {
|
|
|
294
537
|
queueConfig.connection,
|
|
295
538
|
queueConfig.delay
|
|
296
539
|
);
|
|
297
|
-
return
|
|
540
|
+
return {
|
|
541
|
+
notification: notification.constructor.name,
|
|
542
|
+
notifiable: notifiable.getNotifiableId(),
|
|
543
|
+
results: [{ success: true, channel: "queue" }],
|
|
544
|
+
allSuccess: true,
|
|
545
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const results = await this.sendNow(notifiable, notification, channels, options);
|
|
549
|
+
const totalDuration = Date.now() - startTime;
|
|
550
|
+
await this.core.hooks.emit("notification:sent", {
|
|
551
|
+
notification,
|
|
552
|
+
notifiable,
|
|
553
|
+
results,
|
|
554
|
+
allSuccess: results.every((r) => r.success),
|
|
555
|
+
totalDuration
|
|
556
|
+
});
|
|
557
|
+
if (options.throwOnError) {
|
|
558
|
+
const errors = results.filter((r) => !r.success && r.error).map((r) => r.error);
|
|
559
|
+
if (errors.length > 0) {
|
|
560
|
+
throw new AggregateError(errors, `Notification failed on ${errors.length} channel(s)`);
|
|
561
|
+
}
|
|
298
562
|
}
|
|
299
|
-
|
|
563
|
+
return {
|
|
564
|
+
notification: notification.constructor.name,
|
|
565
|
+
notifiable: notifiable.getNotifiableId(),
|
|
566
|
+
results,
|
|
567
|
+
allSuccess: results.every((r) => r.success),
|
|
568
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
569
|
+
};
|
|
300
570
|
}
|
|
301
571
|
/**
|
|
302
|
-
*
|
|
572
|
+
* Batch send notification to multiple recipients.
|
|
573
|
+
*
|
|
574
|
+
* @param notifiables - List of recipients.
|
|
575
|
+
* @param notification - The notification instance.
|
|
576
|
+
* @param options - Options for sending.
|
|
577
|
+
* @returns A promise that resolves to the batch result.
|
|
578
|
+
*/
|
|
579
|
+
async sendBatch(notifiables, notification, options = {}) {
|
|
580
|
+
const { batchConcurrency = 10 } = options;
|
|
581
|
+
const startTime = Date.now();
|
|
582
|
+
const results = [];
|
|
583
|
+
await this.core.hooks.emit("notification:batch:start", {
|
|
584
|
+
notification,
|
|
585
|
+
count: notifiables.length
|
|
586
|
+
});
|
|
587
|
+
for (let i = 0; i < notifiables.length; i += batchConcurrency) {
|
|
588
|
+
const batch = notifiables.slice(i, i + batchConcurrency);
|
|
589
|
+
const batchPromises = batch.map((notifiable) => this.send(notifiable, notification, options));
|
|
590
|
+
const batchResults = await Promise.all(batchPromises);
|
|
591
|
+
results.push(...batchResults);
|
|
592
|
+
}
|
|
593
|
+
const duration = Date.now() - startTime;
|
|
594
|
+
const successCount = results.filter((r) => r.allSuccess).length;
|
|
595
|
+
await this.core.hooks.emit("notification:batch:complete", {
|
|
596
|
+
notification,
|
|
597
|
+
total: notifiables.length,
|
|
598
|
+
success: successCount,
|
|
599
|
+
failed: notifiables.length - successCount,
|
|
600
|
+
duration
|
|
601
|
+
});
|
|
602
|
+
return {
|
|
603
|
+
total: notifiables.length,
|
|
604
|
+
success: successCount,
|
|
605
|
+
failed: notifiables.length - successCount,
|
|
606
|
+
results,
|
|
607
|
+
duration
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Batch send notification to multiple recipients (streaming).
|
|
303
612
|
*
|
|
304
|
-
* @param
|
|
305
|
-
* @param notification - The notification.
|
|
306
|
-
* @param
|
|
613
|
+
* @param notifiables - AsyncIterable or Iterable of recipients.
|
|
614
|
+
* @param notification - The notification instance.
|
|
615
|
+
* @param options - Options for sending.
|
|
616
|
+
* @yields Notification results as they are processed.
|
|
307
617
|
*/
|
|
308
|
-
async
|
|
618
|
+
async *sendBatchStream(notifiables, notification, options = {}) {
|
|
619
|
+
const { batchSize = 10 } = options;
|
|
620
|
+
let batch = [];
|
|
621
|
+
for await (const notifiable of notifiables) {
|
|
622
|
+
batch.push(notifiable);
|
|
623
|
+
if (batch.length >= batchSize) {
|
|
624
|
+
const promises = batch.map((n) => this.send(n, notification, options));
|
|
625
|
+
const results = await Promise.all(promises);
|
|
626
|
+
for (const result of results) {
|
|
627
|
+
yield result;
|
|
628
|
+
}
|
|
629
|
+
batch = [];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (batch.length > 0) {
|
|
633
|
+
const promises = batch.map((n) => this.send(n, notification, options));
|
|
634
|
+
const results = await Promise.all(promises);
|
|
635
|
+
for (const result of results) {
|
|
636
|
+
yield result;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Send immediately (without queue).
|
|
642
|
+
*/
|
|
643
|
+
async sendNow(notifiable, notification, channels, options = {}) {
|
|
644
|
+
const { parallel = true, concurrency } = options;
|
|
645
|
+
if (!parallel) {
|
|
646
|
+
return this.sendSequential(notifiable, notification, channels, options);
|
|
647
|
+
}
|
|
648
|
+
if (concurrency && concurrency > 0) {
|
|
649
|
+
return this.sendWithConcurrencyLimit(notifiable, notification, channels, concurrency, options);
|
|
650
|
+
}
|
|
651
|
+
return this.sendParallel(notifiable, notification, channels, options);
|
|
652
|
+
}
|
|
653
|
+
async sendSequential(notifiable, notification, channels, options) {
|
|
654
|
+
const results = [];
|
|
309
655
|
for (const channelName of channels) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
656
|
+
results.push(await this.sendToChannel(notifiable, notification, channelName, options?.retry));
|
|
657
|
+
}
|
|
658
|
+
return results;
|
|
659
|
+
}
|
|
660
|
+
async sendParallel(notifiable, notification, channels, options) {
|
|
661
|
+
const promises = channels.map(
|
|
662
|
+
(channelName) => this.sendToChannel(notifiable, notification, channelName, options?.retry)
|
|
663
|
+
);
|
|
664
|
+
return Promise.all(promises);
|
|
665
|
+
}
|
|
666
|
+
async sendWithConcurrencyLimit(notifiable, notification, channels, concurrency, options) {
|
|
667
|
+
return this.processWithConcurrency(
|
|
668
|
+
channels,
|
|
669
|
+
(channel) => this.sendToChannel(notifiable, notification, channel, options?.retry),
|
|
670
|
+
concurrency
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
async processWithConcurrency(items, handler, concurrency) {
|
|
674
|
+
const finalResults = [];
|
|
675
|
+
const pool = /* @__PURE__ */ new Set();
|
|
676
|
+
for (const item of items) {
|
|
677
|
+
const p = handler(item).then((res) => {
|
|
678
|
+
finalResults.push(res);
|
|
679
|
+
});
|
|
680
|
+
pool.add(p);
|
|
681
|
+
p.finally(() => pool.delete(p));
|
|
682
|
+
if (pool.size >= concurrency) {
|
|
683
|
+
await Promise.race(pool);
|
|
314
684
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
685
|
+
}
|
|
686
|
+
await Promise.all(pool);
|
|
687
|
+
return finalResults;
|
|
688
|
+
}
|
|
689
|
+
async sendToChannel(notifiable, notification, channelName, retryConfig) {
|
|
690
|
+
const channel = this.channels.get(channelName);
|
|
691
|
+
const startTime = Date.now();
|
|
692
|
+
if (!channel) {
|
|
693
|
+
this.core.logger.warn(`[NotificationManager] Channel '${channelName}' not found, skipping`);
|
|
694
|
+
return {
|
|
695
|
+
success: false,
|
|
696
|
+
channel: channelName,
|
|
697
|
+
error: new Error(`Channel '${channelName}' not registered`)
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
const retry = this.getRetryConfig(notification, retryConfig);
|
|
701
|
+
try {
|
|
702
|
+
if (retry) {
|
|
703
|
+
await withRetry(
|
|
704
|
+
() => this.executeChannelSend(channel, notification, notifiable, channelName),
|
|
705
|
+
{
|
|
706
|
+
...retry,
|
|
707
|
+
shouldRetry: retry.retryableErrors || isRetryableError,
|
|
708
|
+
onRetry: (error, attempt, delay) => {
|
|
709
|
+
this.core.logger.warn(
|
|
710
|
+
`[NotificationManager] Channel '${channelName}' failed, retrying (${attempt}/${retry.maxAttempts}) in ${delay}ms`,
|
|
711
|
+
error
|
|
712
|
+
);
|
|
713
|
+
this.core.hooks.emit("notification:channel:retry", {
|
|
714
|
+
notification,
|
|
715
|
+
notifiable,
|
|
716
|
+
channel: channelName,
|
|
717
|
+
error,
|
|
718
|
+
attempt,
|
|
719
|
+
nextDelay: delay
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
321
723
|
);
|
|
724
|
+
} else {
|
|
725
|
+
await this.executeChannelSend(channel, notification, notifiable, channelName);
|
|
726
|
+
}
|
|
727
|
+
const duration = Date.now() - startTime;
|
|
728
|
+
if (this.metrics) {
|
|
729
|
+
this.metrics.record({
|
|
730
|
+
notification: notification.constructor.name,
|
|
731
|
+
channel: channelName,
|
|
732
|
+
success: true,
|
|
733
|
+
duration,
|
|
734
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
return {
|
|
738
|
+
success: true,
|
|
739
|
+
channel: channelName,
|
|
740
|
+
duration
|
|
741
|
+
};
|
|
742
|
+
} catch (error) {
|
|
743
|
+
const duration = Date.now() - startTime;
|
|
744
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
745
|
+
await this.core.hooks.emit("notification:channel:failed", {
|
|
746
|
+
notification,
|
|
747
|
+
notifiable,
|
|
748
|
+
channel: channelName,
|
|
749
|
+
error: err,
|
|
750
|
+
duration
|
|
751
|
+
});
|
|
752
|
+
this.core.logger.error(
|
|
753
|
+
`[NotificationManager] Failed to send notification via '${channelName}':`,
|
|
754
|
+
error
|
|
755
|
+
);
|
|
756
|
+
if (this.metrics) {
|
|
757
|
+
this.metrics.record({
|
|
758
|
+
notification: notification.constructor.name,
|
|
759
|
+
channel: channelName,
|
|
760
|
+
success: false,
|
|
761
|
+
duration,
|
|
762
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
763
|
+
error: err.message
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
success: false,
|
|
768
|
+
channel: channelName,
|
|
769
|
+
error: err,
|
|
770
|
+
duration: Date.now() - startTime
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
async executeChannelSend(channel, notification, notifiable, channelName) {
|
|
775
|
+
await this.core.hooks.emit("notification:channel:sending", {
|
|
776
|
+
notification,
|
|
777
|
+
notifiable,
|
|
778
|
+
channel: channelName
|
|
779
|
+
});
|
|
780
|
+
await channel.send(notification, notifiable);
|
|
781
|
+
const duration = 0;
|
|
782
|
+
await this.core.hooks.emit("notification:channel:sent", {
|
|
783
|
+
notification,
|
|
784
|
+
notifiable,
|
|
785
|
+
channel: channelName,
|
|
786
|
+
duration
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
getRetryConfig(notification, options) {
|
|
790
|
+
const notificationRetry = notification.retry;
|
|
791
|
+
if (options === false || notificationRetry === void 0) {
|
|
792
|
+
if (typeof options === "object") {
|
|
793
|
+
return {
|
|
794
|
+
maxAttempts: 3,
|
|
795
|
+
baseDelay: 1e3,
|
|
796
|
+
backoff: "exponential",
|
|
797
|
+
maxDelay: 3e4,
|
|
798
|
+
...options
|
|
799
|
+
};
|
|
322
800
|
}
|
|
801
|
+
return void 0;
|
|
802
|
+
}
|
|
803
|
+
if (options || notificationRetry) {
|
|
804
|
+
const retryOptions = typeof options === "object" ? options : {};
|
|
805
|
+
return {
|
|
806
|
+
maxAttempts: 3,
|
|
807
|
+
baseDelay: 1e3,
|
|
808
|
+
backoff: "exponential",
|
|
809
|
+
maxDelay: 3e4,
|
|
810
|
+
...notificationRetry,
|
|
811
|
+
...retryOptions
|
|
812
|
+
};
|
|
323
813
|
}
|
|
814
|
+
return void 0;
|
|
324
815
|
}
|
|
325
816
|
/**
|
|
326
817
|
* Serialize notification (for queuing).
|
|
@@ -329,13 +820,7 @@ var NotificationManager = class {
|
|
|
329
820
|
* @returns A plain object representation of the notification.
|
|
330
821
|
*/
|
|
331
822
|
serializeNotification(notification) {
|
|
332
|
-
|
|
333
|
-
for (const [key, value] of Object.entries(notification)) {
|
|
334
|
-
if (!key.startsWith("_") && typeof value !== "function") {
|
|
335
|
-
data[key] = value;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
return data;
|
|
823
|
+
return deepSerialize(notification);
|
|
339
824
|
}
|
|
340
825
|
};
|
|
341
826
|
|
|
@@ -343,6 +828,7 @@ var NotificationManager = class {
|
|
|
343
828
|
var OrbitFlare = class _OrbitFlare {
|
|
344
829
|
options;
|
|
345
830
|
constructor(options = {}) {
|
|
831
|
+
this.validateOptions(options);
|
|
346
832
|
this.options = {
|
|
347
833
|
enableMail: true,
|
|
348
834
|
enableDatabase: true,
|
|
@@ -361,6 +847,41 @@ var OrbitFlare = class _OrbitFlare {
|
|
|
361
847
|
static configure(options = {}) {
|
|
362
848
|
return new _OrbitFlare(options);
|
|
363
849
|
}
|
|
850
|
+
validateOptions(options) {
|
|
851
|
+
if (options.enableSlack) {
|
|
852
|
+
const slack = options.channels?.slack;
|
|
853
|
+
if (!slack?.webhookUrl) {
|
|
854
|
+
throw new Error(
|
|
855
|
+
"[OrbitFlare] Slack channel enabled but webhookUrl not provided. Configure channels.slack.webhookUrl or set enableSlack to false."
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
if (!this.isValidUrl(slack.webhookUrl)) {
|
|
859
|
+
throw new Error(`[OrbitFlare] Invalid Slack webhook URL: ${slack.webhookUrl}`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (options.enableSms) {
|
|
863
|
+
const sms = options.channels?.sms;
|
|
864
|
+
if (!sms?.provider) {
|
|
865
|
+
throw new Error(
|
|
866
|
+
"[OrbitFlare] SMS channel enabled but provider not specified. Configure channels.sms.provider or set enableSms to false."
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
const supportedProviders = ["twilio", "aws-sns"];
|
|
870
|
+
if (!supportedProviders.includes(sms.provider)) {
|
|
871
|
+
throw new Error(
|
|
872
|
+
`[OrbitFlare] Unsupported SMS provider: ${sms.provider}. Supported providers: ${supportedProviders.join(", ")}`
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
isValidUrl(url) {
|
|
878
|
+
try {
|
|
879
|
+
new URL(url);
|
|
880
|
+
return true;
|
|
881
|
+
} catch {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
364
885
|
/**
|
|
365
886
|
* Install OrbitFlare into PlanetCore.
|
|
366
887
|
*
|
|
@@ -369,55 +890,133 @@ var OrbitFlare = class _OrbitFlare {
|
|
|
369
890
|
async install(core) {
|
|
370
891
|
const manager = new NotificationManager(core);
|
|
371
892
|
if (this.options.enableMail) {
|
|
372
|
-
|
|
373
|
-
if (mail) {
|
|
374
|
-
manager.channel("mail", new MailChannel(mail));
|
|
375
|
-
} else {
|
|
376
|
-
core.logger.warn("[OrbitFlare] Mail service not found, mail channel disabled");
|
|
377
|
-
}
|
|
893
|
+
this.setupMailChannel(core, manager);
|
|
378
894
|
}
|
|
379
895
|
if (this.options.enableDatabase) {
|
|
380
|
-
|
|
381
|
-
if (db) {
|
|
382
|
-
manager.channel("database", new DatabaseChannel(db));
|
|
383
|
-
} else {
|
|
384
|
-
core.logger.warn("[OrbitFlare] Database service not found, database channel disabled");
|
|
385
|
-
}
|
|
896
|
+
this.setupDatabaseChannel(core, manager);
|
|
386
897
|
}
|
|
387
898
|
if (this.options.enableBroadcast) {
|
|
388
|
-
|
|
389
|
-
if (broadcast) {
|
|
390
|
-
manager.channel("broadcast", new BroadcastChannel(broadcast));
|
|
391
|
-
} else {
|
|
392
|
-
core.logger.warn("[OrbitFlare] Broadcast service not found, broadcast channel disabled");
|
|
393
|
-
}
|
|
899
|
+
this.setupBroadcastChannel(core, manager);
|
|
394
900
|
}
|
|
395
901
|
if (this.options.enableSlack) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
902
|
+
this.setupSlackChannel(core, manager);
|
|
903
|
+
}
|
|
904
|
+
if (this.options.enableSms) {
|
|
905
|
+
this.setupSmsChannel(core, manager);
|
|
906
|
+
}
|
|
907
|
+
core.container.instance("notifications", manager);
|
|
908
|
+
this.setupQueueIntegration(core, manager);
|
|
909
|
+
core.logger.info("[OrbitFlare] Installed");
|
|
910
|
+
}
|
|
911
|
+
setupMailChannel(core, manager) {
|
|
912
|
+
const mail = core.container.make("mail");
|
|
913
|
+
if (mail && this.isMailService(mail)) {
|
|
914
|
+
manager.channel("mail", new MailChannel(mail));
|
|
915
|
+
} else {
|
|
916
|
+
core.logger.warn("[OrbitFlare] Mail service not found or invalid, mail channel disabled");
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
setupDatabaseChannel(core, manager) {
|
|
920
|
+
const db = core.container.make("db");
|
|
921
|
+
if (db && this.isDatabaseService(db)) {
|
|
922
|
+
manager.channel("database", new DatabaseChannel(db));
|
|
923
|
+
} else {
|
|
924
|
+
core.logger.warn(
|
|
925
|
+
"[OrbitFlare] Database service not found or invalid, database channel disabled"
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
setupBroadcastChannel(core, manager) {
|
|
930
|
+
const broadcast = core.container.make("broadcast");
|
|
931
|
+
if (broadcast && this.isBroadcastService(broadcast)) {
|
|
932
|
+
manager.channel("broadcast", new BroadcastChannel(broadcast));
|
|
933
|
+
} else {
|
|
934
|
+
core.logger.warn(
|
|
935
|
+
"[OrbitFlare] Broadcast service not found or invalid, broadcast channel disabled"
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
setupSlackChannel(core, manager) {
|
|
940
|
+
const slack = this.options.channels?.slack;
|
|
941
|
+
if (slack) {
|
|
942
|
+
manager.channel("slack", new SlackChannel(slack));
|
|
402
943
|
}
|
|
403
944
|
if (this.options.enableSms) {
|
|
404
945
|
const sms = this.options.channels?.sms;
|
|
405
946
|
if (sms) {
|
|
406
947
|
manager.channel("sms", new SmsChannel(sms));
|
|
407
|
-
} else {
|
|
408
|
-
core.logger.warn("[OrbitFlare] SMS configuration not found, sms channel disabled");
|
|
409
948
|
}
|
|
410
949
|
}
|
|
411
|
-
|
|
950
|
+
}
|
|
951
|
+
setupSmsChannel(core, manager) {
|
|
952
|
+
const sms = this.options.channels?.sms;
|
|
953
|
+
if (sms) {
|
|
954
|
+
manager.channel("sms", new SmsChannel(sms));
|
|
955
|
+
} else {
|
|
956
|
+
core.logger.warn("[OrbitFlare] SMS configuration not found, sms channel disabled");
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
setupQueueIntegration(core, manager) {
|
|
412
960
|
const queue = core.container.make("queue");
|
|
413
|
-
if (queue) {
|
|
961
|
+
if (queue && this.isQueueService(queue)) {
|
|
414
962
|
manager.setQueueManager({
|
|
415
963
|
push: async (job, queueName, connection, delay) => {
|
|
416
964
|
await queue.push(job, queueName, connection, delay);
|
|
417
965
|
}
|
|
418
966
|
});
|
|
419
967
|
}
|
|
420
|
-
|
|
968
|
+
}
|
|
969
|
+
isMailService(service) {
|
|
970
|
+
return typeof service === "object" && service !== null && "send" in service && typeof service.send === "function";
|
|
971
|
+
}
|
|
972
|
+
isDatabaseService(service) {
|
|
973
|
+
return typeof service === "object" && service !== null && "insertNotification" in service && typeof service.insertNotification === "function";
|
|
974
|
+
}
|
|
975
|
+
isBroadcastService(service) {
|
|
976
|
+
return typeof service === "object" && service !== null && "broadcast" in service && typeof service.broadcast === "function";
|
|
977
|
+
}
|
|
978
|
+
isQueueService(service) {
|
|
979
|
+
return typeof service === "object" && service !== null && "push" in service && typeof service.push === "function";
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
// src/templates/NotificationTemplate.ts
|
|
984
|
+
var TemplatedNotification = class extends Notification {
|
|
985
|
+
data = {};
|
|
986
|
+
with(data) {
|
|
987
|
+
this.data = { ...this.data, ...data };
|
|
988
|
+
return this;
|
|
989
|
+
}
|
|
990
|
+
// Auto-implement toMail
|
|
991
|
+
toMail(notifiable) {
|
|
992
|
+
const template = this.mailTemplate();
|
|
993
|
+
return {
|
|
994
|
+
subject: this.interpolate(template.subject),
|
|
995
|
+
view: template.view,
|
|
996
|
+
data: { ...template.data, ...this.data },
|
|
997
|
+
to: this.getRecipientEmail(notifiable)
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
// Auto-implement toSlack
|
|
1001
|
+
toSlack(_notifiable) {
|
|
1002
|
+
if (!this.slackTemplate) {
|
|
1003
|
+
throw new Error("slackTemplate not defined");
|
|
1004
|
+
}
|
|
1005
|
+
const template = this.slackTemplate();
|
|
1006
|
+
return {
|
|
1007
|
+
text: this.interpolate(template.text),
|
|
1008
|
+
channel: template.channel,
|
|
1009
|
+
attachments: template.attachments
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
interpolate(text) {
|
|
1013
|
+
return text.replace(/\{\{(\w+)\}\}/g, (_, key) => String(this.data[key] ?? `{{${key}}}`));
|
|
1014
|
+
}
|
|
1015
|
+
getRecipientEmail(notifiable) {
|
|
1016
|
+
if ("email" in notifiable && typeof notifiable.email === "string") {
|
|
1017
|
+
return notifiable.email;
|
|
1018
|
+
}
|
|
1019
|
+
throw new Error("Notifiable does not have an email property");
|
|
421
1020
|
}
|
|
422
1021
|
};
|
|
423
1022
|
// Annotate the CommonJS export names for ESM import in node:
|
|
@@ -427,7 +1026,10 @@ var OrbitFlare = class _OrbitFlare {
|
|
|
427
1026
|
MailChannel,
|
|
428
1027
|
Notification,
|
|
429
1028
|
NotificationManager,
|
|
1029
|
+
NotificationMetricsCollector,
|
|
430
1030
|
OrbitFlare,
|
|
431
1031
|
SlackChannel,
|
|
432
|
-
SmsChannel
|
|
1032
|
+
SmsChannel,
|
|
1033
|
+
TemplatedNotification,
|
|
1034
|
+
toPrometheusFormat
|
|
433
1035
|
});
|