@gravito/flare 4.0.1 → 5.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 CHANGED
@@ -4,18 +4,32 @@
4
4
 
5
5
  **Status**: v3.4.0 - Production ready with advanced features (Retries, Metrics, Batching, Timeout, Rate Limiting, Preference Driver).
6
6
 
7
- ## Features
8
-
9
- - **Zero runtime overhead**: Pure type wrappers that delegate to channel drivers
10
- - **Multi-channel delivery**: Mail, database, broadcast, Slack, SMS (Twilio & AWS SNS)
11
- - **High Performance**: Parallel channel execution and batch sending capabilities
12
- - **Reliability**: Built-in retry mechanism with exponential backoff and timeout protection
13
- - **Observability**: Comprehensive metrics with Prometheus support
14
- - **Developer Experience**: Strong typing, lifecycle hooks, and template system
15
- - **Queue support**: Works with `@gravito/stream` for async delivery with Lazy Loading
16
- - **Rate Limiting**: Channel-level rate limiting with Token Bucket algorithm
17
- - **Preference Driver**: User notification preferences with automatic channel filtering
18
- - **Middleware System**: Extensible middleware chain for custom notification processing
7
+ ## ✨ Key Features
8
+
9
+ - 🪐 **Galaxy-Ready Notifications**: Native integration with PlanetCore for universal user notification across all Satellites.
10
+ - 📡 **Multi-Channel Delivery**: Seamlessly broadcast via **Mail**, **SMS**, **Slack**, **Discord**, and **Push Notifications**.
11
+ - 🛠️ **Distributed Preference Management**: User-level notification settings that persist across the entire Galaxy.
12
+ - 🛡️ **Reliability Stack**: Built-in exponential backoff, timeout protection, and automatic retries.
13
+ - 🚀 **High Performance**: Parallel channel execution and batch sending optimized for Bun.
14
+ - ⚙️ **Queue Integration**: Offload notification delivery to `@gravito/stream` with zero configuration.
15
+
16
+ ## 🌌 Role in Galaxy Architecture
17
+
18
+ In the **Gravito Galaxy Architecture**, Flare acts as the **Communication Flux (Nervous System Extension)**.
19
+
20
+ - **Outbound Feedback**: Provides the primary mechanism for Satellites to communicate directly with users (e.g., "Your order has shipped" from the `Shop` Satellite).
21
+ - **Preference Guard**: Ensures that user privacy and communication preferences are respected globally, regardless of which Satellite initiates the notification.
22
+ - **Micro-Infrastructure Bridge**: Connects internal domain events to external communication providers (Twilio, AWS, SendGrid) without bloating Satellite logic.
23
+
24
+ ```mermaid
25
+ graph LR
26
+ S[Satellite: Order] -- "Notify" --> Flare{Flare Orbit}
27
+ Flare -->|Check| Pref[User Preferences]
28
+ Flare -->|Route| C1[Mail: SES]
29
+ Flare -->|Route| C2[SMS: Twilio]
30
+ Flare -->|Route| C3[Social: Slack]
31
+ Flare -.->|Queue| Stream[Stream Orbit]
32
+ ```
19
33
 
20
34
  ## Installation
21
35
 
@@ -276,6 +290,14 @@ OrbitFlare.configure({
276
290
  })
277
291
  ```
278
292
 
293
+ ## 📚 Documentation
294
+
295
+ Detailed guides and references for the Galaxy Architecture:
296
+
297
+ - [🏗️ **Architecture Overview**](./README.md) — Multi-channel notification core.
298
+ - [📡 **Notification Strategies**](./doc/NOTIFICATION_STRATEGIES.md) — **NEW**: Multi-channel routing, preferences, and queuing.
299
+ - [⚙️ **Queue Support**](#-queue-support) — Asynchronous delivery with `@gravito/stream`.
300
+
279
301
  ## API Reference
280
302
 
281
303
  ### NotificationManager
package/dist/index.cjs CHANGED
@@ -200,6 +200,8 @@ __export(index_exports, {
200
200
  AbortError: () => AbortError,
201
201
  BroadcastChannel: () => BroadcastChannel,
202
202
  DatabaseChannel: () => DatabaseChannel,
203
+ FlareError: () => FlareError,
204
+ FlareErrorCodes: () => FlareErrorCodes,
203
205
  LazyNotification: () => LazyNotification,
204
206
  MailChannel: () => MailChannel,
205
207
  MemoryStore: () => MemoryStore,
@@ -225,6 +227,37 @@ __export(index_exports, {
225
227
  });
226
228
  module.exports = __toCommonJS(index_exports);
227
229
 
230
+ // src/errors/FlareError.ts
231
+ var import_core = require("@gravito/core");
232
+ var FlareError = class extends import_core.InfrastructureException {
233
+ /**
234
+ * Creates a new FlareError instance.
235
+ *
236
+ * @param code - The error code.
237
+ * @param message - The error message.
238
+ * @param options - Optional infrastructure exception options.
239
+ */
240
+ constructor(code, message, options = {}) {
241
+ const status = options.retryable ? 503 : 500;
242
+ super(status, code, { message, ...options });
243
+ this.name = "FlareError";
244
+ Object.setPrototypeOf(this, new.target.prototype);
245
+ }
246
+ };
247
+
248
+ // src/errors/codes.ts
249
+ var FlareErrorCodes = {
250
+ RATE_LIMIT_EXCEEDED: "flare.rate_limit_exceeded",
251
+ SERIALIZATION_FAILED: "flare.serialization_failed",
252
+ TEMPLATE_NOT_DEFINED: "flare.template_not_defined",
253
+ NOTIFIABLE_MISSING_EMAIL: "flare.notifiable_missing_email",
254
+ INVALID_CONFIG: "flare.invalid_config",
255
+ NOTIFICATION_METHOD_NOT_IMPLEMENTED: "flare.notification_method_not_implemented",
256
+ UNSUPPORTED_PROVIDER: "flare.unsupported_provider",
257
+ CREDENTIALS_MISSING: "flare.credentials_missing",
258
+ SEND_FAILED: "flare.send_failed"
259
+ };
260
+
228
261
  // src/channels/TimeoutChannel.ts
229
262
  var TimeoutError = class extends Error {
230
263
  constructor(message) {
@@ -262,16 +295,23 @@ var TimeoutChannel = class {
262
295
  }
263
296
  const controller = new AbortController();
264
297
  const { signal } = controller;
298
+ let timeoutId;
299
+ let settled = false;
300
+ let handleExternalAbort;
265
301
  if (options?.signal) {
266
302
  if (options.signal.aborted) {
267
303
  throw new AbortError("Request was aborted before sending");
268
304
  }
269
- options.signal.addEventListener("abort", () => {
305
+ handleExternalAbort = () => {
270
306
  controller.abort();
271
- });
307
+ };
308
+ options.signal.addEventListener("abort", handleExternalAbort, { once: true });
272
309
  }
273
310
  const timeoutPromise = new Promise((_, reject) => {
274
- setTimeout(() => {
311
+ timeoutId = setTimeout(() => {
312
+ if (settled) {
313
+ return;
314
+ }
275
315
  if (this.config.onTimeout) {
276
316
  this.config.onTimeout(this.inner.constructor.name, notification);
277
317
  }
@@ -292,7 +332,17 @@ var TimeoutChannel = class {
292
332
  }
293
333
  throw error;
294
334
  });
295
- return Promise.race([sendPromise, timeoutPromise]);
335
+ try {
336
+ return await Promise.race([sendPromise, timeoutPromise]);
337
+ } finally {
338
+ settled = true;
339
+ if (timeoutId) {
340
+ clearTimeout(timeoutId);
341
+ }
342
+ if (options?.signal && handleExternalAbort) {
343
+ options.signal.removeEventListener("abort", handleExternalAbort);
344
+ }
345
+ }
296
346
  }
297
347
  };
298
348
 
@@ -305,7 +355,10 @@ var BroadcastChannel = class {
305
355
  const innerChannel = {
306
356
  send: async (notification, notifiable, _options) => {
307
357
  if (!notification.toBroadcast) {
308
- throw new Error("Notification does not implement toBroadcast method");
358
+ throw new FlareError(
359
+ FlareErrorCodes.NOTIFICATION_METHOD_NOT_IMPLEMENTED,
360
+ "Notification does not implement toBroadcast method"
361
+ );
309
362
  }
310
363
  const broadcastNotification = notification.toBroadcast(notifiable);
311
364
  const notifiableId = notifiable.getNotifiableId();
@@ -339,7 +392,10 @@ var DatabaseChannel = class {
339
392
  const innerChannel = {
340
393
  send: async (notification, notifiable, _options) => {
341
394
  if (!notification.toDatabase) {
342
- throw new Error("Notification does not implement toDatabase method");
395
+ throw new FlareError(
396
+ FlareErrorCodes.NOTIFICATION_METHOD_NOT_IMPLEMENTED,
397
+ "Notification does not implement toDatabase method"
398
+ );
343
399
  }
344
400
  const dbNotification = notification.toDatabase(notifiable);
345
401
  await this.dbService.insertNotification({
@@ -371,7 +427,10 @@ var MailChannel = class {
371
427
  const innerChannel = {
372
428
  send: async (notification, notifiable, _options) => {
373
429
  if (!notification.toMail) {
374
- throw new Error("Notification does not implement toMail method");
430
+ throw new FlareError(
431
+ FlareErrorCodes.NOTIFICATION_METHOD_NOT_IMPLEMENTED,
432
+ "Notification does not implement toMail method"
433
+ );
375
434
  }
376
435
  const message = notification.toMail(notifiable);
377
436
  await this.mailService.send(message);
@@ -397,7 +456,10 @@ var SlackChannel = class {
397
456
  const innerChannel = {
398
457
  send: async (notification, notifiable, options) => {
399
458
  if (!notification.toSlack) {
400
- throw new Error("Notification does not implement toSlack method");
459
+ throw new FlareError(
460
+ FlareErrorCodes.NOTIFICATION_METHOD_NOT_IMPLEMENTED,
461
+ "Notification does not implement toSlack method"
462
+ );
401
463
  }
402
464
  const slackMessage = notification.toSlack(notifiable);
403
465
  const response = await fetch(this.config.webhookUrl, {
@@ -416,7 +478,10 @@ var SlackChannel = class {
416
478
  // Pass AbortSignal to fetch
417
479
  });
418
480
  if (!response.ok) {
419
- throw new Error(`Failed to send Slack notification: ${response.statusText}`);
481
+ throw new FlareError(
482
+ FlareErrorCodes.SEND_FAILED,
483
+ `Failed to send Slack notification: ${response.statusText}`
484
+ );
420
485
  }
421
486
  }
422
487
  };
@@ -440,7 +505,10 @@ var SmsChannel = class {
440
505
  const innerChannel = {
441
506
  send: async (notification, notifiable, options) => {
442
507
  if (!notification.toSms) {
443
- throw new Error("Notification does not implement toSms method");
508
+ throw new FlareError(
509
+ FlareErrorCodes.NOTIFICATION_METHOD_NOT_IMPLEMENTED,
510
+ "Notification does not implement toSms method"
511
+ );
444
512
  }
445
513
  const smsMessage = notification.toSms(notifiable);
446
514
  switch (this.config.provider) {
@@ -451,7 +519,10 @@ var SmsChannel = class {
451
519
  await this.sendViaAwsSns(smsMessage, options?.signal);
452
520
  break;
453
521
  default:
454
- throw new Error(`Unsupported SMS provider: ${this.config.provider}`);
522
+ throw new FlareError(
523
+ FlareErrorCodes.UNSUPPORTED_PROVIDER,
524
+ `Unsupported SMS provider: ${this.config.provider}`
525
+ );
455
526
  }
456
527
  }
457
528
  };
@@ -470,7 +541,10 @@ var SmsChannel = class {
470
541
  */
471
542
  async sendViaTwilio(message, signal) {
472
543
  if (!this.config.apiKey || !this.config.apiSecret) {
473
- throw new Error("Twilio API key and secret are required");
544
+ throw new FlareError(
545
+ FlareErrorCodes.CREDENTIALS_MISSING,
546
+ "Twilio API key and secret are required"
547
+ );
474
548
  }
475
549
  const accountSid = this.config.apiKey;
476
550
  const authToken = this.config.apiSecret;
@@ -493,7 +567,7 @@ var SmsChannel = class {
493
567
  );
494
568
  if (!response.ok) {
495
569
  const error = await response.text();
496
- throw new Error(`Failed to send SMS via Twilio: ${error}`);
570
+ throw new FlareError(FlareErrorCodes.SEND_FAILED, `Failed to send SMS via Twilio: ${error}`);
497
571
  }
498
572
  }
499
573
  /**
@@ -507,7 +581,8 @@ var SmsChannel = class {
507
581
  SNSClient = awsSns.SNSClient;
508
582
  PublishCommand = awsSns.PublishCommand;
509
583
  } catch {
510
- throw new Error(
584
+ throw new FlareError(
585
+ FlareErrorCodes.UNSUPPORTED_PROVIDER,
511
586
  "AWS SNS SMS requires @aws-sdk/client-sns. Install it with: bun add @aws-sdk/client-sns"
512
587
  );
513
588
  }
@@ -538,7 +613,10 @@ var SmsChannel = class {
538
613
  await client.send(command, { abortSignal: signal });
539
614
  } catch (error) {
540
615
  const err = error instanceof Error ? error : new Error(String(error));
541
- throw new Error(`Failed to send SMS via AWS SNS: ${err.message}`);
616
+ throw new FlareError(
617
+ FlareErrorCodes.SEND_FAILED,
618
+ `Failed to send SMS via AWS SNS: ${err.message}`
619
+ );
542
620
  }
543
621
  }
544
622
  };
@@ -797,7 +875,7 @@ var RateLimitMiddleware = class {
797
875
  * Create a new RateLimitMiddleware instance.
798
876
  *
799
877
  * @param config - Rate limit configuration for each channel
800
- * @param store - Optional cache store for distributed rate limiting
878
+ * @param store - Optional cache store for distributed rate limiting (future use)
801
879
  *
802
880
  * @example
803
881
  * ```typescript
@@ -814,7 +892,7 @@ var RateLimitMiddleware = class {
814
892
  */
815
893
  constructor(config, store) {
816
894
  this.config = config;
817
- this.store = store ?? new MemoryStore();
895
+ this.store = store || new MemoryStore();
818
896
  this.initializeBuckets();
819
897
  }
820
898
  /**
@@ -833,7 +911,6 @@ var RateLimitMiddleware = class {
833
911
  buckets = /* @__PURE__ */ new Map();
834
912
  /**
835
913
  * Cache store for distributed rate limiting.
836
- * 分散式限流使用的快取儲存
837
914
  */
838
915
  store;
839
916
  /**
@@ -891,7 +968,8 @@ var RateLimitMiddleware = class {
891
968
  if (bucket) {
892
969
  const allowed = bucket.tryConsume();
893
970
  if (!allowed) {
894
- throw new Error(
971
+ throw new FlareError(
972
+ FlareErrorCodes.RATE_LIMIT_EXCEEDED,
895
973
  `Rate limit exceeded for channel '${channel}' (${window}ly limit). Please try again later.`
896
974
  );
897
975
  }
@@ -1194,7 +1272,8 @@ function checkSerializable(obj, path = "") {
1194
1272
  function assertSerializable(obj) {
1195
1273
  const result = checkSerializable(obj);
1196
1274
  if (!result.serializable) {
1197
- throw new Error(
1275
+ throw new FlareError(
1276
+ FlareErrorCodes.SERIALIZATION_FAILED,
1198
1277
  `\u7269\u4EF6\u5305\u542B\u4E0D\u53EF\u5E8F\u5217\u5316\u7684\u5C6C\u6027:
1199
1278
  \u554F\u984C\u8DEF\u5F91: ${result.problematicPaths.join(", ")}
1200
1279
  \u8A73\u7D30\u8CC7\u8A0A:
@@ -1713,24 +1792,30 @@ var OrbitFlare = class _OrbitFlare {
1713
1792
  if (options.enableSlack) {
1714
1793
  const slack = options.channels?.slack;
1715
1794
  if (!slack?.webhookUrl) {
1716
- throw new Error(
1795
+ throw new FlareError(
1796
+ FlareErrorCodes.INVALID_CONFIG,
1717
1797
  "[OrbitFlare] Slack channel enabled but webhookUrl not provided. Configure channels.slack.webhookUrl or set enableSlack to false."
1718
1798
  );
1719
1799
  }
1720
1800
  if (!this.isValidUrl(slack.webhookUrl)) {
1721
- throw new Error(`[OrbitFlare] Invalid Slack webhook URL: ${slack.webhookUrl}`);
1801
+ throw new FlareError(
1802
+ FlareErrorCodes.INVALID_CONFIG,
1803
+ `[OrbitFlare] Invalid Slack webhook URL: ${slack.webhookUrl}`
1804
+ );
1722
1805
  }
1723
1806
  }
1724
1807
  if (options.enableSms) {
1725
1808
  const sms = options.channels?.sms;
1726
1809
  if (!sms?.provider) {
1727
- throw new Error(
1810
+ throw new FlareError(
1811
+ FlareErrorCodes.INVALID_CONFIG,
1728
1812
  "[OrbitFlare] SMS channel enabled but provider not specified. Configure channels.sms.provider or set enableSms to false."
1729
1813
  );
1730
1814
  }
1731
1815
  const supportedProviders = ["twilio", "aws-sns"];
1732
1816
  if (!supportedProviders.includes(sms.provider)) {
1733
- throw new Error(
1817
+ throw new FlareError(
1818
+ FlareErrorCodes.UNSUPPORTED_PROVIDER,
1734
1819
  `[OrbitFlare] Unsupported SMS provider: ${sms.provider}. Supported providers: ${supportedProviders.join(", ")}`
1735
1820
  );
1736
1821
  }
@@ -1853,6 +1938,7 @@ var OrbitFlare = class _OrbitFlare {
1853
1938
  };
1854
1939
 
1855
1940
  // src/templates/NotificationTemplate.ts
1941
+ var import_core2 = require("@gravito/core");
1856
1942
  var TemplatedNotification = class extends Notification {
1857
1943
  data = {};
1858
1944
  with(data) {
@@ -1872,7 +1958,7 @@ var TemplatedNotification = class extends Notification {
1872
1958
  // Auto-implement toSlack
1873
1959
  toSlack(_notifiable) {
1874
1960
  if (!this.slackTemplate) {
1875
- throw new Error("slackTemplate not defined");
1961
+ throw new FlareError(FlareErrorCodes.TEMPLATE_NOT_DEFINED, "slackTemplate not defined");
1876
1962
  }
1877
1963
  const template = this.slackTemplate();
1878
1964
  return {
@@ -1881,6 +1967,33 @@ var TemplatedNotification = class extends Notification {
1881
1967
  attachments: template.attachments
1882
1968
  };
1883
1969
  }
1970
+ /**
1971
+ * 將 Markdown 模板渲染為 HTML。
1972
+ * 先執行變數插值(`{{var}}`),再將 Markdown 轉為 HTML。
1973
+ * 內建 XSS 防護:過濾 script、iframe、event handler 等危險 HTML。
1974
+ *
1975
+ * 適用於郵件、Slack 等富文本通知頻道。
1976
+ *
1977
+ * @param template - Markdown 格式的模板字串
1978
+ * @returns 安全的 HTML 字串
1979
+ *
1980
+ * @example
1981
+ * ```typescript
1982
+ * const html = this.renderMarkdown('# Hello {{name}}')
1983
+ * await sendEmail({ htmlBody: html })
1984
+ * ```
1985
+ */
1986
+ renderMarkdown(template) {
1987
+ if (!template) {
1988
+ return "";
1989
+ }
1990
+ const interpolated = this.interpolate(template);
1991
+ const md = (0, import_core2.getMarkdownAdapter)();
1992
+ const sanitizeCallbacks = (0, import_core2.createHtmlRenderCallbacks)({
1993
+ html: (rawHtml) => sanitizeHtml(rawHtml)
1994
+ });
1995
+ return md.render(interpolated, sanitizeCallbacks);
1996
+ }
1884
1997
  interpolate(text) {
1885
1998
  return text.replace(/\{\{(\w+)\}\}/g, (_, key) => String(this.data[key] ?? `{{${key}}}`));
1886
1999
  }
@@ -1888,9 +2001,23 @@ var TemplatedNotification = class extends Notification {
1888
2001
  if ("email" in notifiable && typeof notifiable.email === "string") {
1889
2002
  return notifiable.email;
1890
2003
  }
1891
- throw new Error("Notifiable does not have an email property");
2004
+ throw new FlareError(
2005
+ FlareErrorCodes.NOTIFIABLE_MISSING_EMAIL,
2006
+ "Notifiable does not have an email property"
2007
+ );
1892
2008
  }
1893
2009
  };
2010
+ var DANGEROUS_TAGS = /^<\/?(?:script|iframe|object|embed|form|input|textarea|button|select|style|link|meta|base)\b[^>]*>$/i;
2011
+ var EVENT_HANDLER_ATTRS = /\s+on\w+\s*=/i;
2012
+ function sanitizeHtml(rawHtml) {
2013
+ if (DANGEROUS_TAGS.test(rawHtml.trim())) {
2014
+ return "";
2015
+ }
2016
+ if (EVENT_HANDLER_ATTRS.test(rawHtml)) {
2017
+ return "";
2018
+ }
2019
+ return rawHtml;
2020
+ }
1894
2021
 
1895
2022
  // src/index.ts
1896
2023
  init_middleware();
@@ -1949,6 +2076,8 @@ var LazyNotification = class extends Notification {
1949
2076
  AbortError,
1950
2077
  BroadcastChannel,
1951
2078
  DatabaseChannel,
2079
+ FlareError,
2080
+ FlareErrorCodes,
1952
2081
  LazyNotification,
1953
2082
  MailChannel,
1954
2083
  MemoryStore,
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { PlanetCore, GravitoOrbit } from '@gravito/core';
1
+ import { InfrastructureException, InfrastructureExceptionOptions, PlanetCore, GravitoOrbit } from '@gravito/core';
2
2
 
3
3
  /**
4
4
  * Notification system type definitions.
@@ -404,6 +404,59 @@ declare class BroadcastChannel implements NotificationChannel {
404
404
  send(notification: Notification, notifiable: Notifiable, options?: AbortableSendOptions): Promise<void>;
405
405
  }
406
406
 
407
+ /**
408
+ * @fileoverview Flare error codes
409
+ *
410
+ * Namespaced error codes for the Flare notification module.
411
+ *
412
+ * @module @gravito/flare/errors
413
+ */
414
+ /**
415
+ * Error codes for Flare module operations.
416
+ * Follows dot-separated namespace convention.
417
+ */
418
+ declare const FlareErrorCodes: {
419
+ readonly RATE_LIMIT_EXCEEDED: "flare.rate_limit_exceeded";
420
+ readonly SERIALIZATION_FAILED: "flare.serialization_failed";
421
+ readonly TEMPLATE_NOT_DEFINED: "flare.template_not_defined";
422
+ readonly NOTIFIABLE_MISSING_EMAIL: "flare.notifiable_missing_email";
423
+ readonly INVALID_CONFIG: "flare.invalid_config";
424
+ readonly NOTIFICATION_METHOD_NOT_IMPLEMENTED: "flare.notification_method_not_implemented";
425
+ readonly UNSUPPORTED_PROVIDER: "flare.unsupported_provider";
426
+ readonly CREDENTIALS_MISSING: "flare.credentials_missing";
427
+ readonly SEND_FAILED: "flare.send_failed";
428
+ };
429
+ type FlareErrorCode = (typeof FlareErrorCodes)[keyof typeof FlareErrorCodes];
430
+
431
+ /**
432
+ * @fileoverview Flare error types
433
+ *
434
+ * @module @gravito/flare/errors
435
+ */
436
+
437
+ /**
438
+ * Base error class for Flare module.
439
+ *
440
+ * Provides structured error handling with error codes and messages.
441
+ * Extends InfrastructureException for unified error handling across Gravito.
442
+ *
443
+ * @example
444
+ * ```typescript
445
+ * throw new FlareError('flare.send_failed', 'Failed to send notification')
446
+ * ```
447
+ * @public
448
+ */
449
+ declare class FlareError extends InfrastructureException {
450
+ /**
451
+ * Creates a new FlareError instance.
452
+ *
453
+ * @param code - The error code.
454
+ * @param message - The error message.
455
+ * @param options - Optional infrastructure exception options.
456
+ */
457
+ constructor(code: FlareErrorCode, message: string, options?: InfrastructureExceptionOptions);
458
+ }
459
+
407
460
  /**
408
461
  * Database channel 配置選項。
409
462
  */
@@ -1039,14 +1092,13 @@ declare class RateLimitMiddleware implements ChannelMiddleware {
1039
1092
  private buckets;
1040
1093
  /**
1041
1094
  * Cache store for distributed rate limiting.
1042
- * 分散式限流使用的快取儲存
1043
1095
  */
1044
- private store;
1096
+ store: CacheStore;
1045
1097
  /**
1046
1098
  * Create a new RateLimitMiddleware instance.
1047
1099
  *
1048
1100
  * @param config - Rate limit configuration for each channel
1049
- * @param store - Optional cache store for distributed rate limiting
1101
+ * @param store - Optional cache store for distributed rate limiting (future use)
1050
1102
  *
1051
1103
  * @example
1052
1104
  * ```typescript
@@ -1379,7 +1431,24 @@ declare abstract class TemplatedNotification extends Notification {
1379
1431
  protected slackTemplate?(): SlackTemplate;
1380
1432
  toMail(notifiable: Notifiable): MailMessage;
1381
1433
  toSlack(_notifiable: Notifiable): SlackMessage;
1382
- private interpolate;
1434
+ /**
1435
+ * 將 Markdown 模板渲染為 HTML。
1436
+ * 先執行變數插值(`{{var}}`),再將 Markdown 轉為 HTML。
1437
+ * 內建 XSS 防護:過濾 script、iframe、event handler 等危險 HTML。
1438
+ *
1439
+ * 適用於郵件、Slack 等富文本通知頻道。
1440
+ *
1441
+ * @param template - Markdown 格式的模板字串
1442
+ * @returns 安全的 HTML 字串
1443
+ *
1444
+ * @example
1445
+ * ```typescript
1446
+ * const html = this.renderMarkdown('# Hello {{name}}')
1447
+ * await sendEmail({ htmlBody: html })
1448
+ * ```
1449
+ */
1450
+ protected renderMarkdown(template: string): string;
1451
+ protected interpolate(text: string): string;
1383
1452
  private getRecipientEmail;
1384
1453
  }
1385
1454
 
@@ -1866,4 +1935,4 @@ declare class TokenBucket {
1866
1935
  private refill;
1867
1936
  }
1868
1937
 
1869
- export { AbortError, type BatchResult, BroadcastChannel, type BroadcastNotification, type CacheStore, type ChannelFailurePayload, type ChannelHookPayload, type ChannelMiddleware, type ChannelRateLimitConfig, type ChannelSuccessPayload, DatabaseChannel, type DatabaseNotification, type FlareHookEvent, type FlareHookPayloads, type FlareHooks, type HookEmitter, LazyNotification, MailChannel, type MailChannelConfig, type MailMessage, type MailTemplate, MemoryStore, type MetricsSummary, MiddlewarePriority, type MiddlewarePriorityValue, type Notifiable, Notification, type NotificationBatchCompletePayload, type NotificationBatchStartPayload, type NotificationChannel, type NotificationCompletePayload, type NotificationHookPayload, NotificationManager, type NotificationMetric, NotificationMetricsCollector, type NotificationPreference, type NotificationResult, OrbitFlare, type OrbitFlareOptions, PreferenceMiddleware, type RateLimitConfig, RateLimitMiddleware, type RetryConfig, type SendOptions, type SendResult, type SerializationCheckResult, type ShouldQueue, type ShouldRetry, SlackChannel, type SlackChannelConfig, type SlackMessage, type SlackTemplate, SmsChannel, type SmsChannelConfig, type SmsMessage, type TemplateData, TemplatedNotification, TimeoutChannel, type TimeoutConfig, TimeoutError, TokenBucket, assertSerializable, checkSerializable, createHookEmitter, deepDeserialize, deepSerialize, toPrometheusFormat };
1938
+ export { AbortError, type BatchResult, BroadcastChannel, type BroadcastNotification, type CacheStore, type ChannelFailurePayload, type ChannelHookPayload, type ChannelMiddleware, type ChannelRateLimitConfig, type ChannelSuccessPayload, DatabaseChannel, type DatabaseNotification, FlareError, type FlareErrorCode, FlareErrorCodes, type FlareHookEvent, type FlareHookPayloads, type FlareHooks, type HookEmitter, LazyNotification, MailChannel, type MailChannelConfig, type MailMessage, type MailTemplate, MemoryStore, type MetricsSummary, MiddlewarePriority, type MiddlewarePriorityValue, type Notifiable, Notification, type NotificationBatchCompletePayload, type NotificationBatchStartPayload, type NotificationChannel, type NotificationCompletePayload, type NotificationHookPayload, NotificationManager, type NotificationMetric, NotificationMetricsCollector, type NotificationPreference, type NotificationResult, OrbitFlare, type OrbitFlareOptions, PreferenceMiddleware, type RateLimitConfig, RateLimitMiddleware, type RetryConfig, type SendOptions, type SendResult, type SerializationCheckResult, type ShouldQueue, type ShouldRetry, SlackChannel, type SlackChannelConfig, type SlackMessage, type SlackTemplate, SmsChannel, type SmsChannelConfig, type SmsMessage, type TemplateData, TemplatedNotification, TimeoutChannel, type TimeoutConfig, TimeoutError, TokenBucket, assertSerializable, checkSerializable, createHookEmitter, deepDeserialize, deepSerialize, toPrometheusFormat };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PlanetCore, GravitoOrbit } from '@gravito/core';
1
+ import { InfrastructureException, InfrastructureExceptionOptions, PlanetCore, GravitoOrbit } from '@gravito/core';
2
2
 
3
3
  /**
4
4
  * Notification system type definitions.
@@ -404,6 +404,59 @@ declare class BroadcastChannel implements NotificationChannel {
404
404
  send(notification: Notification, notifiable: Notifiable, options?: AbortableSendOptions): Promise<void>;
405
405
  }
406
406
 
407
+ /**
408
+ * @fileoverview Flare error codes
409
+ *
410
+ * Namespaced error codes for the Flare notification module.
411
+ *
412
+ * @module @gravito/flare/errors
413
+ */
414
+ /**
415
+ * Error codes for Flare module operations.
416
+ * Follows dot-separated namespace convention.
417
+ */
418
+ declare const FlareErrorCodes: {
419
+ readonly RATE_LIMIT_EXCEEDED: "flare.rate_limit_exceeded";
420
+ readonly SERIALIZATION_FAILED: "flare.serialization_failed";
421
+ readonly TEMPLATE_NOT_DEFINED: "flare.template_not_defined";
422
+ readonly NOTIFIABLE_MISSING_EMAIL: "flare.notifiable_missing_email";
423
+ readonly INVALID_CONFIG: "flare.invalid_config";
424
+ readonly NOTIFICATION_METHOD_NOT_IMPLEMENTED: "flare.notification_method_not_implemented";
425
+ readonly UNSUPPORTED_PROVIDER: "flare.unsupported_provider";
426
+ readonly CREDENTIALS_MISSING: "flare.credentials_missing";
427
+ readonly SEND_FAILED: "flare.send_failed";
428
+ };
429
+ type FlareErrorCode = (typeof FlareErrorCodes)[keyof typeof FlareErrorCodes];
430
+
431
+ /**
432
+ * @fileoverview Flare error types
433
+ *
434
+ * @module @gravito/flare/errors
435
+ */
436
+
437
+ /**
438
+ * Base error class for Flare module.
439
+ *
440
+ * Provides structured error handling with error codes and messages.
441
+ * Extends InfrastructureException for unified error handling across Gravito.
442
+ *
443
+ * @example
444
+ * ```typescript
445
+ * throw new FlareError('flare.send_failed', 'Failed to send notification')
446
+ * ```
447
+ * @public
448
+ */
449
+ declare class FlareError extends InfrastructureException {
450
+ /**
451
+ * Creates a new FlareError instance.
452
+ *
453
+ * @param code - The error code.
454
+ * @param message - The error message.
455
+ * @param options - Optional infrastructure exception options.
456
+ */
457
+ constructor(code: FlareErrorCode, message: string, options?: InfrastructureExceptionOptions);
458
+ }
459
+
407
460
  /**
408
461
  * Database channel 配置選項。
409
462
  */
@@ -1039,14 +1092,13 @@ declare class RateLimitMiddleware implements ChannelMiddleware {
1039
1092
  private buckets;
1040
1093
  /**
1041
1094
  * Cache store for distributed rate limiting.
1042
- * 分散式限流使用的快取儲存
1043
1095
  */
1044
- private store;
1096
+ store: CacheStore;
1045
1097
  /**
1046
1098
  * Create a new RateLimitMiddleware instance.
1047
1099
  *
1048
1100
  * @param config - Rate limit configuration for each channel
1049
- * @param store - Optional cache store for distributed rate limiting
1101
+ * @param store - Optional cache store for distributed rate limiting (future use)
1050
1102
  *
1051
1103
  * @example
1052
1104
  * ```typescript
@@ -1379,7 +1431,24 @@ declare abstract class TemplatedNotification extends Notification {
1379
1431
  protected slackTemplate?(): SlackTemplate;
1380
1432
  toMail(notifiable: Notifiable): MailMessage;
1381
1433
  toSlack(_notifiable: Notifiable): SlackMessage;
1382
- private interpolate;
1434
+ /**
1435
+ * 將 Markdown 模板渲染為 HTML。
1436
+ * 先執行變數插值(`{{var}}`),再將 Markdown 轉為 HTML。
1437
+ * 內建 XSS 防護:過濾 script、iframe、event handler 等危險 HTML。
1438
+ *
1439
+ * 適用於郵件、Slack 等富文本通知頻道。
1440
+ *
1441
+ * @param template - Markdown 格式的模板字串
1442
+ * @returns 安全的 HTML 字串
1443
+ *
1444
+ * @example
1445
+ * ```typescript
1446
+ * const html = this.renderMarkdown('# Hello {{name}}')
1447
+ * await sendEmail({ htmlBody: html })
1448
+ * ```
1449
+ */
1450
+ protected renderMarkdown(template: string): string;
1451
+ protected interpolate(text: string): string;
1383
1452
  private getRecipientEmail;
1384
1453
  }
1385
1454
 
@@ -1866,4 +1935,4 @@ declare class TokenBucket {
1866
1935
  private refill;
1867
1936
  }
1868
1937
 
1869
- export { AbortError, type BatchResult, BroadcastChannel, type BroadcastNotification, type CacheStore, type ChannelFailurePayload, type ChannelHookPayload, type ChannelMiddleware, type ChannelRateLimitConfig, type ChannelSuccessPayload, DatabaseChannel, type DatabaseNotification, type FlareHookEvent, type FlareHookPayloads, type FlareHooks, type HookEmitter, LazyNotification, MailChannel, type MailChannelConfig, type MailMessage, type MailTemplate, MemoryStore, type MetricsSummary, MiddlewarePriority, type MiddlewarePriorityValue, type Notifiable, Notification, type NotificationBatchCompletePayload, type NotificationBatchStartPayload, type NotificationChannel, type NotificationCompletePayload, type NotificationHookPayload, NotificationManager, type NotificationMetric, NotificationMetricsCollector, type NotificationPreference, type NotificationResult, OrbitFlare, type OrbitFlareOptions, PreferenceMiddleware, type RateLimitConfig, RateLimitMiddleware, type RetryConfig, type SendOptions, type SendResult, type SerializationCheckResult, type ShouldQueue, type ShouldRetry, SlackChannel, type SlackChannelConfig, type SlackMessage, type SlackTemplate, SmsChannel, type SmsChannelConfig, type SmsMessage, type TemplateData, TemplatedNotification, TimeoutChannel, type TimeoutConfig, TimeoutError, TokenBucket, assertSerializable, checkSerializable, createHookEmitter, deepDeserialize, deepSerialize, toPrometheusFormat };
1938
+ export { AbortError, type BatchResult, BroadcastChannel, type BroadcastNotification, type CacheStore, type ChannelFailurePayload, type ChannelHookPayload, type ChannelMiddleware, type ChannelRateLimitConfig, type ChannelSuccessPayload, DatabaseChannel, type DatabaseNotification, FlareError, type FlareErrorCode, FlareErrorCodes, type FlareHookEvent, type FlareHookPayloads, type FlareHooks, type HookEmitter, LazyNotification, MailChannel, type MailChannelConfig, type MailMessage, type MailTemplate, MemoryStore, type MetricsSummary, MiddlewarePriority, type MiddlewarePriorityValue, type Notifiable, Notification, type NotificationBatchCompletePayload, type NotificationBatchStartPayload, type NotificationChannel, type NotificationCompletePayload, type NotificationHookPayload, NotificationManager, type NotificationMetric, NotificationMetricsCollector, type NotificationPreference, type NotificationResult, OrbitFlare, type OrbitFlareOptions, PreferenceMiddleware, type RateLimitConfig, RateLimitMiddleware, type RetryConfig, type SendOptions, type SendResult, type SerializationCheckResult, type ShouldQueue, type ShouldRetry, SlackChannel, type SlackChannelConfig, type SlackMessage, type SlackTemplate, SmsChannel, type SmsChannelConfig, type SmsMessage, type TemplateData, TemplatedNotification, TimeoutChannel, type TimeoutConfig, TimeoutError, TokenBucket, assertSerializable, checkSerializable, createHookEmitter, deepDeserialize, deepSerialize, toPrometheusFormat };
package/dist/index.js CHANGED
@@ -183,6 +183,37 @@ var init_PreferenceMiddleware = __esm({
183
183
  }
184
184
  });
185
185
 
186
+ // src/errors/FlareError.ts
187
+ import { InfrastructureException } from "@gravito/core";
188
+ var FlareError = class extends InfrastructureException {
189
+ /**
190
+ * Creates a new FlareError instance.
191
+ *
192
+ * @param code - The error code.
193
+ * @param message - The error message.
194
+ * @param options - Optional infrastructure exception options.
195
+ */
196
+ constructor(code, message, options = {}) {
197
+ const status = options.retryable ? 503 : 500;
198
+ super(status, code, { message, ...options });
199
+ this.name = "FlareError";
200
+ Object.setPrototypeOf(this, new.target.prototype);
201
+ }
202
+ };
203
+
204
+ // src/errors/codes.ts
205
+ var FlareErrorCodes = {
206
+ RATE_LIMIT_EXCEEDED: "flare.rate_limit_exceeded",
207
+ SERIALIZATION_FAILED: "flare.serialization_failed",
208
+ TEMPLATE_NOT_DEFINED: "flare.template_not_defined",
209
+ NOTIFIABLE_MISSING_EMAIL: "flare.notifiable_missing_email",
210
+ INVALID_CONFIG: "flare.invalid_config",
211
+ NOTIFICATION_METHOD_NOT_IMPLEMENTED: "flare.notification_method_not_implemented",
212
+ UNSUPPORTED_PROVIDER: "flare.unsupported_provider",
213
+ CREDENTIALS_MISSING: "flare.credentials_missing",
214
+ SEND_FAILED: "flare.send_failed"
215
+ };
216
+
186
217
  // src/channels/TimeoutChannel.ts
187
218
  var TimeoutError = class extends Error {
188
219
  constructor(message) {
@@ -220,16 +251,23 @@ var TimeoutChannel = class {
220
251
  }
221
252
  const controller = new AbortController();
222
253
  const { signal } = controller;
254
+ let timeoutId;
255
+ let settled = false;
256
+ let handleExternalAbort;
223
257
  if (options?.signal) {
224
258
  if (options.signal.aborted) {
225
259
  throw new AbortError("Request was aborted before sending");
226
260
  }
227
- options.signal.addEventListener("abort", () => {
261
+ handleExternalAbort = () => {
228
262
  controller.abort();
229
- });
263
+ };
264
+ options.signal.addEventListener("abort", handleExternalAbort, { once: true });
230
265
  }
231
266
  const timeoutPromise = new Promise((_, reject) => {
232
- setTimeout(() => {
267
+ timeoutId = setTimeout(() => {
268
+ if (settled) {
269
+ return;
270
+ }
233
271
  if (this.config.onTimeout) {
234
272
  this.config.onTimeout(this.inner.constructor.name, notification);
235
273
  }
@@ -250,7 +288,17 @@ var TimeoutChannel = class {
250
288
  }
251
289
  throw error;
252
290
  });
253
- return Promise.race([sendPromise, timeoutPromise]);
291
+ try {
292
+ return await Promise.race([sendPromise, timeoutPromise]);
293
+ } finally {
294
+ settled = true;
295
+ if (timeoutId) {
296
+ clearTimeout(timeoutId);
297
+ }
298
+ if (options?.signal && handleExternalAbort) {
299
+ options.signal.removeEventListener("abort", handleExternalAbort);
300
+ }
301
+ }
254
302
  }
255
303
  };
256
304
 
@@ -263,7 +311,10 @@ var BroadcastChannel = class {
263
311
  const innerChannel = {
264
312
  send: async (notification, notifiable, _options) => {
265
313
  if (!notification.toBroadcast) {
266
- throw new Error("Notification does not implement toBroadcast method");
314
+ throw new FlareError(
315
+ FlareErrorCodes.NOTIFICATION_METHOD_NOT_IMPLEMENTED,
316
+ "Notification does not implement toBroadcast method"
317
+ );
267
318
  }
268
319
  const broadcastNotification = notification.toBroadcast(notifiable);
269
320
  const notifiableId = notifiable.getNotifiableId();
@@ -297,7 +348,10 @@ var DatabaseChannel = class {
297
348
  const innerChannel = {
298
349
  send: async (notification, notifiable, _options) => {
299
350
  if (!notification.toDatabase) {
300
- throw new Error("Notification does not implement toDatabase method");
351
+ throw new FlareError(
352
+ FlareErrorCodes.NOTIFICATION_METHOD_NOT_IMPLEMENTED,
353
+ "Notification does not implement toDatabase method"
354
+ );
301
355
  }
302
356
  const dbNotification = notification.toDatabase(notifiable);
303
357
  await this.dbService.insertNotification({
@@ -329,7 +383,10 @@ var MailChannel = class {
329
383
  const innerChannel = {
330
384
  send: async (notification, notifiable, _options) => {
331
385
  if (!notification.toMail) {
332
- throw new Error("Notification does not implement toMail method");
386
+ throw new FlareError(
387
+ FlareErrorCodes.NOTIFICATION_METHOD_NOT_IMPLEMENTED,
388
+ "Notification does not implement toMail method"
389
+ );
333
390
  }
334
391
  const message = notification.toMail(notifiable);
335
392
  await this.mailService.send(message);
@@ -355,7 +412,10 @@ var SlackChannel = class {
355
412
  const innerChannel = {
356
413
  send: async (notification, notifiable, options) => {
357
414
  if (!notification.toSlack) {
358
- throw new Error("Notification does not implement toSlack method");
415
+ throw new FlareError(
416
+ FlareErrorCodes.NOTIFICATION_METHOD_NOT_IMPLEMENTED,
417
+ "Notification does not implement toSlack method"
418
+ );
359
419
  }
360
420
  const slackMessage = notification.toSlack(notifiable);
361
421
  const response = await fetch(this.config.webhookUrl, {
@@ -374,7 +434,10 @@ var SlackChannel = class {
374
434
  // Pass AbortSignal to fetch
375
435
  });
376
436
  if (!response.ok) {
377
- throw new Error(`Failed to send Slack notification: ${response.statusText}`);
437
+ throw new FlareError(
438
+ FlareErrorCodes.SEND_FAILED,
439
+ `Failed to send Slack notification: ${response.statusText}`
440
+ );
378
441
  }
379
442
  }
380
443
  };
@@ -398,7 +461,10 @@ var SmsChannel = class {
398
461
  const innerChannel = {
399
462
  send: async (notification, notifiable, options) => {
400
463
  if (!notification.toSms) {
401
- throw new Error("Notification does not implement toSms method");
464
+ throw new FlareError(
465
+ FlareErrorCodes.NOTIFICATION_METHOD_NOT_IMPLEMENTED,
466
+ "Notification does not implement toSms method"
467
+ );
402
468
  }
403
469
  const smsMessage = notification.toSms(notifiable);
404
470
  switch (this.config.provider) {
@@ -409,7 +475,10 @@ var SmsChannel = class {
409
475
  await this.sendViaAwsSns(smsMessage, options?.signal);
410
476
  break;
411
477
  default:
412
- throw new Error(`Unsupported SMS provider: ${this.config.provider}`);
478
+ throw new FlareError(
479
+ FlareErrorCodes.UNSUPPORTED_PROVIDER,
480
+ `Unsupported SMS provider: ${this.config.provider}`
481
+ );
413
482
  }
414
483
  }
415
484
  };
@@ -428,7 +497,10 @@ var SmsChannel = class {
428
497
  */
429
498
  async sendViaTwilio(message, signal) {
430
499
  if (!this.config.apiKey || !this.config.apiSecret) {
431
- throw new Error("Twilio API key and secret are required");
500
+ throw new FlareError(
501
+ FlareErrorCodes.CREDENTIALS_MISSING,
502
+ "Twilio API key and secret are required"
503
+ );
432
504
  }
433
505
  const accountSid = this.config.apiKey;
434
506
  const authToken = this.config.apiSecret;
@@ -451,7 +523,7 @@ var SmsChannel = class {
451
523
  );
452
524
  if (!response.ok) {
453
525
  const error = await response.text();
454
- throw new Error(`Failed to send SMS via Twilio: ${error}`);
526
+ throw new FlareError(FlareErrorCodes.SEND_FAILED, `Failed to send SMS via Twilio: ${error}`);
455
527
  }
456
528
  }
457
529
  /**
@@ -465,7 +537,8 @@ var SmsChannel = class {
465
537
  SNSClient = awsSns.SNSClient;
466
538
  PublishCommand = awsSns.PublishCommand;
467
539
  } catch {
468
- throw new Error(
540
+ throw new FlareError(
541
+ FlareErrorCodes.UNSUPPORTED_PROVIDER,
469
542
  "AWS SNS SMS requires @aws-sdk/client-sns. Install it with: bun add @aws-sdk/client-sns"
470
543
  );
471
544
  }
@@ -496,7 +569,10 @@ var SmsChannel = class {
496
569
  await client.send(command, { abortSignal: signal });
497
570
  } catch (error) {
498
571
  const err = error instanceof Error ? error : new Error(String(error));
499
- throw new Error(`Failed to send SMS via AWS SNS: ${err.message}`);
572
+ throw new FlareError(
573
+ FlareErrorCodes.SEND_FAILED,
574
+ `Failed to send SMS via AWS SNS: ${err.message}`
575
+ );
500
576
  }
501
577
  }
502
578
  };
@@ -755,7 +831,7 @@ var RateLimitMiddleware = class {
755
831
  * Create a new RateLimitMiddleware instance.
756
832
  *
757
833
  * @param config - Rate limit configuration for each channel
758
- * @param store - Optional cache store for distributed rate limiting
834
+ * @param store - Optional cache store for distributed rate limiting (future use)
759
835
  *
760
836
  * @example
761
837
  * ```typescript
@@ -772,7 +848,7 @@ var RateLimitMiddleware = class {
772
848
  */
773
849
  constructor(config, store) {
774
850
  this.config = config;
775
- this.store = store ?? new MemoryStore();
851
+ this.store = store || new MemoryStore();
776
852
  this.initializeBuckets();
777
853
  }
778
854
  /**
@@ -791,7 +867,6 @@ var RateLimitMiddleware = class {
791
867
  buckets = /* @__PURE__ */ new Map();
792
868
  /**
793
869
  * Cache store for distributed rate limiting.
794
- * 分散式限流使用的快取儲存
795
870
  */
796
871
  store;
797
872
  /**
@@ -849,7 +924,8 @@ var RateLimitMiddleware = class {
849
924
  if (bucket) {
850
925
  const allowed = bucket.tryConsume();
851
926
  if (!allowed) {
852
- throw new Error(
927
+ throw new FlareError(
928
+ FlareErrorCodes.RATE_LIMIT_EXCEEDED,
853
929
  `Rate limit exceeded for channel '${channel}' (${window}ly limit). Please try again later.`
854
930
  );
855
931
  }
@@ -1152,7 +1228,8 @@ function checkSerializable(obj, path = "") {
1152
1228
  function assertSerializable(obj) {
1153
1229
  const result = checkSerializable(obj);
1154
1230
  if (!result.serializable) {
1155
- throw new Error(
1231
+ throw new FlareError(
1232
+ FlareErrorCodes.SERIALIZATION_FAILED,
1156
1233
  `\u7269\u4EF6\u5305\u542B\u4E0D\u53EF\u5E8F\u5217\u5316\u7684\u5C6C\u6027:
1157
1234
  \u554F\u984C\u8DEF\u5F91: ${result.problematicPaths.join(", ")}
1158
1235
  \u8A73\u7D30\u8CC7\u8A0A:
@@ -1671,24 +1748,30 @@ var OrbitFlare = class _OrbitFlare {
1671
1748
  if (options.enableSlack) {
1672
1749
  const slack = options.channels?.slack;
1673
1750
  if (!slack?.webhookUrl) {
1674
- throw new Error(
1751
+ throw new FlareError(
1752
+ FlareErrorCodes.INVALID_CONFIG,
1675
1753
  "[OrbitFlare] Slack channel enabled but webhookUrl not provided. Configure channels.slack.webhookUrl or set enableSlack to false."
1676
1754
  );
1677
1755
  }
1678
1756
  if (!this.isValidUrl(slack.webhookUrl)) {
1679
- throw new Error(`[OrbitFlare] Invalid Slack webhook URL: ${slack.webhookUrl}`);
1757
+ throw new FlareError(
1758
+ FlareErrorCodes.INVALID_CONFIG,
1759
+ `[OrbitFlare] Invalid Slack webhook URL: ${slack.webhookUrl}`
1760
+ );
1680
1761
  }
1681
1762
  }
1682
1763
  if (options.enableSms) {
1683
1764
  const sms = options.channels?.sms;
1684
1765
  if (!sms?.provider) {
1685
- throw new Error(
1766
+ throw new FlareError(
1767
+ FlareErrorCodes.INVALID_CONFIG,
1686
1768
  "[OrbitFlare] SMS channel enabled but provider not specified. Configure channels.sms.provider or set enableSms to false."
1687
1769
  );
1688
1770
  }
1689
1771
  const supportedProviders = ["twilio", "aws-sns"];
1690
1772
  if (!supportedProviders.includes(sms.provider)) {
1691
- throw new Error(
1773
+ throw new FlareError(
1774
+ FlareErrorCodes.UNSUPPORTED_PROVIDER,
1692
1775
  `[OrbitFlare] Unsupported SMS provider: ${sms.provider}. Supported providers: ${supportedProviders.join(", ")}`
1693
1776
  );
1694
1777
  }
@@ -1811,6 +1894,7 @@ var OrbitFlare = class _OrbitFlare {
1811
1894
  };
1812
1895
 
1813
1896
  // src/templates/NotificationTemplate.ts
1897
+ import { createHtmlRenderCallbacks, getMarkdownAdapter } from "@gravito/core";
1814
1898
  var TemplatedNotification = class extends Notification {
1815
1899
  data = {};
1816
1900
  with(data) {
@@ -1830,7 +1914,7 @@ var TemplatedNotification = class extends Notification {
1830
1914
  // Auto-implement toSlack
1831
1915
  toSlack(_notifiable) {
1832
1916
  if (!this.slackTemplate) {
1833
- throw new Error("slackTemplate not defined");
1917
+ throw new FlareError(FlareErrorCodes.TEMPLATE_NOT_DEFINED, "slackTemplate not defined");
1834
1918
  }
1835
1919
  const template = this.slackTemplate();
1836
1920
  return {
@@ -1839,6 +1923,33 @@ var TemplatedNotification = class extends Notification {
1839
1923
  attachments: template.attachments
1840
1924
  };
1841
1925
  }
1926
+ /**
1927
+ * 將 Markdown 模板渲染為 HTML。
1928
+ * 先執行變數插值(`{{var}}`),再將 Markdown 轉為 HTML。
1929
+ * 內建 XSS 防護:過濾 script、iframe、event handler 等危險 HTML。
1930
+ *
1931
+ * 適用於郵件、Slack 等富文本通知頻道。
1932
+ *
1933
+ * @param template - Markdown 格式的模板字串
1934
+ * @returns 安全的 HTML 字串
1935
+ *
1936
+ * @example
1937
+ * ```typescript
1938
+ * const html = this.renderMarkdown('# Hello {{name}}')
1939
+ * await sendEmail({ htmlBody: html })
1940
+ * ```
1941
+ */
1942
+ renderMarkdown(template) {
1943
+ if (!template) {
1944
+ return "";
1945
+ }
1946
+ const interpolated = this.interpolate(template);
1947
+ const md = getMarkdownAdapter();
1948
+ const sanitizeCallbacks = createHtmlRenderCallbacks({
1949
+ html: (rawHtml) => sanitizeHtml(rawHtml)
1950
+ });
1951
+ return md.render(interpolated, sanitizeCallbacks);
1952
+ }
1842
1953
  interpolate(text) {
1843
1954
  return text.replace(/\{\{(\w+)\}\}/g, (_, key) => String(this.data[key] ?? `{{${key}}}`));
1844
1955
  }
@@ -1846,9 +1957,23 @@ var TemplatedNotification = class extends Notification {
1846
1957
  if ("email" in notifiable && typeof notifiable.email === "string") {
1847
1958
  return notifiable.email;
1848
1959
  }
1849
- throw new Error("Notifiable does not have an email property");
1960
+ throw new FlareError(
1961
+ FlareErrorCodes.NOTIFIABLE_MISSING_EMAIL,
1962
+ "Notifiable does not have an email property"
1963
+ );
1850
1964
  }
1851
1965
  };
1966
+ var DANGEROUS_TAGS = /^<\/?(?:script|iframe|object|embed|form|input|textarea|button|select|style|link|meta|base)\b[^>]*>$/i;
1967
+ var EVENT_HANDLER_ATTRS = /\s+on\w+\s*=/i;
1968
+ function sanitizeHtml(rawHtml) {
1969
+ if (DANGEROUS_TAGS.test(rawHtml.trim())) {
1970
+ return "";
1971
+ }
1972
+ if (EVENT_HANDLER_ATTRS.test(rawHtml)) {
1973
+ return "";
1974
+ }
1975
+ return rawHtml;
1976
+ }
1852
1977
 
1853
1978
  // src/index.ts
1854
1979
  init_middleware();
@@ -1906,6 +2031,8 @@ export {
1906
2031
  AbortError,
1907
2032
  BroadcastChannel,
1908
2033
  DatabaseChannel,
2034
+ FlareError,
2035
+ FlareErrorCodes,
1909
2036
  LazyNotification,
1910
2037
  MailChannel,
1911
2038
  MemoryStore,
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@gravito/flare",
3
- "version": "4.0.1",
3
+ "sideEffects": false,
4
+ "version": "5.0.0",
4
5
  "publishConfig": {
5
6
  "access": "public"
6
7
  },
@@ -23,6 +24,7 @@
23
24
  ],
24
25
  "scripts": {
25
26
  "build": "bun run build.ts",
27
+ "build:dts": "bun run build.ts --dts-only",
26
28
  "test": "bun test --timeout=10000",
27
29
  "typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck",
28
30
  "test:coverage": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run --bun scripts/check-coverage.ts",
@@ -42,13 +44,13 @@
42
44
  "author": "Carl Lee <carllee0520@gmail.com>",
43
45
  "license": "MIT",
44
46
  "dependencies": {
45
- "@gravito/core": "^1.6.1",
47
+ "@gravito/core": "^2.0.6",
46
48
  "@aws-sdk/client-sns": "^3.734.0"
47
49
  },
48
50
  "peerDependencies": {
49
- "@gravito/stream": "^2.0.2",
50
- "@gravito/signal": "^3.0.4",
51
- "@gravito/radiance": "^1.0.4"
51
+ "@gravito/stream": "^3.0.0",
52
+ "@gravito/signal": "^4.0.0",
53
+ "@gravito/radiance": "^2.0.0"
52
54
  },
53
55
  "devDependencies": {
54
56
  "bun-types": "latest",