@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/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;