@gravito/flare 4.0.0 → 4.0.2

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
@@ -262,16 +262,23 @@ var TimeoutChannel = class {
262
262
  }
263
263
  const controller = new AbortController();
264
264
  const { signal } = controller;
265
+ let timeoutId;
266
+ let settled = false;
267
+ let handleExternalAbort;
265
268
  if (options?.signal) {
266
269
  if (options.signal.aborted) {
267
270
  throw new AbortError("Request was aborted before sending");
268
271
  }
269
- options.signal.addEventListener("abort", () => {
272
+ handleExternalAbort = () => {
270
273
  controller.abort();
271
- });
274
+ };
275
+ options.signal.addEventListener("abort", handleExternalAbort, { once: true });
272
276
  }
273
277
  const timeoutPromise = new Promise((_, reject) => {
274
- setTimeout(() => {
278
+ timeoutId = setTimeout(() => {
279
+ if (settled) {
280
+ return;
281
+ }
275
282
  if (this.config.onTimeout) {
276
283
  this.config.onTimeout(this.inner.constructor.name, notification);
277
284
  }
@@ -292,7 +299,17 @@ var TimeoutChannel = class {
292
299
  }
293
300
  throw error;
294
301
  });
295
- return Promise.race([sendPromise, timeoutPromise]);
302
+ try {
303
+ return await Promise.race([sendPromise, timeoutPromise]);
304
+ } finally {
305
+ settled = true;
306
+ if (timeoutId) {
307
+ clearTimeout(timeoutId);
308
+ }
309
+ if (options?.signal && handleExternalAbort) {
310
+ options.signal.removeEventListener("abort", handleExternalAbort);
311
+ }
312
+ }
296
313
  }
297
314
  };
298
315
 
@@ -797,7 +814,7 @@ var RateLimitMiddleware = class {
797
814
  * Create a new RateLimitMiddleware instance.
798
815
  *
799
816
  * @param config - Rate limit configuration for each channel
800
- * @param store - Optional cache store for distributed rate limiting
817
+ * @param store - Optional cache store for distributed rate limiting (future use)
801
818
  *
802
819
  * @example
803
820
  * ```typescript
@@ -814,7 +831,7 @@ var RateLimitMiddleware = class {
814
831
  */
815
832
  constructor(config, store) {
816
833
  this.config = config;
817
- this.store = store ?? new MemoryStore();
834
+ this.store = store || new MemoryStore();
818
835
  this.initializeBuckets();
819
836
  }
820
837
  /**
@@ -832,7 +849,7 @@ var RateLimitMiddleware = class {
832
849
  */
833
850
  buckets = /* @__PURE__ */ new Map();
834
851
  /**
835
- * Cache store for rate limit state (memory or distributed).
852
+ * Cache store for distributed rate limiting.
836
853
  */
837
854
  store;
838
855
  /**
@@ -1852,6 +1869,7 @@ var OrbitFlare = class _OrbitFlare {
1852
1869
  };
1853
1870
 
1854
1871
  // src/templates/NotificationTemplate.ts
1872
+ var import_core = require("@gravito/core");
1855
1873
  var TemplatedNotification = class extends Notification {
1856
1874
  data = {};
1857
1875
  with(data) {
@@ -1880,6 +1898,33 @@ var TemplatedNotification = class extends Notification {
1880
1898
  attachments: template.attachments
1881
1899
  };
1882
1900
  }
1901
+ /**
1902
+ * 將 Markdown 模板渲染為 HTML。
1903
+ * 先執行變數插值(`{{var}}`),再將 Markdown 轉為 HTML。
1904
+ * 內建 XSS 防護:過濾 script、iframe、event handler 等危險 HTML。
1905
+ *
1906
+ * 適用於郵件、Slack 等富文本通知頻道。
1907
+ *
1908
+ * @param template - Markdown 格式的模板字串
1909
+ * @returns 安全的 HTML 字串
1910
+ *
1911
+ * @example
1912
+ * ```typescript
1913
+ * const html = this.renderMarkdown('# Hello {{name}}')
1914
+ * await sendEmail({ htmlBody: html })
1915
+ * ```
1916
+ */
1917
+ renderMarkdown(template) {
1918
+ if (!template) {
1919
+ return "";
1920
+ }
1921
+ const interpolated = this.interpolate(template);
1922
+ const md = (0, import_core.getMarkdownAdapter)();
1923
+ const sanitizeCallbacks = (0, import_core.createHtmlRenderCallbacks)({
1924
+ html: (rawHtml) => sanitizeHtml(rawHtml)
1925
+ });
1926
+ return md.render(interpolated, sanitizeCallbacks);
1927
+ }
1883
1928
  interpolate(text) {
1884
1929
  return text.replace(/\{\{(\w+)\}\}/g, (_, key) => String(this.data[key] ?? `{{${key}}}`));
1885
1930
  }
@@ -1890,6 +1935,17 @@ var TemplatedNotification = class extends Notification {
1890
1935
  throw new Error("Notifiable does not have an email property");
1891
1936
  }
1892
1937
  };
1938
+ var DANGEROUS_TAGS = /^<\/?(?:script|iframe|object|embed|form|input|textarea|button|select|style|link|meta|base)\b[^>]*>$/i;
1939
+ var EVENT_HANDLER_ATTRS = /\s+on\w+\s*=/i;
1940
+ function sanitizeHtml(rawHtml) {
1941
+ if (DANGEROUS_TAGS.test(rawHtml.trim())) {
1942
+ return "";
1943
+ }
1944
+ if (EVENT_HANDLER_ATTRS.test(rawHtml)) {
1945
+ return "";
1946
+ }
1947
+ return rawHtml;
1948
+ }
1893
1949
 
1894
1950
  // src/index.ts
1895
1951
  init_middleware();
package/dist/index.d.cts CHANGED
@@ -1038,14 +1038,14 @@ declare class RateLimitMiddleware implements ChannelMiddleware {
1038
1038
  */
1039
1039
  private buckets;
1040
1040
  /**
1041
- * Cache store for rate limit state (memory or distributed).
1041
+ * Cache store for distributed rate limiting.
1042
1042
  */
1043
- private store;
1043
+ store: CacheStore;
1044
1044
  /**
1045
1045
  * Create a new RateLimitMiddleware instance.
1046
1046
  *
1047
1047
  * @param config - Rate limit configuration for each channel
1048
- * @param store - Optional cache store for distributed rate limiting
1048
+ * @param store - Optional cache store for distributed rate limiting (future use)
1049
1049
  *
1050
1050
  * @example
1051
1051
  * ```typescript
@@ -1378,7 +1378,24 @@ declare abstract class TemplatedNotification extends Notification {
1378
1378
  protected slackTemplate?(): SlackTemplate;
1379
1379
  toMail(notifiable: Notifiable): MailMessage;
1380
1380
  toSlack(_notifiable: Notifiable): SlackMessage;
1381
- private interpolate;
1381
+ /**
1382
+ * 將 Markdown 模板渲染為 HTML。
1383
+ * 先執行變數插值(`{{var}}`),再將 Markdown 轉為 HTML。
1384
+ * 內建 XSS 防護:過濾 script、iframe、event handler 等危險 HTML。
1385
+ *
1386
+ * 適用於郵件、Slack 等富文本通知頻道。
1387
+ *
1388
+ * @param template - Markdown 格式的模板字串
1389
+ * @returns 安全的 HTML 字串
1390
+ *
1391
+ * @example
1392
+ * ```typescript
1393
+ * const html = this.renderMarkdown('# Hello {{name}}')
1394
+ * await sendEmail({ htmlBody: html })
1395
+ * ```
1396
+ */
1397
+ protected renderMarkdown(template: string): string;
1398
+ protected interpolate(text: string): string;
1382
1399
  private getRecipientEmail;
1383
1400
  }
1384
1401
 
package/dist/index.d.ts CHANGED
@@ -1038,14 +1038,14 @@ declare class RateLimitMiddleware implements ChannelMiddleware {
1038
1038
  */
1039
1039
  private buckets;
1040
1040
  /**
1041
- * Cache store for rate limit state (memory or distributed).
1041
+ * Cache store for distributed rate limiting.
1042
1042
  */
1043
- private store;
1043
+ store: CacheStore;
1044
1044
  /**
1045
1045
  * Create a new RateLimitMiddleware instance.
1046
1046
  *
1047
1047
  * @param config - Rate limit configuration for each channel
1048
- * @param store - Optional cache store for distributed rate limiting
1048
+ * @param store - Optional cache store for distributed rate limiting (future use)
1049
1049
  *
1050
1050
  * @example
1051
1051
  * ```typescript
@@ -1378,7 +1378,24 @@ declare abstract class TemplatedNotification extends Notification {
1378
1378
  protected slackTemplate?(): SlackTemplate;
1379
1379
  toMail(notifiable: Notifiable): MailMessage;
1380
1380
  toSlack(_notifiable: Notifiable): SlackMessage;
1381
- private interpolate;
1381
+ /**
1382
+ * 將 Markdown 模板渲染為 HTML。
1383
+ * 先執行變數插值(`{{var}}`),再將 Markdown 轉為 HTML。
1384
+ * 內建 XSS 防護:過濾 script、iframe、event handler 等危險 HTML。
1385
+ *
1386
+ * 適用於郵件、Slack 等富文本通知頻道。
1387
+ *
1388
+ * @param template - Markdown 格式的模板字串
1389
+ * @returns 安全的 HTML 字串
1390
+ *
1391
+ * @example
1392
+ * ```typescript
1393
+ * const html = this.renderMarkdown('# Hello {{name}}')
1394
+ * await sendEmail({ htmlBody: html })
1395
+ * ```
1396
+ */
1397
+ protected renderMarkdown(template: string): string;
1398
+ protected interpolate(text: string): string;
1382
1399
  private getRecipientEmail;
1383
1400
  }
1384
1401
 
package/dist/index.js CHANGED
@@ -220,16 +220,23 @@ var TimeoutChannel = class {
220
220
  }
221
221
  const controller = new AbortController();
222
222
  const { signal } = controller;
223
+ let timeoutId;
224
+ let settled = false;
225
+ let handleExternalAbort;
223
226
  if (options?.signal) {
224
227
  if (options.signal.aborted) {
225
228
  throw new AbortError("Request was aborted before sending");
226
229
  }
227
- options.signal.addEventListener("abort", () => {
230
+ handleExternalAbort = () => {
228
231
  controller.abort();
229
- });
232
+ };
233
+ options.signal.addEventListener("abort", handleExternalAbort, { once: true });
230
234
  }
231
235
  const timeoutPromise = new Promise((_, reject) => {
232
- setTimeout(() => {
236
+ timeoutId = setTimeout(() => {
237
+ if (settled) {
238
+ return;
239
+ }
233
240
  if (this.config.onTimeout) {
234
241
  this.config.onTimeout(this.inner.constructor.name, notification);
235
242
  }
@@ -250,7 +257,17 @@ var TimeoutChannel = class {
250
257
  }
251
258
  throw error;
252
259
  });
253
- return Promise.race([sendPromise, timeoutPromise]);
260
+ try {
261
+ return await Promise.race([sendPromise, timeoutPromise]);
262
+ } finally {
263
+ settled = true;
264
+ if (timeoutId) {
265
+ clearTimeout(timeoutId);
266
+ }
267
+ if (options?.signal && handleExternalAbort) {
268
+ options.signal.removeEventListener("abort", handleExternalAbort);
269
+ }
270
+ }
254
271
  }
255
272
  };
256
273
 
@@ -755,7 +772,7 @@ var RateLimitMiddleware = class {
755
772
  * Create a new RateLimitMiddleware instance.
756
773
  *
757
774
  * @param config - Rate limit configuration for each channel
758
- * @param store - Optional cache store for distributed rate limiting
775
+ * @param store - Optional cache store for distributed rate limiting (future use)
759
776
  *
760
777
  * @example
761
778
  * ```typescript
@@ -772,7 +789,7 @@ var RateLimitMiddleware = class {
772
789
  */
773
790
  constructor(config, store) {
774
791
  this.config = config;
775
- this.store = store ?? new MemoryStore();
792
+ this.store = store || new MemoryStore();
776
793
  this.initializeBuckets();
777
794
  }
778
795
  /**
@@ -790,7 +807,7 @@ var RateLimitMiddleware = class {
790
807
  */
791
808
  buckets = /* @__PURE__ */ new Map();
792
809
  /**
793
- * Cache store for rate limit state (memory or distributed).
810
+ * Cache store for distributed rate limiting.
794
811
  */
795
812
  store;
796
813
  /**
@@ -1810,6 +1827,7 @@ var OrbitFlare = class _OrbitFlare {
1810
1827
  };
1811
1828
 
1812
1829
  // src/templates/NotificationTemplate.ts
1830
+ import { createHtmlRenderCallbacks, getMarkdownAdapter } from "@gravito/core";
1813
1831
  var TemplatedNotification = class extends Notification {
1814
1832
  data = {};
1815
1833
  with(data) {
@@ -1838,6 +1856,33 @@ var TemplatedNotification = class extends Notification {
1838
1856
  attachments: template.attachments
1839
1857
  };
1840
1858
  }
1859
+ /**
1860
+ * 將 Markdown 模板渲染為 HTML。
1861
+ * 先執行變數插值(`{{var}}`),再將 Markdown 轉為 HTML。
1862
+ * 內建 XSS 防護:過濾 script、iframe、event handler 等危險 HTML。
1863
+ *
1864
+ * 適用於郵件、Slack 等富文本通知頻道。
1865
+ *
1866
+ * @param template - Markdown 格式的模板字串
1867
+ * @returns 安全的 HTML 字串
1868
+ *
1869
+ * @example
1870
+ * ```typescript
1871
+ * const html = this.renderMarkdown('# Hello {{name}}')
1872
+ * await sendEmail({ htmlBody: html })
1873
+ * ```
1874
+ */
1875
+ renderMarkdown(template) {
1876
+ if (!template) {
1877
+ return "";
1878
+ }
1879
+ const interpolated = this.interpolate(template);
1880
+ const md = getMarkdownAdapter();
1881
+ const sanitizeCallbacks = createHtmlRenderCallbacks({
1882
+ html: (rawHtml) => sanitizeHtml(rawHtml)
1883
+ });
1884
+ return md.render(interpolated, sanitizeCallbacks);
1885
+ }
1841
1886
  interpolate(text) {
1842
1887
  return text.replace(/\{\{(\w+)\}\}/g, (_, key) => String(this.data[key] ?? `{{${key}}}`));
1843
1888
  }
@@ -1848,6 +1893,17 @@ var TemplatedNotification = class extends Notification {
1848
1893
  throw new Error("Notifiable does not have an email property");
1849
1894
  }
1850
1895
  };
1896
+ var DANGEROUS_TAGS = /^<\/?(?:script|iframe|object|embed|form|input|textarea|button|select|style|link|meta|base)\b[^>]*>$/i;
1897
+ var EVENT_HANDLER_ATTRS = /\s+on\w+\s*=/i;
1898
+ function sanitizeHtml(rawHtml) {
1899
+ if (DANGEROUS_TAGS.test(rawHtml.trim())) {
1900
+ return "";
1901
+ }
1902
+ if (EVENT_HANDLER_ATTRS.test(rawHtml)) {
1903
+ return "";
1904
+ }
1905
+ return rawHtml;
1906
+ }
1851
1907
 
1852
1908
  // src/index.ts
1853
1909
  init_middleware();
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@gravito/flare",
3
- "version": "4.0.0",
3
+ "sideEffects": false,
4
+ "version": "4.0.2",
4
5
  "publishConfig": {
5
6
  "access": "public"
6
7
  },
@@ -23,11 +24,12 @@
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",
29
31
  "test:ci": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run --bun scripts/check-coverage.ts",
30
- "test:unit": "bun test tests/ --timeout=10000",
32
+ "test:unit": "bun test $(find tests -name '*.test.ts' ! -name '*.integration.test.ts' 2>/dev/null | tr '\\n' ' ') --timeout=10000",
31
33
  "test:integration": "test $(find tests -name '*.integration.test.ts' 2>/dev/null | wc -l) -gt 0 && find tests -name '*.integration.test.ts' -print0 | xargs -0 bun test --timeout=10000 || echo 'No integration tests found'"
32
34
  },
33
35
  "keywords": [
@@ -42,13 +44,13 @@
42
44
  "author": "Carl Lee <carllee0520@gmail.com>",
43
45
  "license": "MIT",
44
46
  "dependencies": {
45
- "@gravito/core": "workspace:*",
47
+ "@gravito/core": "^2.0.6",
46
48
  "@aws-sdk/client-sns": "^3.734.0"
47
49
  },
48
50
  "peerDependencies": {
49
- "@gravito/stream": "workspace:*",
50
- "@gravito/signal": "workspace:*",
51
- "@gravito/radiance": "workspace:*"
51
+ "@gravito/stream": "^2.1.0",
52
+ "@gravito/signal": "^3.1.0",
53
+ "@gravito/radiance": "^1.0.4"
52
54
  },
53
55
  "devDependencies": {
54
56
  "bun-types": "latest",