@gravito/flare 3.3.0 → 4.0.1
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 +135 -3
- package/dist/index.cjs +1036 -97
- package/dist/index.d.cts +1250 -12
- package/dist/index.d.ts +1250 -12
- package/dist/index.js +1040 -97
- package/package.json +10 -8
package/dist/index.cjs
CHANGED
|
@@ -5,6 +5,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
5
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __esm = (fn, res) => function __init() {
|
|
9
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
+
};
|
|
8
11
|
var __export = (target, all) => {
|
|
9
12
|
for (var name in all)
|
|
10
13
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
@@ -27,131 +30,445 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
27
30
|
));
|
|
28
31
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
32
|
|
|
33
|
+
// src/types/middleware.ts
|
|
34
|
+
var MiddlewarePriority;
|
|
35
|
+
var init_middleware = __esm({
|
|
36
|
+
"src/types/middleware.ts"() {
|
|
37
|
+
"use strict";
|
|
38
|
+
MiddlewarePriority = {
|
|
39
|
+
/** 最高優先級:安全檢查 (100) */
|
|
40
|
+
SECURITY: 100,
|
|
41
|
+
/** 高優先級:限流 (80) */
|
|
42
|
+
RATE_LIMIT: 80,
|
|
43
|
+
/** 中等優先級:驗證 (50) */
|
|
44
|
+
VALIDATION: 50,
|
|
45
|
+
/** 預設優先級 (0) */
|
|
46
|
+
DEFAULT: 0,
|
|
47
|
+
/** 低優先級:日誌記錄 (-50) */
|
|
48
|
+
LOGGING: -50,
|
|
49
|
+
/** 最低優先級:監控 (-100) */
|
|
50
|
+
MONITORING: -100
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// src/middleware/PreferenceMiddleware.ts
|
|
56
|
+
var PreferenceMiddleware_exports = {};
|
|
57
|
+
__export(PreferenceMiddleware_exports, {
|
|
58
|
+
PreferenceMiddleware: () => PreferenceMiddleware
|
|
59
|
+
});
|
|
60
|
+
var PreferenceMiddleware;
|
|
61
|
+
var init_PreferenceMiddleware = __esm({
|
|
62
|
+
"src/middleware/PreferenceMiddleware.ts"() {
|
|
63
|
+
"use strict";
|
|
64
|
+
init_middleware();
|
|
65
|
+
PreferenceMiddleware = class {
|
|
66
|
+
/**
|
|
67
|
+
* Create a new PreferenceMiddleware instance.
|
|
68
|
+
*
|
|
69
|
+
* @param preferenceProvider - Optional preference provider; uses Notifiable method if not provided.
|
|
70
|
+
* @param logger - Optional logger instance for recording errors.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```typescript
|
|
74
|
+
* // Without provider (reads from Notifiable.getNotificationPreferences)
|
|
75
|
+
* const middleware = new PreferenceMiddleware();
|
|
76
|
+
*
|
|
77
|
+
* // Using database provider and logger
|
|
78
|
+
* const middleware = new PreferenceMiddleware(new DatabasePreferenceProvider(), logger);
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
constructor(preferenceProvider, logger) {
|
|
82
|
+
this.preferenceProvider = preferenceProvider;
|
|
83
|
+
this.logger = logger;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Middleware name.
|
|
87
|
+
*/
|
|
88
|
+
name = "preference";
|
|
89
|
+
/**
|
|
90
|
+
* Middleware priority (medium priority for validation).
|
|
91
|
+
*/
|
|
92
|
+
priority = MiddlewarePriority.VALIDATION;
|
|
93
|
+
/**
|
|
94
|
+
* Handle the notification and apply user preference filtering.
|
|
95
|
+
*
|
|
96
|
+
* Processes the notification and filters based on user preferences:
|
|
97
|
+
* 1. If notification type is in disabledNotifications, it is skipped.
|
|
98
|
+
* 2. If channel is in disabledChannels, it is skipped.
|
|
99
|
+
* 3. If enabledChannels is set, only channels in that list are allowed.
|
|
100
|
+
* 4. If preference loading fails, the notification is allowed as a fallback.
|
|
101
|
+
*
|
|
102
|
+
* @param notification - The notification to send.
|
|
103
|
+
* @param notifiable - The recipient.
|
|
104
|
+
* @param channel - The channel name.
|
|
105
|
+
* @param next - Callback to proceed to the next middleware or send operation.
|
|
106
|
+
* @returns A promise that resolves when processing is complete.
|
|
107
|
+
*/
|
|
108
|
+
async handle(notification, notifiable, channel, next) {
|
|
109
|
+
try {
|
|
110
|
+
const preferences = await this.getPreferences(notifiable);
|
|
111
|
+
if (!preferences) {
|
|
112
|
+
await next();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (this.isNotificationDisabled(notification, preferences)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!this.isChannelAllowed(channel, preferences)) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
await next();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const errorMessage = `[PreferenceMiddleware] Failed to load preferences for ${notifiable.getNotifiableId()}, allowing notification to proceed:`;
|
|
124
|
+
if (this.logger) {
|
|
125
|
+
this.logger.error(errorMessage, error);
|
|
126
|
+
} else {
|
|
127
|
+
console.error(errorMessage, error);
|
|
128
|
+
}
|
|
129
|
+
await next();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Get user preferences from Notifiable or custom provider.
|
|
134
|
+
*
|
|
135
|
+
* Priority: Notifiable.getNotificationPreferences > preferenceProvider.
|
|
136
|
+
*
|
|
137
|
+
* @param notifiable - The recipient.
|
|
138
|
+
* @returns The user preferences or null if not found.
|
|
139
|
+
*/
|
|
140
|
+
async getPreferences(notifiable) {
|
|
141
|
+
if (notifiable.getNotificationPreferences) {
|
|
142
|
+
const prefs = await notifiable.getNotificationPreferences();
|
|
143
|
+
return prefs || null;
|
|
144
|
+
}
|
|
145
|
+
if (this.preferenceProvider) {
|
|
146
|
+
const prefs = await this.preferenceProvider.getUserPreferences(notifiable);
|
|
147
|
+
return prefs || null;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Check if notification type is disabled by user.
|
|
153
|
+
*
|
|
154
|
+
* @param notification - The notification instance.
|
|
155
|
+
* @param preferences - User preferences.
|
|
156
|
+
* @returns True if the notification is disabled.
|
|
157
|
+
*/
|
|
158
|
+
isNotificationDisabled(notification, preferences) {
|
|
159
|
+
const { disabledNotifications } = preferences;
|
|
160
|
+
if (!disabledNotifications || disabledNotifications.length === 0) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
const notificationName = notification.constructor.name;
|
|
164
|
+
return disabledNotifications.includes(notificationName);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Check if channel is allowed by user preferences.
|
|
168
|
+
*
|
|
169
|
+
* Priority:
|
|
170
|
+
* 1. disabledChannels (if listed, it is denied)
|
|
171
|
+
* 2. enabledChannels (if set, only listed are allowed)
|
|
172
|
+
* 3. Allow all if neither are set.
|
|
173
|
+
*
|
|
174
|
+
* @param channel - The channel name.
|
|
175
|
+
* @param preferences - User preferences.
|
|
176
|
+
* @returns True if the channel is allowed.
|
|
177
|
+
*/
|
|
178
|
+
isChannelAllowed(channel, preferences) {
|
|
179
|
+
const { enabledChannels, disabledChannels } = preferences;
|
|
180
|
+
if (disabledChannels && disabledChannels.length > 0) {
|
|
181
|
+
if (disabledChannels.includes(channel)) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (enabledChannels && enabledChannels.length > 0) {
|
|
186
|
+
return enabledChannels.includes(channel);
|
|
187
|
+
}
|
|
188
|
+
if (enabledChannels !== void 0 && enabledChannels.length === 0) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
30
197
|
// src/index.ts
|
|
31
198
|
var index_exports = {};
|
|
32
199
|
__export(index_exports, {
|
|
200
|
+
AbortError: () => AbortError,
|
|
33
201
|
BroadcastChannel: () => BroadcastChannel,
|
|
34
202
|
DatabaseChannel: () => DatabaseChannel,
|
|
203
|
+
LazyNotification: () => LazyNotification,
|
|
35
204
|
MailChannel: () => MailChannel,
|
|
205
|
+
MemoryStore: () => MemoryStore,
|
|
206
|
+
MiddlewarePriority: () => MiddlewarePriority,
|
|
36
207
|
Notification: () => Notification,
|
|
37
208
|
NotificationManager: () => NotificationManager,
|
|
38
209
|
NotificationMetricsCollector: () => NotificationMetricsCollector,
|
|
39
210
|
OrbitFlare: () => OrbitFlare,
|
|
211
|
+
PreferenceMiddleware: () => PreferenceMiddleware,
|
|
212
|
+
RateLimitMiddleware: () => RateLimitMiddleware,
|
|
40
213
|
SlackChannel: () => SlackChannel,
|
|
41
214
|
SmsChannel: () => SmsChannel,
|
|
42
215
|
TemplatedNotification: () => TemplatedNotification,
|
|
216
|
+
TimeoutChannel: () => TimeoutChannel,
|
|
217
|
+
TimeoutError: () => TimeoutError,
|
|
218
|
+
TokenBucket: () => TokenBucket,
|
|
219
|
+
assertSerializable: () => assertSerializable,
|
|
220
|
+
checkSerializable: () => checkSerializable,
|
|
221
|
+
createHookEmitter: () => createHookEmitter,
|
|
222
|
+
deepDeserialize: () => deepDeserialize,
|
|
223
|
+
deepSerialize: () => deepSerialize,
|
|
43
224
|
toPrometheusFormat: () => toPrometheusFormat
|
|
44
225
|
});
|
|
45
226
|
module.exports = __toCommonJS(index_exports);
|
|
46
227
|
|
|
228
|
+
// src/channels/TimeoutChannel.ts
|
|
229
|
+
var TimeoutError = class extends Error {
|
|
230
|
+
constructor(message) {
|
|
231
|
+
super(message);
|
|
232
|
+
this.name = "TimeoutError";
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
var AbortError = class extends Error {
|
|
236
|
+
constructor(message) {
|
|
237
|
+
super(message);
|
|
238
|
+
this.name = "AbortError";
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
var TimeoutChannel = class {
|
|
242
|
+
constructor(inner, config) {
|
|
243
|
+
this.inner = inner;
|
|
244
|
+
this.config = config;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Sends a notification through the inner channel with a timeout guard.
|
|
248
|
+
*
|
|
249
|
+
* @param notification - The notification to send.
|
|
250
|
+
* @param notifiable - The recipient of the notification.
|
|
251
|
+
* @param options - Send options including an optional AbortSignal.
|
|
252
|
+
* @returns A promise that resolves when the notification is sent.
|
|
253
|
+
* @throws {TimeoutError} Thrown if the operation exceeds the configured timeout.
|
|
254
|
+
* @throws {AbortError} Thrown if the operation is aborted via the provided signal.
|
|
255
|
+
*/
|
|
256
|
+
async send(notification, notifiable, options) {
|
|
257
|
+
if (this.config.timeout <= 0) {
|
|
258
|
+
if (this.config.onTimeout) {
|
|
259
|
+
this.config.onTimeout(this.inner.constructor.name, notification);
|
|
260
|
+
}
|
|
261
|
+
throw new TimeoutError(`Notification send timeout after ${this.config.timeout}ms`);
|
|
262
|
+
}
|
|
263
|
+
const controller = new AbortController();
|
|
264
|
+
const { signal } = controller;
|
|
265
|
+
if (options?.signal) {
|
|
266
|
+
if (options.signal.aborted) {
|
|
267
|
+
throw new AbortError("Request was aborted before sending");
|
|
268
|
+
}
|
|
269
|
+
options.signal.addEventListener("abort", () => {
|
|
270
|
+
controller.abort();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
274
|
+
setTimeout(() => {
|
|
275
|
+
if (this.config.onTimeout) {
|
|
276
|
+
this.config.onTimeout(this.inner.constructor.name, notification);
|
|
277
|
+
}
|
|
278
|
+
controller.abort();
|
|
279
|
+
if (options?.signal?.aborted) {
|
|
280
|
+
reject(new AbortError("Request was aborted externally"));
|
|
281
|
+
} else {
|
|
282
|
+
reject(new TimeoutError(`Notification send timeout after ${this.config.timeout}ms`));
|
|
283
|
+
}
|
|
284
|
+
}, this.config.timeout);
|
|
285
|
+
});
|
|
286
|
+
const sendPromise = this.inner.send(notification, notifiable, { signal }).catch((error) => {
|
|
287
|
+
if (options?.signal?.aborted) {
|
|
288
|
+
throw new AbortError("Request was aborted externally");
|
|
289
|
+
}
|
|
290
|
+
if (signal.aborted) {
|
|
291
|
+
throw new TimeoutError(`Notification send timeout after ${this.config.timeout}ms`);
|
|
292
|
+
}
|
|
293
|
+
throw error;
|
|
294
|
+
});
|
|
295
|
+
return Promise.race([sendPromise, timeoutPromise]);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
47
299
|
// src/channels/BroadcastChannel.ts
|
|
300
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
48
301
|
var BroadcastChannel = class {
|
|
49
|
-
constructor(broadcastService) {
|
|
302
|
+
constructor(broadcastService, config) {
|
|
50
303
|
this.broadcastService = broadcastService;
|
|
304
|
+
this.config = config;
|
|
305
|
+
const innerChannel = {
|
|
306
|
+
send: async (notification, notifiable, _options) => {
|
|
307
|
+
if (!notification.toBroadcast) {
|
|
308
|
+
throw new Error("Notification does not implement toBroadcast method");
|
|
309
|
+
}
|
|
310
|
+
const broadcastNotification = notification.toBroadcast(notifiable);
|
|
311
|
+
const notifiableId = notifiable.getNotifiableId();
|
|
312
|
+
const notifiableType = notifiable.getNotifiableType?.() || "user";
|
|
313
|
+
const channel = `private-${notifiableType}.${notifiableId}`;
|
|
314
|
+
await this.broadcastService.broadcast(
|
|
315
|
+
channel,
|
|
316
|
+
broadcastNotification.type,
|
|
317
|
+
broadcastNotification.data
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
const timeout = this.config?.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
322
|
+
this.timeoutChannel = new TimeoutChannel(innerChannel, {
|
|
323
|
+
timeout,
|
|
324
|
+
onTimeout: this.config?.onTimeout
|
|
325
|
+
});
|
|
51
326
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
const broadcastNotification = notification.toBroadcast(notifiable);
|
|
57
|
-
const notifiableId = notifiable.getNotifiableId();
|
|
58
|
-
const notifiableType = notifiable.getNotifiableType?.() || "user";
|
|
59
|
-
const channel = `private-${notifiableType}.${notifiableId}`;
|
|
60
|
-
await this.broadcastService.broadcast(
|
|
61
|
-
channel,
|
|
62
|
-
broadcastNotification.type,
|
|
63
|
-
broadcastNotification.data
|
|
64
|
-
);
|
|
327
|
+
timeoutChannel;
|
|
328
|
+
async send(notification, notifiable, options) {
|
|
329
|
+
return this.timeoutChannel.send(notification, notifiable, options);
|
|
65
330
|
}
|
|
66
331
|
};
|
|
67
332
|
|
|
68
333
|
// src/channels/DatabaseChannel.ts
|
|
334
|
+
var DEFAULT_TIMEOUT_MS2 = 1e4;
|
|
69
335
|
var DatabaseChannel = class {
|
|
70
|
-
constructor(dbService) {
|
|
336
|
+
constructor(dbService, config) {
|
|
71
337
|
this.dbService = dbService;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
338
|
+
this.config = config;
|
|
339
|
+
const innerChannel = {
|
|
340
|
+
send: async (notification, notifiable, _options) => {
|
|
341
|
+
if (!notification.toDatabase) {
|
|
342
|
+
throw new Error("Notification does not implement toDatabase method");
|
|
343
|
+
}
|
|
344
|
+
const dbNotification = notification.toDatabase(notifiable);
|
|
345
|
+
await this.dbService.insertNotification({
|
|
346
|
+
notifiableId: notifiable.getNotifiableId(),
|
|
347
|
+
notifiableType: notifiable.getNotifiableType?.() || "user",
|
|
348
|
+
type: dbNotification.type,
|
|
349
|
+
data: dbNotification.data
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
const timeout = this.config?.timeout ?? DEFAULT_TIMEOUT_MS2;
|
|
354
|
+
this.timeoutChannel = new TimeoutChannel(innerChannel, {
|
|
355
|
+
timeout,
|
|
356
|
+
onTimeout: this.config?.onTimeout
|
|
83
357
|
});
|
|
84
358
|
}
|
|
359
|
+
timeoutChannel;
|
|
360
|
+
async send(notification, notifiable, options) {
|
|
361
|
+
return this.timeoutChannel.send(notification, notifiable, options);
|
|
362
|
+
}
|
|
85
363
|
};
|
|
86
364
|
|
|
87
365
|
// src/channels/MailChannel.ts
|
|
366
|
+
var DEFAULT_TIMEOUT_MS3 = 3e4;
|
|
88
367
|
var MailChannel = class {
|
|
89
|
-
constructor(mailService) {
|
|
368
|
+
constructor(mailService, config) {
|
|
90
369
|
this.mailService = mailService;
|
|
370
|
+
this.config = config;
|
|
371
|
+
const innerChannel = {
|
|
372
|
+
send: async (notification, notifiable, _options) => {
|
|
373
|
+
if (!notification.toMail) {
|
|
374
|
+
throw new Error("Notification does not implement toMail method");
|
|
375
|
+
}
|
|
376
|
+
const message = notification.toMail(notifiable);
|
|
377
|
+
await this.mailService.send(message);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const timeout = this.config?.timeout ?? DEFAULT_TIMEOUT_MS3;
|
|
381
|
+
this.timeoutChannel = new TimeoutChannel(innerChannel, {
|
|
382
|
+
timeout,
|
|
383
|
+
onTimeout: this.config?.onTimeout
|
|
384
|
+
});
|
|
91
385
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
const message = notification.toMail(notifiable);
|
|
97
|
-
await this.mailService.send(message);
|
|
386
|
+
timeoutChannel;
|
|
387
|
+
async send(notification, notifiable, options) {
|
|
388
|
+
return this.timeoutChannel.send(notification, notifiable, options);
|
|
98
389
|
}
|
|
99
390
|
};
|
|
100
391
|
|
|
101
392
|
// src/channels/SlackChannel.ts
|
|
393
|
+
var DEFAULT_TIMEOUT_MS4 = 3e4;
|
|
102
394
|
var SlackChannel = class {
|
|
103
395
|
constructor(config) {
|
|
104
396
|
this.config = config;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
397
|
+
const innerChannel = {
|
|
398
|
+
send: async (notification, notifiable, options) => {
|
|
399
|
+
if (!notification.toSlack) {
|
|
400
|
+
throw new Error("Notification does not implement toSlack method");
|
|
401
|
+
}
|
|
402
|
+
const slackMessage = notification.toSlack(notifiable);
|
|
403
|
+
const response = await fetch(this.config.webhookUrl, {
|
|
404
|
+
method: "POST",
|
|
405
|
+
headers: {
|
|
406
|
+
"Content-Type": "application/json"
|
|
407
|
+
},
|
|
408
|
+
body: JSON.stringify({
|
|
409
|
+
text: slackMessage.text,
|
|
410
|
+
channel: slackMessage.channel || this.config.defaultChannel,
|
|
411
|
+
username: slackMessage.username,
|
|
412
|
+
icon_emoji: slackMessage.iconEmoji,
|
|
413
|
+
attachments: slackMessage.attachments
|
|
414
|
+
}),
|
|
415
|
+
signal: options?.signal
|
|
416
|
+
// Pass AbortSignal to fetch
|
|
417
|
+
});
|
|
418
|
+
if (!response.ok) {
|
|
419
|
+
throw new Error(`Failed to send Slack notification: ${response.statusText}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
const timeout = this.config.timeout ?? DEFAULT_TIMEOUT_MS4;
|
|
424
|
+
this.timeoutChannel = new TimeoutChannel(innerChannel, {
|
|
425
|
+
timeout,
|
|
426
|
+
onTimeout: this.config.onTimeout
|
|
123
427
|
});
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
428
|
+
}
|
|
429
|
+
timeoutChannel;
|
|
430
|
+
async send(notification, notifiable, options) {
|
|
431
|
+
return this.timeoutChannel.send(notification, notifiable, options);
|
|
127
432
|
}
|
|
128
433
|
};
|
|
129
434
|
|
|
130
435
|
// src/channels/SmsChannel.ts
|
|
436
|
+
var DEFAULT_TIMEOUT_MS5 = 3e4;
|
|
131
437
|
var SmsChannel = class {
|
|
132
438
|
constructor(config) {
|
|
133
439
|
this.config = config;
|
|
440
|
+
const innerChannel = {
|
|
441
|
+
send: async (notification, notifiable, options) => {
|
|
442
|
+
if (!notification.toSms) {
|
|
443
|
+
throw new Error("Notification does not implement toSms method");
|
|
444
|
+
}
|
|
445
|
+
const smsMessage = notification.toSms(notifiable);
|
|
446
|
+
switch (this.config.provider) {
|
|
447
|
+
case "twilio":
|
|
448
|
+
await this.sendViaTwilio(smsMessage, options?.signal);
|
|
449
|
+
break;
|
|
450
|
+
case "aws-sns":
|
|
451
|
+
await this.sendViaAwsSns(smsMessage, options?.signal);
|
|
452
|
+
break;
|
|
453
|
+
default:
|
|
454
|
+
throw new Error(`Unsupported SMS provider: ${this.config.provider}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
const timeout = this.config.timeout ?? DEFAULT_TIMEOUT_MS5;
|
|
459
|
+
this.timeoutChannel = new TimeoutChannel(innerChannel, {
|
|
460
|
+
timeout,
|
|
461
|
+
onTimeout: this.config.onTimeout
|
|
462
|
+
});
|
|
134
463
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
const smsMessage = notification.toSms(notifiable);
|
|
140
|
-
switch (this.config.provider) {
|
|
141
|
-
case "twilio":
|
|
142
|
-
await this.sendViaTwilio(smsMessage);
|
|
143
|
-
break;
|
|
144
|
-
case "aws-sns":
|
|
145
|
-
await this.sendViaAwsSns(smsMessage);
|
|
146
|
-
break;
|
|
147
|
-
default:
|
|
148
|
-
throw new Error(`Unsupported SMS provider: ${this.config.provider}`);
|
|
149
|
-
}
|
|
464
|
+
timeoutChannel;
|
|
465
|
+
async send(notification, notifiable, options) {
|
|
466
|
+
return this.timeoutChannel.send(notification, notifiable, options);
|
|
150
467
|
}
|
|
151
468
|
/**
|
|
152
|
-
* Send SMS via Twilio.
|
|
469
|
+
* Send SMS via Twilio with AbortSignal support.
|
|
153
470
|
*/
|
|
154
|
-
async sendViaTwilio(message) {
|
|
471
|
+
async sendViaTwilio(message, signal) {
|
|
155
472
|
if (!this.config.apiKey || !this.config.apiSecret) {
|
|
156
473
|
throw new Error("Twilio API key and secret are required");
|
|
157
474
|
}
|
|
@@ -166,10 +483,12 @@ var SmsChannel = class {
|
|
|
166
483
|
"Content-Type": "application/x-www-form-urlencoded"
|
|
167
484
|
},
|
|
168
485
|
body: new URLSearchParams({
|
|
169
|
-
From: this.config.from || "",
|
|
486
|
+
From: message.from || this.config.from || "",
|
|
170
487
|
To: message.to,
|
|
171
488
|
Body: message.message
|
|
172
|
-
})
|
|
489
|
+
}),
|
|
490
|
+
signal
|
|
491
|
+
// Pass AbortSignal to fetch
|
|
173
492
|
}
|
|
174
493
|
);
|
|
175
494
|
if (!response.ok) {
|
|
@@ -178,9 +497,9 @@ var SmsChannel = class {
|
|
|
178
497
|
}
|
|
179
498
|
}
|
|
180
499
|
/**
|
|
181
|
-
* Send SMS via AWS SNS.
|
|
500
|
+
* Send SMS via AWS SNS with AbortSignal support.
|
|
182
501
|
*/
|
|
183
|
-
async sendViaAwsSns(message) {
|
|
502
|
+
async sendViaAwsSns(message, signal) {
|
|
184
503
|
let SNSClient;
|
|
185
504
|
let PublishCommand;
|
|
186
505
|
try {
|
|
@@ -216,7 +535,7 @@ var SmsChannel = class {
|
|
|
216
535
|
}
|
|
217
536
|
});
|
|
218
537
|
try {
|
|
219
|
-
await client.send(command);
|
|
538
|
+
await client.send(command, { abortSignal: signal });
|
|
220
539
|
} catch (error) {
|
|
221
540
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
222
541
|
throw new Error(`Failed to send SMS via AWS SNS: ${err.message}`);
|
|
@@ -317,6 +636,338 @@ var NotificationMetricsCollector = class {
|
|
|
317
636
|
}
|
|
318
637
|
};
|
|
319
638
|
|
|
639
|
+
// src/index.ts
|
|
640
|
+
init_PreferenceMiddleware();
|
|
641
|
+
|
|
642
|
+
// src/middleware/RateLimitMiddleware.ts
|
|
643
|
+
init_middleware();
|
|
644
|
+
|
|
645
|
+
// src/utils/TokenBucket.ts
|
|
646
|
+
var TokenBucket = class {
|
|
647
|
+
/**
|
|
648
|
+
* 創建一個新的 TokenBucket 實例
|
|
649
|
+
*
|
|
650
|
+
* @param capacity - 桶的最大容量(tokens 上限)
|
|
651
|
+
* @param refillRate - 每秒補充的 tokens 數量
|
|
652
|
+
*
|
|
653
|
+
* @example
|
|
654
|
+
* ```typescript
|
|
655
|
+
* // 每秒最多 100 個請求
|
|
656
|
+
* const bucket = new TokenBucket(100, 100)
|
|
657
|
+
* ```
|
|
658
|
+
*/
|
|
659
|
+
constructor(capacity, refillRate) {
|
|
660
|
+
this.capacity = capacity;
|
|
661
|
+
this.refillRate = refillRate;
|
|
662
|
+
this.tokens = capacity;
|
|
663
|
+
this.lastRefill = Date.now();
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* 當前可用的 tokens 數量
|
|
667
|
+
*/
|
|
668
|
+
tokens;
|
|
669
|
+
/**
|
|
670
|
+
* 上次補充 tokens 的時間戳(毫秒)
|
|
671
|
+
*/
|
|
672
|
+
lastRefill;
|
|
673
|
+
/**
|
|
674
|
+
* 嘗試從桶中消耗指定數量的 tokens
|
|
675
|
+
*
|
|
676
|
+
* 此方法會先執行 token 補充,然後檢查是否有足夠的 tokens。
|
|
677
|
+
* 如果有足夠的 tokens,則消耗並返回 true;否則返回 false 且不消耗。
|
|
678
|
+
*
|
|
679
|
+
* @param tokens - 要消耗的 tokens 數量(預設為 1)
|
|
680
|
+
* @returns 如果成功消耗則返回 true,否則返回 false
|
|
681
|
+
*
|
|
682
|
+
* @example
|
|
683
|
+
* ```typescript
|
|
684
|
+
* const bucket = new TokenBucket(10, 1)
|
|
685
|
+
*
|
|
686
|
+
* // 嘗試消耗 1 個 token
|
|
687
|
+
* if (bucket.tryConsume()) {
|
|
688
|
+
* console.log('請求被允許')
|
|
689
|
+
* }
|
|
690
|
+
*
|
|
691
|
+
* // 嘗試消耗 3 個 tokens
|
|
692
|
+
* if (bucket.tryConsume(3)) {
|
|
693
|
+
* console.log('批次請求被允許')
|
|
694
|
+
* }
|
|
695
|
+
* ```
|
|
696
|
+
*/
|
|
697
|
+
tryConsume(tokens = 1) {
|
|
698
|
+
if (tokens <= 0) {
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
this.refill();
|
|
702
|
+
if (this.tokens >= tokens) {
|
|
703
|
+
this.tokens -= tokens;
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* 獲取當前可用的 tokens 數量
|
|
710
|
+
*
|
|
711
|
+
* 此方法會先執行補充操作,然後返回當前的 tokens 數量。
|
|
712
|
+
*
|
|
713
|
+
* @returns 當前可用的 tokens 數量
|
|
714
|
+
*
|
|
715
|
+
* @example
|
|
716
|
+
* ```typescript
|
|
717
|
+
* const bucket = new TokenBucket(10, 1)
|
|
718
|
+
* console.log(`剩餘 ${bucket.getTokens()} 個 tokens`)
|
|
719
|
+
* ```
|
|
720
|
+
*/
|
|
721
|
+
getTokens() {
|
|
722
|
+
this.refill();
|
|
723
|
+
return this.tokens;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* 根據經過的時間補充 tokens
|
|
727
|
+
*
|
|
728
|
+
* 此方法計算自上次補充以來經過的時間,並根據 refillRate 補充相應數量的 tokens。
|
|
729
|
+
* tokens 數量不會超過容量上限。
|
|
730
|
+
*
|
|
731
|
+
* @private
|
|
732
|
+
*/
|
|
733
|
+
refill() {
|
|
734
|
+
const now = Date.now();
|
|
735
|
+
const elapsed = now - this.lastRefill;
|
|
736
|
+
if (elapsed <= 0 || this.refillRate <= 0) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const tokensToAdd = elapsed / 1e3 * this.refillRate;
|
|
740
|
+
if (tokensToAdd > 0) {
|
|
741
|
+
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
|
|
742
|
+
this.lastRefill = now;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
// src/middleware/RateLimitMiddleware.ts
|
|
748
|
+
var SECONDS_PER_MINUTE = 60;
|
|
749
|
+
var SECONDS_PER_HOUR = 3600;
|
|
750
|
+
var DEFAULT_CLEANUP_INTERVAL_MS = 6e4;
|
|
751
|
+
var MemoryStore = class {
|
|
752
|
+
cache = /* @__PURE__ */ new Map();
|
|
753
|
+
cleanupInterval;
|
|
754
|
+
constructor(cleanupIntervalMs = DEFAULT_CLEANUP_INTERVAL_MS) {
|
|
755
|
+
this.cleanupInterval = setInterval(() => {
|
|
756
|
+
const now = Date.now();
|
|
757
|
+
for (const [key, item] of this.cache.entries()) {
|
|
758
|
+
if (now > item.expiry) {
|
|
759
|
+
this.cache.delete(key);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}, cleanupIntervalMs);
|
|
763
|
+
}
|
|
764
|
+
async get(key) {
|
|
765
|
+
const item = this.cache.get(key);
|
|
766
|
+
if (!item) {
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
if (Date.now() > item.expiry) {
|
|
770
|
+
this.cache.delete(key);
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
return item.value;
|
|
774
|
+
}
|
|
775
|
+
async put(key, value, ttl) {
|
|
776
|
+
this.cache.set(key, {
|
|
777
|
+
value,
|
|
778
|
+
expiry: Date.now() + ttl * 1e3
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
async forget(key) {
|
|
782
|
+
this.cache.delete(key);
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* 清理所有資源,停止清理計時器
|
|
786
|
+
*/
|
|
787
|
+
destroy() {
|
|
788
|
+
if (this.cleanupInterval) {
|
|
789
|
+
clearInterval(this.cleanupInterval);
|
|
790
|
+
this.cleanupInterval = void 0;
|
|
791
|
+
}
|
|
792
|
+
this.cache.clear();
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
var RateLimitMiddleware = class {
|
|
796
|
+
/**
|
|
797
|
+
* Create a new RateLimitMiddleware instance.
|
|
798
|
+
*
|
|
799
|
+
* @param config - Rate limit configuration for each channel
|
|
800
|
+
* @param store - Optional cache store for distributed rate limiting
|
|
801
|
+
*
|
|
802
|
+
* @example
|
|
803
|
+
* ```typescript
|
|
804
|
+
* // 使用預設記憶體儲存
|
|
805
|
+
* const middleware = new RateLimitMiddleware({
|
|
806
|
+
* email: { maxPerSecond: 10 }
|
|
807
|
+
* })
|
|
808
|
+
*
|
|
809
|
+
* // 使用 Redis 儲存(分散式環境)
|
|
810
|
+
* const middleware = new RateLimitMiddleware({
|
|
811
|
+
* email: { maxPerSecond: 10 }
|
|
812
|
+
* }, redisStore)
|
|
813
|
+
* ```
|
|
814
|
+
*/
|
|
815
|
+
constructor(config, store) {
|
|
816
|
+
this.config = config;
|
|
817
|
+
this.store = store ?? new MemoryStore();
|
|
818
|
+
this.initializeBuckets();
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Middleware name.
|
|
822
|
+
*/
|
|
823
|
+
name = "rate-limit";
|
|
824
|
+
/**
|
|
825
|
+
* Middleware priority (high priority, executes early in the chain).
|
|
826
|
+
* 中介層優先級(高優先級,在鏈中較早執行)
|
|
827
|
+
*/
|
|
828
|
+
priority = MiddlewarePriority.RATE_LIMIT;
|
|
829
|
+
/**
|
|
830
|
+
* Token buckets for each channel and time window.
|
|
831
|
+
* Key format: `{channel}:{window}` (e.g., 'email:second', 'sms:minute')
|
|
832
|
+
*/
|
|
833
|
+
buckets = /* @__PURE__ */ new Map();
|
|
834
|
+
/**
|
|
835
|
+
* Cache store for distributed rate limiting.
|
|
836
|
+
* 分散式限流使用的快取儲存
|
|
837
|
+
*/
|
|
838
|
+
store;
|
|
839
|
+
/**
|
|
840
|
+
* Initialize token buckets for all configured channels.
|
|
841
|
+
*
|
|
842
|
+
* 為所有配置的通道初始化 Token Bucket。
|
|
843
|
+
*
|
|
844
|
+
* @private
|
|
845
|
+
*/
|
|
846
|
+
initializeBuckets() {
|
|
847
|
+
for (const [channel, limits] of Object.entries(this.config)) {
|
|
848
|
+
if (limits.maxPerSecond) {
|
|
849
|
+
const key = `${channel}:second`;
|
|
850
|
+
this.buckets.set(key, new TokenBucket(limits.maxPerSecond, limits.maxPerSecond));
|
|
851
|
+
}
|
|
852
|
+
if (limits.maxPerMinute) {
|
|
853
|
+
const key = `${channel}:minute`;
|
|
854
|
+
this.buckets.set(
|
|
855
|
+
key,
|
|
856
|
+
new TokenBucket(limits.maxPerMinute, limits.maxPerMinute / SECONDS_PER_MINUTE)
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
if (limits.maxPerHour) {
|
|
860
|
+
const key = `${channel}:hour`;
|
|
861
|
+
this.buckets.set(
|
|
862
|
+
key,
|
|
863
|
+
new TokenBucket(limits.maxPerHour, limits.maxPerHour / SECONDS_PER_HOUR)
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Handle the notification and apply rate limiting.
|
|
870
|
+
*
|
|
871
|
+
* 處理通知並應用限流規則。如果超過任一時間窗口的限制,
|
|
872
|
+
* 將拋出錯誤阻止通知發送。
|
|
873
|
+
*
|
|
874
|
+
* @param notification - The notification being sent
|
|
875
|
+
* @param notifiable - The recipient
|
|
876
|
+
* @param channel - The channel name
|
|
877
|
+
* @param next - Continue to the next middleware or send the notification
|
|
878
|
+
*
|
|
879
|
+
* @throws {Error} 當超過限流時拋出錯誤
|
|
880
|
+
*/
|
|
881
|
+
async handle(_notification, _notifiable, channel, next) {
|
|
882
|
+
const channelConfig = this.config[channel];
|
|
883
|
+
if (!channelConfig) {
|
|
884
|
+
await next();
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
const windows = ["second", "minute", "hour"];
|
|
888
|
+
for (const window of windows) {
|
|
889
|
+
const key = `${channel}:${window}`;
|
|
890
|
+
const bucket = this.buckets.get(key);
|
|
891
|
+
if (bucket) {
|
|
892
|
+
const allowed = bucket.tryConsume();
|
|
893
|
+
if (!allowed) {
|
|
894
|
+
throw new Error(
|
|
895
|
+
`Rate limit exceeded for channel '${channel}' (${window}ly limit). Please try again later.`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
await next();
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Get current rate limit status for a channel.
|
|
904
|
+
*
|
|
905
|
+
* 獲取指定通道的當前限流狀態(用於除錯和監控)。
|
|
906
|
+
*
|
|
907
|
+
* @param channel - The channel name
|
|
908
|
+
* @returns Current token counts for each time window
|
|
909
|
+
*
|
|
910
|
+
* @example
|
|
911
|
+
* ```typescript
|
|
912
|
+
* const status = middleware.getStatus('email')
|
|
913
|
+
* console.log(`Email remaining: ${status.second}/${config.email.maxPerSecond}`)
|
|
914
|
+
* ```
|
|
915
|
+
*/
|
|
916
|
+
getStatus(channel) {
|
|
917
|
+
const status = {};
|
|
918
|
+
const secondBucket = this.buckets.get(`${channel}:second`);
|
|
919
|
+
if (secondBucket) {
|
|
920
|
+
status.second = secondBucket.getTokens();
|
|
921
|
+
}
|
|
922
|
+
const minuteBucket = this.buckets.get(`${channel}:minute`);
|
|
923
|
+
if (minuteBucket) {
|
|
924
|
+
status.minute = minuteBucket.getTokens();
|
|
925
|
+
}
|
|
926
|
+
const hourBucket = this.buckets.get(`${channel}:hour`);
|
|
927
|
+
if (hourBucket) {
|
|
928
|
+
status.hour = hourBucket.getTokens();
|
|
929
|
+
}
|
|
930
|
+
return status;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Reset rate limit for a specific channel.
|
|
934
|
+
*
|
|
935
|
+
* 重置指定通道的限流計數(用於測試或手動重置)。
|
|
936
|
+
*
|
|
937
|
+
* @param channel - The channel name to reset
|
|
938
|
+
*
|
|
939
|
+
* @example
|
|
940
|
+
* ```typescript
|
|
941
|
+
* // 在測試中重置限流
|
|
942
|
+
* middleware.reset('email')
|
|
943
|
+
* ```
|
|
944
|
+
*/
|
|
945
|
+
reset(channel) {
|
|
946
|
+
const channelConfig = this.config[channel];
|
|
947
|
+
if (!channelConfig) {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
if (channelConfig.maxPerSecond) {
|
|
951
|
+
const key = `${channel}:second`;
|
|
952
|
+
this.buckets.set(key, new TokenBucket(channelConfig.maxPerSecond, channelConfig.maxPerSecond));
|
|
953
|
+
}
|
|
954
|
+
if (channelConfig.maxPerMinute) {
|
|
955
|
+
const key = `${channel}:minute`;
|
|
956
|
+
this.buckets.set(
|
|
957
|
+
key,
|
|
958
|
+
new TokenBucket(channelConfig.maxPerMinute, channelConfig.maxPerMinute / SECONDS_PER_MINUTE)
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
if (channelConfig.maxPerHour) {
|
|
962
|
+
const key = `${channel}:hour`;
|
|
963
|
+
this.buckets.set(
|
|
964
|
+
key,
|
|
965
|
+
new TokenBucket(channelConfig.maxPerHour, channelConfig.maxPerHour / SECONDS_PER_HOUR)
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
|
|
320
971
|
// src/Notification.ts
|
|
321
972
|
var Notification = class {
|
|
322
973
|
/**
|
|
@@ -341,6 +992,15 @@ var Notification = class {
|
|
|
341
992
|
}
|
|
342
993
|
};
|
|
343
994
|
|
|
995
|
+
// src/utils/hookEmitter.ts
|
|
996
|
+
function createHookEmitter(core) {
|
|
997
|
+
return {
|
|
998
|
+
emit: async (event, payload) => {
|
|
999
|
+
await core.hooks.emit(event, payload);
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
344
1004
|
// src/utils/retry.ts
|
|
345
1005
|
var DEFAULT_RETRY_OPTIONS = {
|
|
346
1006
|
maxAttempts: 3,
|
|
@@ -435,26 +1095,140 @@ function deepSerialize(obj, seen = /* @__PURE__ */ new WeakSet()) {
|
|
|
435
1095
|
}
|
|
436
1096
|
const result = {};
|
|
437
1097
|
for (const [key, value] of Object.entries(obj)) {
|
|
438
|
-
|
|
1098
|
+
const isSpecialProperty = key.startsWith("__");
|
|
1099
|
+
const isPrivateProperty = key.startsWith("_") && !isSpecialProperty;
|
|
1100
|
+
if (!isPrivateProperty && typeof value !== "function") {
|
|
439
1101
|
result[key] = deepSerialize(value, seen);
|
|
440
1102
|
}
|
|
441
1103
|
}
|
|
442
1104
|
return result;
|
|
443
1105
|
}
|
|
1106
|
+
function deepDeserialize(obj) {
|
|
1107
|
+
if (obj === null || typeof obj !== "object") {
|
|
1108
|
+
return obj;
|
|
1109
|
+
}
|
|
1110
|
+
if ("__type" in obj) {
|
|
1111
|
+
const typed = obj;
|
|
1112
|
+
switch (typed.__type) {
|
|
1113
|
+
case "Date":
|
|
1114
|
+
return new Date(typed.value);
|
|
1115
|
+
case "Map":
|
|
1116
|
+
return new Map(
|
|
1117
|
+
typed.value.map(([k, v]) => [
|
|
1118
|
+
deepDeserialize(k),
|
|
1119
|
+
deepDeserialize(v)
|
|
1120
|
+
])
|
|
1121
|
+
);
|
|
1122
|
+
case "Set":
|
|
1123
|
+
return new Set(typed.value.map((v) => deepDeserialize(v)));
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
if (Array.isArray(obj)) {
|
|
1127
|
+
return obj.map((item) => deepDeserialize(item));
|
|
1128
|
+
}
|
|
1129
|
+
const result = {};
|
|
1130
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1131
|
+
result[key] = deepDeserialize(value);
|
|
1132
|
+
}
|
|
1133
|
+
return result;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// src/utils/serializationGuard.ts
|
|
1137
|
+
function checkSerializable(obj, path = "") {
|
|
1138
|
+
const problematicPaths = [];
|
|
1139
|
+
const warnings = [];
|
|
1140
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
1141
|
+
function check(value, currentPath) {
|
|
1142
|
+
if (value === null || value === void 0) {
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (typeof value !== "object" && typeof value !== "function" && typeof value !== "symbol") {
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (typeof value === "symbol") {
|
|
1149
|
+
problematicPaths.push(currentPath);
|
|
1150
|
+
warnings.push(`\u767C\u73FE\u4E0D\u53EF\u5E8F\u5217\u5316\u7684 Symbol \u65BC\u8DEF\u5F91: ${currentPath}`);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
if (typeof value === "function") {
|
|
1154
|
+
problematicPaths.push(currentPath);
|
|
1155
|
+
warnings.push(`\u767C\u73FE\u4E0D\u53EF\u5E8F\u5217\u5316\u7684 Function \u65BC\u8DEF\u5F91: ${currentPath}`);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
if (seen.has(value)) {
|
|
1159
|
+
problematicPaths.push(currentPath);
|
|
1160
|
+
warnings.push(`\u767C\u73FE\u5FAA\u74B0\u5F15\u7528\u65BC\u8DEF\u5F91: ${currentPath}`);
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
seen.add(value);
|
|
1164
|
+
if (value instanceof Date || value instanceof Map || value instanceof Set || value instanceof RegExp || value instanceof Error) {
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (value instanceof Promise || value instanceof WeakMap || value instanceof WeakSet || value instanceof ArrayBuffer || value instanceof DataView || typeof Buffer !== "undefined" && value instanceof Buffer) {
|
|
1168
|
+
problematicPaths.push(currentPath);
|
|
1169
|
+
warnings.push(`\u767C\u73FE\u4E0D\u53EF\u5E8F\u5217\u5316\u7684 ${value.constructor.name} \u65BC\u8DEF\u5F91: ${currentPath}`);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
if (Array.isArray(value)) {
|
|
1173
|
+
value.forEach((item, index) => {
|
|
1174
|
+
const itemPath = currentPath ? `${currentPath}[${index}]` : `[${index}]`;
|
|
1175
|
+
check(item, itemPath);
|
|
1176
|
+
});
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
for (const [key, val] of Object.entries(value)) {
|
|
1180
|
+
if (key.startsWith("_")) {
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
const newPath = currentPath ? `${currentPath}.${key}` : key;
|
|
1184
|
+
check(val, newPath);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
check(obj, path);
|
|
1188
|
+
return {
|
|
1189
|
+
serializable: problematicPaths.length === 0,
|
|
1190
|
+
problematicPaths,
|
|
1191
|
+
warnings
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
function assertSerializable(obj) {
|
|
1195
|
+
const result = checkSerializable(obj);
|
|
1196
|
+
if (!result.serializable) {
|
|
1197
|
+
throw new Error(
|
|
1198
|
+
`\u7269\u4EF6\u5305\u542B\u4E0D\u53EF\u5E8F\u5217\u5316\u7684\u5C6C\u6027:
|
|
1199
|
+
\u554F\u984C\u8DEF\u5F91: ${result.problematicPaths.join(", ")}
|
|
1200
|
+
\u8A73\u7D30\u8CC7\u8A0A:
|
|
1201
|
+
${result.warnings.join("\n")}`
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
444
1205
|
|
|
445
1206
|
// src/NotificationManager.ts
|
|
446
1207
|
var NotificationManager = class {
|
|
447
1208
|
constructor(core) {
|
|
448
1209
|
this.core = core;
|
|
1210
|
+
this.hookEmitter = createHookEmitter(core);
|
|
449
1211
|
}
|
|
450
1212
|
/**
|
|
451
1213
|
* Channel registry.
|
|
452
1214
|
*/
|
|
453
1215
|
channels = /* @__PURE__ */ new Map();
|
|
1216
|
+
/**
|
|
1217
|
+
* Middleware stack for intercepting channel sends.
|
|
1218
|
+
*/
|
|
1219
|
+
middlewares = [];
|
|
1220
|
+
/**
|
|
1221
|
+
* Indicates whether the middleware stack needs re-sorting.
|
|
1222
|
+
*/
|
|
1223
|
+
middlewaresDirty = false;
|
|
454
1224
|
/**
|
|
455
1225
|
* Queue manager (optional, injected by `orbit-queue`).
|
|
456
1226
|
*/
|
|
457
1227
|
queueManager;
|
|
1228
|
+
/**
|
|
1229
|
+
* Type-safe hook emitter for notification events.
|
|
1230
|
+
*/
|
|
1231
|
+
hookEmitter;
|
|
458
1232
|
metrics;
|
|
459
1233
|
/**
|
|
460
1234
|
* Enable metrics collection.
|
|
@@ -483,6 +1257,30 @@ var NotificationManager = class {
|
|
|
483
1257
|
channel(name, channel) {
|
|
484
1258
|
this.channels.set(name, channel);
|
|
485
1259
|
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Register a middleware for intercepting channel sends.
|
|
1262
|
+
*
|
|
1263
|
+
* Middleware will be executed in the order they are registered.
|
|
1264
|
+
* Each middleware can modify, block, or monitor the notification flow.
|
|
1265
|
+
*
|
|
1266
|
+
* @param middleware - The middleware instance to register.
|
|
1267
|
+
*
|
|
1268
|
+
* @example
|
|
1269
|
+
* ```typescript
|
|
1270
|
+
* import { RateLimitMiddleware } from '@gravito/flare'
|
|
1271
|
+
*
|
|
1272
|
+
* const rateLimiter = new RateLimitMiddleware({
|
|
1273
|
+
* email: { maxPerSecond: 10 },
|
|
1274
|
+
* sms: { maxPerSecond: 5 }
|
|
1275
|
+
* })
|
|
1276
|
+
*
|
|
1277
|
+
* manager.use(rateLimiter)
|
|
1278
|
+
* ```
|
|
1279
|
+
*/
|
|
1280
|
+
use(middleware) {
|
|
1281
|
+
this.middlewares.push(middleware);
|
|
1282
|
+
this.middlewaresDirty = true;
|
|
1283
|
+
}
|
|
486
1284
|
/**
|
|
487
1285
|
* Register the queue manager (called by `orbit-queue`).
|
|
488
1286
|
*
|
|
@@ -508,13 +1306,13 @@ var NotificationManager = class {
|
|
|
508
1306
|
async send(notifiable, notification, options = {}) {
|
|
509
1307
|
const channels = notification.via(notifiable);
|
|
510
1308
|
const startTime = Date.now();
|
|
511
|
-
await this.
|
|
1309
|
+
await this.hookEmitter.emit("notification:sending", {
|
|
512
1310
|
notification,
|
|
513
1311
|
notifiable,
|
|
514
1312
|
channels
|
|
515
1313
|
});
|
|
516
1314
|
if (notification.shouldQueue() && this.queueManager) {
|
|
517
|
-
await this.
|
|
1315
|
+
await this.hookEmitter.emit("notification:queued", {
|
|
518
1316
|
notification,
|
|
519
1317
|
notifiable,
|
|
520
1318
|
channels
|
|
@@ -526,7 +1324,15 @@ var NotificationManager = class {
|
|
|
526
1324
|
notifiableId: notifiable.getNotifiableId(),
|
|
527
1325
|
notifiableType: notifiable.getNotifiableType?.() || "user",
|
|
528
1326
|
channels,
|
|
529
|
-
notificationData:
|
|
1327
|
+
notificationData: (() => {
|
|
1328
|
+
const checkResult = checkSerializable(notification);
|
|
1329
|
+
if (!checkResult.serializable) {
|
|
1330
|
+
this.core.logger.warn(
|
|
1331
|
+
`[NotificationManager] Notification '${notification.constructor.name}' contains non-serializable properties. These will be filtered out during queuing. Problematic paths: ${checkResult.problematicPaths.join(", ")}. Advice: Use LazyNotification or keep only IDs/pure data in the constructor.`
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
return this.serializeNotification(notification);
|
|
1335
|
+
})(),
|
|
530
1336
|
handle: async () => {
|
|
531
1337
|
await this.sendNow(notifiable, notification, channels);
|
|
532
1338
|
}
|
|
@@ -547,7 +1353,7 @@ var NotificationManager = class {
|
|
|
547
1353
|
}
|
|
548
1354
|
const results = await this.sendNow(notifiable, notification, channels, options);
|
|
549
1355
|
const totalDuration = Date.now() - startTime;
|
|
550
|
-
await this.
|
|
1356
|
+
await this.hookEmitter.emit("notification:sent", {
|
|
551
1357
|
notification,
|
|
552
1358
|
notifiable,
|
|
553
1359
|
results,
|
|
@@ -580,7 +1386,7 @@ var NotificationManager = class {
|
|
|
580
1386
|
const { batchConcurrency = 10 } = options;
|
|
581
1387
|
const startTime = Date.now();
|
|
582
1388
|
const results = [];
|
|
583
|
-
await this.
|
|
1389
|
+
await this.hookEmitter.emit("notification:batch:start", {
|
|
584
1390
|
notification,
|
|
585
1391
|
count: notifiables.length
|
|
586
1392
|
});
|
|
@@ -592,7 +1398,7 @@ var NotificationManager = class {
|
|
|
592
1398
|
}
|
|
593
1399
|
const duration = Date.now() - startTime;
|
|
594
1400
|
const successCount = results.filter((r) => r.allSuccess).length;
|
|
595
|
-
await this.
|
|
1401
|
+
await this.hookEmitter.emit("notification:batch:complete", {
|
|
596
1402
|
notification,
|
|
597
1403
|
total: notifiables.length,
|
|
598
1404
|
success: successCount,
|
|
@@ -710,7 +1516,7 @@ var NotificationManager = class {
|
|
|
710
1516
|
`[NotificationManager] Channel '${channelName}' failed, retrying (${attempt}/${retry.maxAttempts}) in ${delay}ms`,
|
|
711
1517
|
error
|
|
712
1518
|
);
|
|
713
|
-
this.
|
|
1519
|
+
this.hookEmitter.emit("notification:channel:retry", {
|
|
714
1520
|
notification,
|
|
715
1521
|
notifiable,
|
|
716
1522
|
channel: channelName,
|
|
@@ -742,7 +1548,7 @@ var NotificationManager = class {
|
|
|
742
1548
|
} catch (error) {
|
|
743
1549
|
const duration = Date.now() - startTime;
|
|
744
1550
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
745
|
-
await this.
|
|
1551
|
+
await this.hookEmitter.emit("notification:channel:failed", {
|
|
746
1552
|
notification,
|
|
747
1553
|
notifiable,
|
|
748
1554
|
channel: channelName,
|
|
@@ -772,20 +1578,76 @@ var NotificationManager = class {
|
|
|
772
1578
|
}
|
|
773
1579
|
}
|
|
774
1580
|
async executeChannelSend(channel, notification, notifiable, channelName) {
|
|
775
|
-
await this.
|
|
1581
|
+
await this.hookEmitter.emit("notification:channel:sending", {
|
|
776
1582
|
notification,
|
|
777
1583
|
notifiable,
|
|
778
1584
|
channel: channelName
|
|
779
1585
|
});
|
|
780
|
-
|
|
1586
|
+
const executeWithMiddleware = async () => {
|
|
1587
|
+
await this.executeMiddlewareChain(0, notification, notifiable, channelName, async () => {
|
|
1588
|
+
await channel.send(notification, notifiable);
|
|
1589
|
+
});
|
|
1590
|
+
};
|
|
1591
|
+
await executeWithMiddleware();
|
|
781
1592
|
const duration = 0;
|
|
782
|
-
await this.
|
|
1593
|
+
await this.hookEmitter.emit("notification:channel:sent", {
|
|
783
1594
|
notification,
|
|
784
1595
|
notifiable,
|
|
785
1596
|
channel: channelName,
|
|
786
1597
|
duration
|
|
787
1598
|
});
|
|
788
1599
|
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Retrieves the sorted list of middleware (Lazy sorting).
|
|
1602
|
+
*
|
|
1603
|
+
* Uses a lazy evaluation strategy: sorting only happens when needed to avoid
|
|
1604
|
+
* overhead on every `use()` call.
|
|
1605
|
+
* Sorting rules:
|
|
1606
|
+
* 1. Higher priority (larger number) executes first.
|
|
1607
|
+
* 2. Stable sort is maintained for identical priorities (registration order).
|
|
1608
|
+
*
|
|
1609
|
+
* @returns The sorted list of middleware.
|
|
1610
|
+
* @private
|
|
1611
|
+
*/
|
|
1612
|
+
getSortedMiddlewares() {
|
|
1613
|
+
if (!this.middlewaresDirty) {
|
|
1614
|
+
return this.middlewares;
|
|
1615
|
+
}
|
|
1616
|
+
this.middlewares.sort((a, b) => {
|
|
1617
|
+
const priorityA = a.priority ?? 0;
|
|
1618
|
+
const priorityB = b.priority ?? 0;
|
|
1619
|
+
return priorityB - priorityA;
|
|
1620
|
+
});
|
|
1621
|
+
this.middlewaresDirty = false;
|
|
1622
|
+
return this.middlewares;
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Execute middleware chain recursively.
|
|
1626
|
+
*
|
|
1627
|
+
* @param index - Current middleware index
|
|
1628
|
+
* @param notification - The notification being sent
|
|
1629
|
+
* @param notifiable - The recipient
|
|
1630
|
+
* @param channelName - The channel name
|
|
1631
|
+
* @param finalHandler - The final handler to execute (actual channel.send)
|
|
1632
|
+
* @private
|
|
1633
|
+
*/
|
|
1634
|
+
async executeMiddlewareChain(index, notification, notifiable, channelName, finalHandler) {
|
|
1635
|
+
const sortedMiddlewares = this.getSortedMiddlewares();
|
|
1636
|
+
if (index >= sortedMiddlewares.length) {
|
|
1637
|
+
await finalHandler();
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
const middleware = sortedMiddlewares[index];
|
|
1641
|
+
await middleware.handle(notification, notifiable, channelName, async () => {
|
|
1642
|
+
await this.executeMiddlewareChain(
|
|
1643
|
+
index + 1,
|
|
1644
|
+
notification,
|
|
1645
|
+
notifiable,
|
|
1646
|
+
channelName,
|
|
1647
|
+
finalHandler
|
|
1648
|
+
);
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
789
1651
|
getRetryConfig(notification, options) {
|
|
790
1652
|
const notificationRetry = notification.retry;
|
|
791
1653
|
if (options === false || notificationRetry === void 0) {
|
|
@@ -904,6 +1766,7 @@ var OrbitFlare = class _OrbitFlare {
|
|
|
904
1766
|
if (this.options.enableSms) {
|
|
905
1767
|
this.setupSmsChannel(core, manager);
|
|
906
1768
|
}
|
|
1769
|
+
this.setupMiddleware(core, manager);
|
|
907
1770
|
core.container.instance("notifications", manager);
|
|
908
1771
|
this.setupQueueIntegration(core, manager);
|
|
909
1772
|
core.logger.info("[OrbitFlare] Installed");
|
|
@@ -918,8 +1781,9 @@ var OrbitFlare = class _OrbitFlare {
|
|
|
918
1781
|
}
|
|
919
1782
|
setupDatabaseChannel(core, manager) {
|
|
920
1783
|
const db = core.container.make("db");
|
|
1784
|
+
const config = this.options.channels?.database;
|
|
921
1785
|
if (db && this.isDatabaseService(db)) {
|
|
922
|
-
manager.channel("database", new DatabaseChannel(db));
|
|
1786
|
+
manager.channel("database", new DatabaseChannel(db, config));
|
|
923
1787
|
} else {
|
|
924
1788
|
core.logger.warn(
|
|
925
1789
|
"[OrbitFlare] Database service not found or invalid, database channel disabled"
|
|
@@ -928,25 +1792,20 @@ var OrbitFlare = class _OrbitFlare {
|
|
|
928
1792
|
}
|
|
929
1793
|
setupBroadcastChannel(core, manager) {
|
|
930
1794
|
const broadcast = core.container.make("broadcast");
|
|
1795
|
+
const config = this.options.channels?.broadcast;
|
|
931
1796
|
if (broadcast && this.isBroadcastService(broadcast)) {
|
|
932
|
-
manager.channel("broadcast", new BroadcastChannel(broadcast));
|
|
1797
|
+
manager.channel("broadcast", new BroadcastChannel(broadcast, config));
|
|
933
1798
|
} else {
|
|
934
1799
|
core.logger.warn(
|
|
935
1800
|
"[OrbitFlare] Broadcast service not found or invalid, broadcast channel disabled"
|
|
936
1801
|
);
|
|
937
1802
|
}
|
|
938
1803
|
}
|
|
939
|
-
setupSlackChannel(
|
|
1804
|
+
setupSlackChannel(_core, manager) {
|
|
940
1805
|
const slack = this.options.channels?.slack;
|
|
941
1806
|
if (slack) {
|
|
942
1807
|
manager.channel("slack", new SlackChannel(slack));
|
|
943
1808
|
}
|
|
944
|
-
if (this.options.enableSms) {
|
|
945
|
-
const sms = this.options.channels?.sms;
|
|
946
|
-
if (sms) {
|
|
947
|
-
manager.channel("sms", new SmsChannel(sms));
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
1809
|
}
|
|
951
1810
|
setupSmsChannel(core, manager) {
|
|
952
1811
|
const sms = this.options.channels?.sms;
|
|
@@ -966,6 +1825,19 @@ var OrbitFlare = class _OrbitFlare {
|
|
|
966
1825
|
});
|
|
967
1826
|
}
|
|
968
1827
|
}
|
|
1828
|
+
setupMiddleware(core, manager) {
|
|
1829
|
+
if (this.options.middleware) {
|
|
1830
|
+
for (const middleware of this.options.middleware) {
|
|
1831
|
+
manager.use(middleware);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
if (this.options.enablePreference) {
|
|
1835
|
+
const { PreferenceMiddleware: PreferenceMiddleware2 } = (init_PreferenceMiddleware(), __toCommonJS(PreferenceMiddleware_exports));
|
|
1836
|
+
const preferenceMiddleware = new PreferenceMiddleware2(this.options.preferenceProvider);
|
|
1837
|
+
manager.use(preferenceMiddleware);
|
|
1838
|
+
core.logger.info("[OrbitFlare] Preference middleware enabled");
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
969
1841
|
isMailService(service) {
|
|
970
1842
|
return typeof service === "object" && service !== null && "send" in service && typeof service.send === "function";
|
|
971
1843
|
}
|
|
@@ -1019,17 +1891,84 @@ var TemplatedNotification = class extends Notification {
|
|
|
1019
1891
|
throw new Error("Notifiable does not have an email property");
|
|
1020
1892
|
}
|
|
1021
1893
|
};
|
|
1894
|
+
|
|
1895
|
+
// src/index.ts
|
|
1896
|
+
init_middleware();
|
|
1897
|
+
|
|
1898
|
+
// src/utils/LazyNotification.ts
|
|
1899
|
+
var LazyNotification = class extends Notification {
|
|
1900
|
+
/**
|
|
1901
|
+
* 快取的資料
|
|
1902
|
+
*/
|
|
1903
|
+
_cachedData;
|
|
1904
|
+
/**
|
|
1905
|
+
* 取得快取的資料
|
|
1906
|
+
*
|
|
1907
|
+
* @returns 快取的資料,如果尚未載入則回傳 undefined
|
|
1908
|
+
*/
|
|
1909
|
+
getCached() {
|
|
1910
|
+
return this._cachedData;
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* 設定快取資料
|
|
1914
|
+
*
|
|
1915
|
+
* @param data - 要快取的資料
|
|
1916
|
+
*/
|
|
1917
|
+
setCached(data) {
|
|
1918
|
+
this._cachedData = data;
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* 檢查資料是否已載入
|
|
1922
|
+
*
|
|
1923
|
+
* @returns 如果資料已快取則回傳 true
|
|
1924
|
+
*/
|
|
1925
|
+
isLoaded() {
|
|
1926
|
+
return this._cachedData !== void 0;
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1929
|
+
* 清除快取
|
|
1930
|
+
*/
|
|
1931
|
+
clearCache() {
|
|
1932
|
+
this._cachedData = void 0;
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* 載入資料並快取(如果尚未載入)
|
|
1936
|
+
*
|
|
1937
|
+
* @param notifiable - 通知接收者
|
|
1938
|
+
* @returns 載入的資料
|
|
1939
|
+
*/
|
|
1940
|
+
async ensureLoaded(notifiable) {
|
|
1941
|
+
if (this._cachedData === void 0) {
|
|
1942
|
+
this._cachedData = await this.loadData(notifiable);
|
|
1943
|
+
}
|
|
1944
|
+
return this._cachedData;
|
|
1945
|
+
}
|
|
1946
|
+
};
|
|
1022
1947
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1023
1948
|
0 && (module.exports = {
|
|
1949
|
+
AbortError,
|
|
1024
1950
|
BroadcastChannel,
|
|
1025
1951
|
DatabaseChannel,
|
|
1952
|
+
LazyNotification,
|
|
1026
1953
|
MailChannel,
|
|
1954
|
+
MemoryStore,
|
|
1955
|
+
MiddlewarePriority,
|
|
1027
1956
|
Notification,
|
|
1028
1957
|
NotificationManager,
|
|
1029
1958
|
NotificationMetricsCollector,
|
|
1030
1959
|
OrbitFlare,
|
|
1960
|
+
PreferenceMiddleware,
|
|
1961
|
+
RateLimitMiddleware,
|
|
1031
1962
|
SlackChannel,
|
|
1032
1963
|
SmsChannel,
|
|
1033
1964
|
TemplatedNotification,
|
|
1965
|
+
TimeoutChannel,
|
|
1966
|
+
TimeoutError,
|
|
1967
|
+
TokenBucket,
|
|
1968
|
+
assertSerializable,
|
|
1969
|
+
checkSerializable,
|
|
1970
|
+
createHookEmitter,
|
|
1971
|
+
deepDeserialize,
|
|
1972
|
+
deepSerialize,
|
|
1034
1973
|
toPrometheusFormat
|
|
1035
1974
|
});
|