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