@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/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2025 Hong Minhee
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
<!-- deno-fmt-ignore-file -->
|
|
2
|
+
|
|
3
|
+
@upyo/pool
|
|
4
|
+
==========
|
|
5
|
+
|
|
6
|
+
[![JSR][JSR badge]][JSR]
|
|
7
|
+
[![npm][npm badge]][npm]
|
|
8
|
+
|
|
9
|
+
Pool transport for the [Upyo] email library with load balancing and failover
|
|
10
|
+
strategies for combining multiple email providers.
|
|
11
|
+
|
|
12
|
+
[JSR]: https://jsr.io/@upyo/pool
|
|
13
|
+
[JSR badge]: https://jsr.io/badges/@upyo/pool
|
|
14
|
+
[npm]: https://www.npmjs.com/package/@upyo/pool
|
|
15
|
+
[npm badge]: https://img.shields.io/npm/v/@upyo/pool?logo=npm
|
|
16
|
+
[Upyo]: https://upyo.org/
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Features
|
|
20
|
+
--------
|
|
21
|
+
|
|
22
|
+
- Multiple strategies: Round-robin, weighted, priority, and custom
|
|
23
|
+
selector-based routing
|
|
24
|
+
- Automatic failover: Retry with different transports when one fails
|
|
25
|
+
- Load balancing: Distribute email traffic across multiple providers
|
|
26
|
+
- Drop-in replacement: Implements the same `Transport` interface
|
|
27
|
+
- Resource management: Proper cleanup with `AsyncDisposable` support
|
|
28
|
+
- Cross-runtime compatibility (Node.js, Deno, Bun, edge functions)
|
|
29
|
+
- TypeScript support
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
Installation
|
|
33
|
+
------------
|
|
34
|
+
|
|
35
|
+
~~~~ sh
|
|
36
|
+
npm add @upyo/core @upyo/pool
|
|
37
|
+
pnpm add @upyo/core @upyo/pool
|
|
38
|
+
yarn add @upyo/core @upyo/pool
|
|
39
|
+
deno add --jsr @upyo/core @upyo/pool
|
|
40
|
+
bun add @upyo/core @upyo/pool
|
|
41
|
+
~~~~
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
Usage
|
|
45
|
+
-----
|
|
46
|
+
|
|
47
|
+
### Round-robin load balancing
|
|
48
|
+
|
|
49
|
+
Distribute messages evenly across multiple transports in circular order:
|
|
50
|
+
|
|
51
|
+
~~~~ typescript
|
|
52
|
+
import { PoolTransport } from "@upyo/pool";
|
|
53
|
+
import { createSmtpTransport } from "@upyo/smtp";
|
|
54
|
+
import { createMailgunTransport } from "@upyo/mailgun";
|
|
55
|
+
import { createSendGridTransport } from "@upyo/sendgrid";
|
|
56
|
+
|
|
57
|
+
const transport = new PoolTransport({
|
|
58
|
+
strategy: "round-robin",
|
|
59
|
+
transports: [
|
|
60
|
+
{ transport: createSmtpTransport({ /* config */ }) },
|
|
61
|
+
{ transport: createMailgunTransport({ /* config */ }) },
|
|
62
|
+
{ transport: createSendGridTransport({ /* config */ }) },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Messages are sent in order: SMTP → Mailgun → SendGrid → SMTP → ...
|
|
67
|
+
await transport.send(message1); // Uses SMTP
|
|
68
|
+
await transport.send(message2); // Uses Mailgun
|
|
69
|
+
await transport.send(message3); // Uses SendGrid
|
|
70
|
+
await transport.send(message4); // Uses SMTP again
|
|
71
|
+
~~~~
|
|
72
|
+
|
|
73
|
+
### Weighted distribution
|
|
74
|
+
|
|
75
|
+
Distribute traffic proportionally based on configured weights:
|
|
76
|
+
|
|
77
|
+
~~~~ typescript
|
|
78
|
+
const transport = new PoolTransport({
|
|
79
|
+
strategy: "weighted",
|
|
80
|
+
transports: [
|
|
81
|
+
{ transport: primaryTransport, weight: 3 }, // Gets ~60% of traffic
|
|
82
|
+
{ transport: secondaryTransport, weight: 2 }, // Gets ~40% of traffic
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
~~~~
|
|
86
|
+
|
|
87
|
+
### Priority-based failover
|
|
88
|
+
|
|
89
|
+
Always use the highest priority transport, falling back to lower priorities
|
|
90
|
+
only on failure:
|
|
91
|
+
|
|
92
|
+
~~~~ typescript
|
|
93
|
+
const transport = new PoolTransport({
|
|
94
|
+
strategy: "priority",
|
|
95
|
+
transports: [
|
|
96
|
+
{ transport: primaryTransport, priority: 100 },
|
|
97
|
+
{ transport: backupTransport, priority: 50 },
|
|
98
|
+
{ transport: emergencyTransport, priority: 10 },
|
|
99
|
+
],
|
|
100
|
+
maxRetries: 3, // Try up to 3 transports
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Always tries primary first, only uses backup if primary fails
|
|
104
|
+
const receipt = await transport.send(message);
|
|
105
|
+
~~~~
|
|
106
|
+
|
|
107
|
+
### Custom routing with selectors
|
|
108
|
+
|
|
109
|
+
Route messages based on custom logic:
|
|
110
|
+
|
|
111
|
+
~~~~ typescript
|
|
112
|
+
const transport = new PoolTransport({
|
|
113
|
+
strategy: "selector-based",
|
|
114
|
+
transports: [
|
|
115
|
+
{
|
|
116
|
+
transport: bulkEmailTransport,
|
|
117
|
+
selector: (msg) => msg.tags?.includes("newsletter"),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
transport: transactionalTransport,
|
|
121
|
+
selector: (msg) => msg.priority === "high",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
transport: euTransport,
|
|
125
|
+
selector: (msg) => msg.metadata?.region === "EU",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
transport: defaultTransport, // No selector - catches everything else
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Newsletter goes through bulk provider
|
|
134
|
+
await transport.send({
|
|
135
|
+
...message,
|
|
136
|
+
tags: ["newsletter", "marketing"],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Important email goes through premium provider
|
|
140
|
+
await transport.send({
|
|
141
|
+
...message,
|
|
142
|
+
priority: "high",
|
|
143
|
+
});
|
|
144
|
+
~~~~
|
|
145
|
+
|
|
146
|
+
### Custom strategies
|
|
147
|
+
|
|
148
|
+
You can implement custom routing strategies by creating a class that implements
|
|
149
|
+
the `Strategy` interface:
|
|
150
|
+
|
|
151
|
+
~~~~ typescript
|
|
152
|
+
import { PoolTransport, type Strategy, type TransportSelection } from "@upyo/pool";
|
|
153
|
+
|
|
154
|
+
class TimeBasedStrategy implements Strategy {
|
|
155
|
+
select(message, transports, attemptedIndices) {
|
|
156
|
+
const hour = new Date().getHours();
|
|
157
|
+
|
|
158
|
+
// Use different transports based on time of day
|
|
159
|
+
const preferredIndex = hour < 12 ? 0 : 1; // Morning vs afternoon
|
|
160
|
+
|
|
161
|
+
if (!attemptedIndices.has(preferredIndex) &&
|
|
162
|
+
transports[preferredIndex]?.enabled) {
|
|
163
|
+
return {
|
|
164
|
+
entry: transports[preferredIndex],
|
|
165
|
+
index: preferredIndex,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Fallback to any available transport
|
|
170
|
+
for (let i = 0; i < transports.length; i++) {
|
|
171
|
+
if (!attemptedIndices.has(i) && transports[i].enabled) {
|
|
172
|
+
return { entry: transports[i], index: i };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
reset() {
|
|
180
|
+
// Custom reset logic if needed
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const transport = new PoolTransport({
|
|
185
|
+
strategy: new TimeBasedStrategy(),
|
|
186
|
+
transports: [
|
|
187
|
+
{ transport: morningTransport },
|
|
188
|
+
{ transport: afternoonTransport },
|
|
189
|
+
],
|
|
190
|
+
});
|
|
191
|
+
~~~~
|
|
192
|
+
|
|
193
|
+
### Resource management
|
|
194
|
+
|
|
195
|
+
The pool transport implements `AsyncDisposable` for automatic cleanup:
|
|
196
|
+
|
|
197
|
+
~~~~ typescript
|
|
198
|
+
// Automatic cleanup with 'using' statement
|
|
199
|
+
await using transport = new PoolTransport({
|
|
200
|
+
strategy: "round-robin",
|
|
201
|
+
transports: [/* ... */],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await transport.send(message);
|
|
205
|
+
// All underlying transports are disposed automatically
|
|
206
|
+
|
|
207
|
+
// Or manual cleanup
|
|
208
|
+
const transport = new PoolTransport(config);
|
|
209
|
+
try {
|
|
210
|
+
await transport.send(message);
|
|
211
|
+
} finally {
|
|
212
|
+
await transport[Symbol.asyncDispose]();
|
|
213
|
+
}
|
|
214
|
+
~~~~
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
Configuration
|
|
218
|
+
-------------
|
|
219
|
+
|
|
220
|
+
### `PoolConfig`
|
|
221
|
+
|
|
222
|
+
| Property | Type | Required | Default | Description |
|
|
223
|
+
|---------------------|-------------------------------------------------------------------------|----------|----------------------|-------------------------------------------------------------------|
|
|
224
|
+
| `strategy` | `"round-robin" | "weighted" | "priority" | "selector-based" | Strategy` | Yes | | The strategy for selecting transports |
|
|
225
|
+
| `transports` | `TransportEntry[]` | Yes | | Array of transport configurations |
|
|
226
|
+
| `maxRetries` | `number` | No | Number of transports | Maximum retry attempts on failure |
|
|
227
|
+
| `timeout` | `number` | No | | Timeout in milliseconds for each send attempt |
|
|
228
|
+
| `continueOnSuccess` | `boolean` | No | `false` | Continue trying transports after success (selector strategy only) |
|
|
229
|
+
|
|
230
|
+
### `TransportEntry`
|
|
231
|
+
|
|
232
|
+
| Property | Type | Required | Default | Description |
|
|
233
|
+
|-------------|---------------------------------|----------|---------|-----------------------------------------------------|
|
|
234
|
+
| `transport` | `Transport` | Yes | | The transport instance |
|
|
235
|
+
| `weight` | `number` | No | `1` | Weight for weighted distribution |
|
|
236
|
+
| `priority` | `number` | No | `0` | Priority for priority strategy (higher = preferred) |
|
|
237
|
+
| `selector` | `(message: Message) => boolean` | No | | Custom selector function |
|
|
238
|
+
| `enabled` | `boolean` | No | `true` | Whether this transport is enabled |
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
Strategies
|
|
242
|
+
----------
|
|
243
|
+
|
|
244
|
+
### Round-robin
|
|
245
|
+
|
|
246
|
+
Cycles through transports in order, ensuring even distribution:
|
|
247
|
+
|
|
248
|
+
- Maintains internal counter
|
|
249
|
+
- Skips disabled transports
|
|
250
|
+
- Wraps around at the end of the list
|
|
251
|
+
- Best for: Even load distribution
|
|
252
|
+
|
|
253
|
+
### Weighted
|
|
254
|
+
|
|
255
|
+
Randomly selects transports based on configured weights:
|
|
256
|
+
|
|
257
|
+
- Higher weight = higher probability of selection
|
|
258
|
+
- Supports fractional weights
|
|
259
|
+
- Stateless random selection
|
|
260
|
+
- Best for: Proportional traffic distribution
|
|
261
|
+
|
|
262
|
+
### Priority
|
|
263
|
+
|
|
264
|
+
Always attempts highest priority transport first:
|
|
265
|
+
|
|
266
|
+
- Sorts by priority value (descending)
|
|
267
|
+
- Falls back to lower priorities on failure
|
|
268
|
+
- Random selection among same priority
|
|
269
|
+
- Best for: Primary/backup scenarios
|
|
270
|
+
|
|
271
|
+
### Selector-based
|
|
272
|
+
|
|
273
|
+
Routes messages based on custom logic:
|
|
274
|
+
|
|
275
|
+
- Evaluates selector functions in order
|
|
276
|
+
- Transports without selectors act as catch-all
|
|
277
|
+
- Falls back to default if no selector matches
|
|
278
|
+
- Best for: Content-based routing
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
Error handling
|
|
282
|
+
--------------
|
|
283
|
+
|
|
284
|
+
The pool transport aggregates errors from all failed attempts:
|
|
285
|
+
|
|
286
|
+
~~~~ typescript
|
|
287
|
+
const receipt = await transport.send(message);
|
|
288
|
+
|
|
289
|
+
if (!receipt.successful) {
|
|
290
|
+
// Contains errors from all attempted transports
|
|
291
|
+
console.error("Failed to send:", receipt.errorMessages);
|
|
292
|
+
}
|
|
293
|
+
~~~~
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
Testing
|
|
297
|
+
-------
|
|
298
|
+
|
|
299
|
+
Use `MockTransport` for testing pool behavior:
|
|
300
|
+
|
|
301
|
+
~~~~ typescript
|
|
302
|
+
import { PoolTransport } from "@upyo/pool";
|
|
303
|
+
import { MockTransport } from "@upyo/mock";
|
|
304
|
+
|
|
305
|
+
const mockTransport1 = new MockTransport();
|
|
306
|
+
const mockTransport2 = new MockTransport();
|
|
307
|
+
|
|
308
|
+
const pool = new PoolTransport({
|
|
309
|
+
strategy: "round-robin",
|
|
310
|
+
transports: [
|
|
311
|
+
{ transport: mockTransport1 },
|
|
312
|
+
{ transport: mockTransport2 },
|
|
313
|
+
],
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await pool.send(message);
|
|
317
|
+
|
|
318
|
+
// Verify distribution
|
|
319
|
+
assert.equal(mockTransport1.getSentMessagesCount(), 1);
|
|
320
|
+
assert.equal(mockTransport2.getSentMessagesCount(), 0);
|
|
321
|
+
~~~~
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
Use cases
|
|
325
|
+
---------
|
|
326
|
+
|
|
327
|
+
### High availability
|
|
328
|
+
|
|
329
|
+
Ensure email delivery even when providers have outages:
|
|
330
|
+
|
|
331
|
+
~~~~ typescript
|
|
332
|
+
const transport = new PoolTransport({
|
|
333
|
+
strategy: "priority",
|
|
334
|
+
transports: [
|
|
335
|
+
{ transport: primaryProvider, priority: 100 },
|
|
336
|
+
{ transport: backupProvider1, priority: 50 },
|
|
337
|
+
{ transport: backupProvider2, priority: 50 },
|
|
338
|
+
],
|
|
339
|
+
});
|
|
340
|
+
~~~~
|
|
341
|
+
|
|
342
|
+
### Cost optimization
|
|
343
|
+
|
|
344
|
+
Route different types of emails through appropriate providers:
|
|
345
|
+
|
|
346
|
+
~~~~ typescript
|
|
347
|
+
const transport = new PoolTransport({
|
|
348
|
+
strategy: "selector-based",
|
|
349
|
+
transports: [
|
|
350
|
+
{
|
|
351
|
+
transport: cheapBulkProvider,
|
|
352
|
+
selector: (msg) => msg.tags?.includes("newsletter"),
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
transport: premiumProvider,
|
|
356
|
+
selector: (msg) => msg.priority === "high" ||
|
|
357
|
+
msg.tags?.includes("transactional"),
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
});
|
|
361
|
+
~~~~
|
|
362
|
+
|
|
363
|
+
### Rate limit management
|
|
364
|
+
|
|
365
|
+
Distribute load when approaching provider limits:
|
|
366
|
+
|
|
367
|
+
~~~~ typescript
|
|
368
|
+
const transport = new PoolTransport({
|
|
369
|
+
strategy: "weighted",
|
|
370
|
+
transports: [
|
|
371
|
+
{ transport: provider1, weight: 1 }, // 1000 emails/hour limit
|
|
372
|
+
{ transport: provider2, weight: 2 }, // 2000 emails/hour limit
|
|
373
|
+
],
|
|
374
|
+
});
|
|
375
|
+
~~~~
|
|
376
|
+
|
|
377
|
+
### Gradual migration
|
|
378
|
+
|
|
379
|
+
Shift traffic from old to new provider:
|
|
380
|
+
|
|
381
|
+
~~~~ typescript
|
|
382
|
+
const transport = new PoolTransport({
|
|
383
|
+
strategy: "weighted",
|
|
384
|
+
transports: [
|
|
385
|
+
{ transport: oldProvider, weight: 90 }, // Start with 90%
|
|
386
|
+
{ transport: newProvider, weight: 10 }, // Gradually increase
|
|
387
|
+
],
|
|
388
|
+
});
|
|
389
|
+
~~~~
|