@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/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(_message) {
171
- throw new Error("AWS SNS SMS provider not yet implemented. Please install @aws-sdk/client-sns");
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/Notification.ts
176
- var Notification = class {
177
- /**
178
- * Get mail message (optional).
179
- * Implement this if the notification will be sent via the mail channel.
180
- */
181
- toMail(_notifiable) {
182
- throw new Error("toMail method not implemented");
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
- * Get database notification (optional).
186
- * Implement this if the notification will be stored via the database channel.
187
- */
188
- toDatabase(_notifiable) {
189
- throw new Error("toDatabase method not implemented");
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
- * Get broadcast notification (optional).
193
- * Implement this if the notification will be sent via the broadcast channel.
194
- */
195
- toBroadcast(_notifiable) {
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
- * Get Slack message (optional).
200
- * Implement this if the notification will be sent via the Slack channel.
201
- */
202
- toSlack(_notifiable) {
203
- throw new Error("toSlack method not implemented");
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
- * Get SMS message (optional).
207
- * Implement this if the notification will be sent via the SMS channel.
208
- */
209
- toSms(_notifiable) {
210
- throw new Error("toSms method not implemented");
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
- * @returns A promise that resolves when the notification is sent or queued.
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
- await this.sendNow(notifiable, notification, channels);
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
- * Send immediately (without queue).
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 notifiable - The recipient.
305
- * @param notification - The notification.
306
- * @param channels - The list of channels to send via.
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 sendNow(notifiable, notification, channels) {
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
- const channel = this.channels.get(channelName);
311
- if (!channel) {
312
- this.core.logger.warn(`[NotificationManager] Channel '${channelName}' not found, skipping`);
313
- continue;
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
- try {
316
- await channel.send(notification, notifiable);
317
- } catch (error) {
318
- this.core.logger.error(
319
- `[NotificationManager] Failed to send notification via '${channelName}':`,
320
- error
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
- const data = {};
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
- const mail = core.container.make("mail");
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
- const db = core.container.make("db");
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
- const broadcast = core.container.make("broadcast");
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
- const slack = this.options.channels?.slack;
397
- if (slack) {
398
- manager.channel("slack", new SlackChannel(slack));
399
- } else {
400
- core.logger.warn("[OrbitFlare] Slack configuration not found, slack channel disabled");
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
- core.container.instance("notifications", manager);
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
- core.logger.info("[OrbitFlare] Installed");
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
  });