@upyo/pool 0.3.0-dev.36
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/LICENSE +20 -0
- package/README.md +389 -0
- package/dist/index.cjs +401 -0
- package/dist/index.d.cts +363 -0
- package/dist/index.d.ts +363 -0
- package/dist/index.js +396 -0
- package/package.json +74 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/config.ts
|
|
3
|
+
/**
|
|
4
|
+
* Creates a resolved pool configuration with defaults applied.
|
|
5
|
+
*
|
|
6
|
+
* @param config The pool configuration.
|
|
7
|
+
* @returns The resolved configuration with defaults.
|
|
8
|
+
* @throws {Error} If the configuration is invalid.
|
|
9
|
+
* @since 0.3.0
|
|
10
|
+
*/
|
|
11
|
+
function createPoolConfig(config) {
|
|
12
|
+
if (!config.transports || config.transports.length === 0) throw new Error("Pool must have at least one transport");
|
|
13
|
+
const enabledTransports = config.transports.filter((entry) => entry.enabled !== false);
|
|
14
|
+
if (enabledTransports.length === 0) throw new Error("Pool must have at least one enabled transport");
|
|
15
|
+
const resolvedTransports = config.transports.map((entry) => ({
|
|
16
|
+
transport: entry.transport,
|
|
17
|
+
weight: entry.weight ?? 1,
|
|
18
|
+
priority: entry.priority ?? 0,
|
|
19
|
+
selector: entry.selector,
|
|
20
|
+
enabled: entry.enabled ?? true
|
|
21
|
+
}));
|
|
22
|
+
if (config.strategy === "weighted") {
|
|
23
|
+
const hasValidWeights = resolvedTransports.some((entry) => entry.enabled && entry.weight > 0);
|
|
24
|
+
if (!hasValidWeights) throw new Error("Weighted strategy requires at least one enabled transport with positive weight");
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
strategy: config.strategy,
|
|
28
|
+
transports: resolvedTransports,
|
|
29
|
+
maxRetries: config.maxRetries ?? enabledTransports.length,
|
|
30
|
+
timeout: config.timeout,
|
|
31
|
+
continueOnSuccess: config.continueOnSuccess ?? false
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/strategies/round-robin-strategy.ts
|
|
37
|
+
/**
|
|
38
|
+
* Round-robin strategy that cycles through transports in order.
|
|
39
|
+
*
|
|
40
|
+
* This strategy maintains an internal counter and selects transports
|
|
41
|
+
* in a circular fashion, ensuring even distribution of messages across
|
|
42
|
+
* all enabled transports.
|
|
43
|
+
* @since 0.3.0
|
|
44
|
+
*/
|
|
45
|
+
var RoundRobinStrategy = class {
|
|
46
|
+
currentIndex = 0;
|
|
47
|
+
/**
|
|
48
|
+
* Selects the next transport in round-robin order.
|
|
49
|
+
*
|
|
50
|
+
* @param _message The message to send (unused in this strategy).
|
|
51
|
+
* @param transports Available transports.
|
|
52
|
+
* @param attemptedIndices Indices of transports that have already been
|
|
53
|
+
* attempted.
|
|
54
|
+
* @returns The selected transport or `undefined` if all transports have been
|
|
55
|
+
* attempted.
|
|
56
|
+
*/
|
|
57
|
+
select(_message, transports, attemptedIndices) {
|
|
58
|
+
const enabledCount = transports.filter((t) => t.enabled).length;
|
|
59
|
+
if (enabledCount < 1) return void 0;
|
|
60
|
+
for (let attempts = 0; attempts < enabledCount; attempts++) {
|
|
61
|
+
while (!transports[this.currentIndex].enabled) this.currentIndex = (this.currentIndex + 1) % transports.length;
|
|
62
|
+
const index = this.currentIndex;
|
|
63
|
+
const entry = transports[index];
|
|
64
|
+
this.currentIndex = (this.currentIndex + 1) % transports.length;
|
|
65
|
+
if (attemptedIndices.has(index)) continue;
|
|
66
|
+
return {
|
|
67
|
+
entry,
|
|
68
|
+
index
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return void 0;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resets the round-robin counter to start from the beginning.
|
|
75
|
+
*/
|
|
76
|
+
reset() {
|
|
77
|
+
this.currentIndex = 0;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/strategies/weighted-strategy.ts
|
|
83
|
+
/**
|
|
84
|
+
* Weighted strategy that distributes traffic based on configured weights.
|
|
85
|
+
*
|
|
86
|
+
* This strategy uses weighted random selection to distribute messages
|
|
87
|
+
* across transports proportionally to their configured weights.
|
|
88
|
+
* A transport with weight 2 will receive approximately twice as many
|
|
89
|
+
* messages as a transport with weight 1.
|
|
90
|
+
* @since 0.3.0
|
|
91
|
+
*/
|
|
92
|
+
var WeightedStrategy = class {
|
|
93
|
+
/**
|
|
94
|
+
* Selects a transport based on weighted random distribution.
|
|
95
|
+
*
|
|
96
|
+
* @param _message The message to send (unused in this strategy).
|
|
97
|
+
* @param transports Available transports.
|
|
98
|
+
* @param attemptedIndices Indices of transports that have already been
|
|
99
|
+
* attempted.
|
|
100
|
+
* @returns The selected transport or `undefined` if all transports have been
|
|
101
|
+
* attempted.
|
|
102
|
+
*/
|
|
103
|
+
select(_message, transports, attemptedIndices) {
|
|
104
|
+
const availableTransports = transports.map((entry, index) => ({
|
|
105
|
+
entry,
|
|
106
|
+
index
|
|
107
|
+
})).filter(({ entry, index }) => entry.enabled && entry.weight > 0 && !attemptedIndices.has(index));
|
|
108
|
+
if (availableTransports.length < 1) return void 0;
|
|
109
|
+
const totalWeight = availableTransports.reduce((sum, { entry }) => sum + entry.weight, 0);
|
|
110
|
+
if (totalWeight <= 0) return void 0;
|
|
111
|
+
const random = Math.random() * totalWeight;
|
|
112
|
+
let cumulativeWeight = 0;
|
|
113
|
+
for (const { entry, index } of availableTransports) {
|
|
114
|
+
cumulativeWeight += entry.weight;
|
|
115
|
+
if (random < cumulativeWeight) return {
|
|
116
|
+
entry,
|
|
117
|
+
index
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const last = availableTransports[availableTransports.length - 1];
|
|
121
|
+
return {
|
|
122
|
+
entry: last.entry,
|
|
123
|
+
index: last.index
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Resets the strategy (no-op for weighted strategy as it's stateless).
|
|
128
|
+
*/
|
|
129
|
+
reset() {}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/strategies/priority-strategy.ts
|
|
134
|
+
/**
|
|
135
|
+
* Priority strategy that selects transports based on priority values.
|
|
136
|
+
*
|
|
137
|
+
* This strategy always attempts to use the highest priority transport
|
|
138
|
+
* first, falling back to lower priority transports only when higher
|
|
139
|
+
* priority ones fail. Transports with the same priority are considered
|
|
140
|
+
* equivalent and one is selected randomly.
|
|
141
|
+
* @since 0.3.0
|
|
142
|
+
*/
|
|
143
|
+
var PriorityStrategy = class {
|
|
144
|
+
/**
|
|
145
|
+
* Selects the highest priority transport that hasn't been attempted.
|
|
146
|
+
*
|
|
147
|
+
* @param _message The message to send (unused in this strategy).
|
|
148
|
+
* @param transports Available transports.
|
|
149
|
+
* @param attemptedIndices Indices of transports that have already been
|
|
150
|
+
* attempted.
|
|
151
|
+
* @returns The selected transport or `undefined` if all transports have been
|
|
152
|
+
* attempted.
|
|
153
|
+
*/
|
|
154
|
+
select(_message, transports, attemptedIndices) {
|
|
155
|
+
const availableTransports = transports.map((entry, index) => ({
|
|
156
|
+
entry,
|
|
157
|
+
index
|
|
158
|
+
})).filter(({ entry, index }) => entry.enabled && !attemptedIndices.has(index));
|
|
159
|
+
if (availableTransports.length < 1) return void 0;
|
|
160
|
+
availableTransports.sort((a, b) => b.entry.priority - a.entry.priority);
|
|
161
|
+
const highestPriority = availableTransports[0].entry.priority;
|
|
162
|
+
const topPriorityTransports = availableTransports.filter(({ entry }) => entry.priority === highestPriority);
|
|
163
|
+
if (topPriorityTransports.length > 1) {
|
|
164
|
+
const randomIndex = Math.floor(Math.random() * topPriorityTransports.length);
|
|
165
|
+
return topPriorityTransports[randomIndex];
|
|
166
|
+
}
|
|
167
|
+
return topPriorityTransports[0];
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Resets the strategy (no-op for priority strategy as it's stateless).
|
|
171
|
+
*/
|
|
172
|
+
reset() {}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/strategies/selector-strategy.ts
|
|
177
|
+
/**
|
|
178
|
+
* Selector strategy that routes messages based on custom selector functions.
|
|
179
|
+
*
|
|
180
|
+
* This strategy evaluates each transport's selector function (if provided)
|
|
181
|
+
* to determine if it should handle a specific message. Transports without
|
|
182
|
+
* selectors are considered as catch-all fallbacks. Among matching transports,
|
|
183
|
+
* one is selected randomly.
|
|
184
|
+
* @since 0.3.0
|
|
185
|
+
*/
|
|
186
|
+
var SelectorStrategy = class {
|
|
187
|
+
/**
|
|
188
|
+
* Selects a transport based on selector function matching.
|
|
189
|
+
*
|
|
190
|
+
* @param message The message to send.
|
|
191
|
+
* @param transports Available transports.
|
|
192
|
+
* @param attemptedIndices Indices of transports that have already been
|
|
193
|
+
* attempted.
|
|
194
|
+
* @returns The selected transport or `undefined` if no transport matches.
|
|
195
|
+
*/
|
|
196
|
+
select(message, transports, attemptedIndices) {
|
|
197
|
+
const availableTransports = transports.map((entry, index) => ({
|
|
198
|
+
entry,
|
|
199
|
+
index
|
|
200
|
+
})).filter(({ entry, index }) => entry.enabled && !attemptedIndices.has(index));
|
|
201
|
+
if (availableTransports.length < 1) return void 0;
|
|
202
|
+
const withSelectors = [];
|
|
203
|
+
const withoutSelectors = [];
|
|
204
|
+
for (const transport of availableTransports) if (transport.entry.selector) try {
|
|
205
|
+
if (transport.entry.selector(message)) withSelectors.push(transport);
|
|
206
|
+
} catch {}
|
|
207
|
+
else withoutSelectors.push(transport);
|
|
208
|
+
const candidates = withSelectors.length > 0 ? withSelectors : withoutSelectors;
|
|
209
|
+
if (candidates.length < 1) return void 0;
|
|
210
|
+
const randomIndex = Math.floor(Math.random() * candidates.length);
|
|
211
|
+
return candidates[randomIndex];
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Resets the strategy (no-op for selector strategy as it's stateless).
|
|
215
|
+
*/
|
|
216
|
+
reset() {}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/pool-transport.ts
|
|
221
|
+
/**
|
|
222
|
+
* Pool transport that combines multiple transports with various load balancing
|
|
223
|
+
* and failover strategies.
|
|
224
|
+
*
|
|
225
|
+
* This transport implements the same `Transport` interface, making it a drop-in
|
|
226
|
+
* replacement for any single transport. It distributes messages across multiple
|
|
227
|
+
* underlying transports based on the configured strategy.
|
|
228
|
+
*
|
|
229
|
+
* @example Round-robin load balancing
|
|
230
|
+
* ```typescript
|
|
231
|
+
* import { PoolTransport } from "@upyo/pool";
|
|
232
|
+
*
|
|
233
|
+
* const transport = new PoolTransport({
|
|
234
|
+
* strategy: "round-robin",
|
|
235
|
+
* transports: [
|
|
236
|
+
* { transport: mailgunTransport },
|
|
237
|
+
* { transport: sendgridTransport },
|
|
238
|
+
* { transport: sesTransport },
|
|
239
|
+
* ],
|
|
240
|
+
* });
|
|
241
|
+
* ```
|
|
242
|
+
*
|
|
243
|
+
* @example Priority-based failover
|
|
244
|
+
* ```typescript
|
|
245
|
+
* const transport = new PoolTransport({
|
|
246
|
+
* strategy: "priority",
|
|
247
|
+
* transports: [
|
|
248
|
+
* { transport: primaryTransport, priority: 100 },
|
|
249
|
+
* { transport: backupTransport, priority: 50 },
|
|
250
|
+
* { transport: lastResortTransport, priority: 10 },
|
|
251
|
+
* ],
|
|
252
|
+
* });
|
|
253
|
+
* ```
|
|
254
|
+
*
|
|
255
|
+
* @example Custom routing with selectors
|
|
256
|
+
* ```typescript
|
|
257
|
+
* const transport = new PoolTransport({
|
|
258
|
+
* strategy: "selector-based",
|
|
259
|
+
* transports: [
|
|
260
|
+
* {
|
|
261
|
+
* transport: bulkEmailTransport,
|
|
262
|
+
* selector: (msg) => msg.tags?.includes("newsletter"),
|
|
263
|
+
* },
|
|
264
|
+
* {
|
|
265
|
+
* transport: transactionalTransport,
|
|
266
|
+
* selector: (msg) => msg.priority === "high",
|
|
267
|
+
* },
|
|
268
|
+
* { transport: defaultTransport }, // Catches everything else
|
|
269
|
+
* ],
|
|
270
|
+
* });
|
|
271
|
+
* ```
|
|
272
|
+
*
|
|
273
|
+
* @since 0.3.0
|
|
274
|
+
*/
|
|
275
|
+
var PoolTransport = class {
|
|
276
|
+
/**
|
|
277
|
+
* The resolved configuration used by this pool transport.
|
|
278
|
+
*/
|
|
279
|
+
config;
|
|
280
|
+
strategy;
|
|
281
|
+
/**
|
|
282
|
+
* Creates a new PoolTransport instance.
|
|
283
|
+
*
|
|
284
|
+
* @param config Configuration options for the pool transport.
|
|
285
|
+
* @throws {Error} If the configuration is invalid.
|
|
286
|
+
*/
|
|
287
|
+
constructor(config) {
|
|
288
|
+
this.config = createPoolConfig(config);
|
|
289
|
+
this.strategy = this.createStrategy(this.config.strategy);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Sends a single email message using the pool strategy.
|
|
293
|
+
*
|
|
294
|
+
* The transport is selected based on the configured strategy. If the
|
|
295
|
+
* selected transport fails, the pool will retry with other transports
|
|
296
|
+
* up to the configured retry limit.
|
|
297
|
+
*
|
|
298
|
+
* @param message The email message to send.
|
|
299
|
+
* @param options Optional transport options including abort signal.
|
|
300
|
+
* @returns A promise that resolves to a receipt indicating success or failure.
|
|
301
|
+
*/
|
|
302
|
+
async send(message, options) {
|
|
303
|
+
const attemptedIndices = /* @__PURE__ */ new Set();
|
|
304
|
+
const errors = [];
|
|
305
|
+
for (let attempt = 0; attempt < this.config.maxRetries; attempt++) {
|
|
306
|
+
if (options?.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
|
|
307
|
+
const selection = this.strategy.select(message, this.config.transports, attemptedIndices);
|
|
308
|
+
if (!selection) break;
|
|
309
|
+
attemptedIndices.add(selection.index);
|
|
310
|
+
try {
|
|
311
|
+
const sendOptions = this.createSendOptions(options);
|
|
312
|
+
const receipt = await selection.entry.transport.send(message, sendOptions);
|
|
313
|
+
if (receipt.successful) return receipt;
|
|
314
|
+
errors.push(...receipt.errorMessages);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
|
317
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
318
|
+
errors.push(errorMessage);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
successful: false,
|
|
323
|
+
errorMessages: errors.length > 0 ? errors : ["All transports failed to send the message"]
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Sends multiple email messages using the pool strategy.
|
|
328
|
+
*
|
|
329
|
+
* Each message is sent individually using the `send` method, respecting
|
|
330
|
+
* the configured strategy and retry logic.
|
|
331
|
+
*
|
|
332
|
+
* @param messages An iterable or async iterable of messages to send.
|
|
333
|
+
* @param options Optional transport options including abort signal.
|
|
334
|
+
* @returns An async iterable of receipts, one for each message.
|
|
335
|
+
*/
|
|
336
|
+
async *sendMany(messages, options) {
|
|
337
|
+
this.strategy.reset();
|
|
338
|
+
for await (const message of messages) {
|
|
339
|
+
if (options?.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
|
|
340
|
+
yield await this.send(message, options);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Disposes of all underlying transports that support disposal.
|
|
345
|
+
*
|
|
346
|
+
* This method is called automatically when using the `await using` syntax.
|
|
347
|
+
* It ensures proper cleanup of resources held by the underlying transports.
|
|
348
|
+
*/
|
|
349
|
+
async [Symbol.asyncDispose]() {
|
|
350
|
+
const disposalPromises = [];
|
|
351
|
+
for (const entry of this.config.transports) {
|
|
352
|
+
const transport = entry.transport;
|
|
353
|
+
if (typeof transport[Symbol.asyncDispose] === "function") {
|
|
354
|
+
const asyncDispose = transport[Symbol.asyncDispose]();
|
|
355
|
+
disposalPromises.push(Promise.resolve(asyncDispose));
|
|
356
|
+
} else if (typeof transport[Symbol.dispose] === "function") try {
|
|
357
|
+
transport[Symbol.dispose]();
|
|
358
|
+
} catch {}
|
|
359
|
+
}
|
|
360
|
+
await Promise.allSettled(disposalPromises);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Creates a strategy instance based on the strategy type or returns the provided strategy.
|
|
364
|
+
*/
|
|
365
|
+
createStrategy(strategy) {
|
|
366
|
+
if (typeof strategy === "object" && strategy !== null) return strategy;
|
|
367
|
+
switch (strategy) {
|
|
368
|
+
case "round-robin": return new RoundRobinStrategy();
|
|
369
|
+
case "weighted": return new WeightedStrategy();
|
|
370
|
+
case "priority": return new PriorityStrategy();
|
|
371
|
+
case "selector-based": return new SelectorStrategy();
|
|
372
|
+
default: throw new Error(`Unknown strategy: ${strategy}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Creates send options with timeout if configured.
|
|
377
|
+
*/
|
|
378
|
+
createSendOptions(options) {
|
|
379
|
+
if (!this.config.timeout) return options;
|
|
380
|
+
const controller = new AbortController();
|
|
381
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
382
|
+
if (options?.signal) options.signal.addEventListener("abort", () => {
|
|
383
|
+
clearTimeout(timeoutId);
|
|
384
|
+
controller.abort();
|
|
385
|
+
});
|
|
386
|
+
controller.signal.addEventListener("abort", () => {
|
|
387
|
+
clearTimeout(timeoutId);
|
|
388
|
+
});
|
|
389
|
+
return {
|
|
390
|
+
...options,
|
|
391
|
+
signal: controller.signal
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
//#endregion
|
|
397
|
+
exports.PoolTransport = PoolTransport;
|
|
398
|
+
exports.PriorityStrategy = PriorityStrategy;
|
|
399
|
+
exports.RoundRobinStrategy = RoundRobinStrategy;
|
|
400
|
+
exports.SelectorStrategy = SelectorStrategy;
|
|
401
|
+
exports.WeightedStrategy = WeightedStrategy;
|