@upyo/retry 0.5.0-dev.0

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 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,46 @@
1
+ <!-- deno-fmt-ignore-file -->
2
+
3
+ @upyo/retry
4
+ ===========
5
+
6
+ *@upyo/retry* provides a retry and backoff decorator for Upyo transports.
7
+ It wraps any `Transport` implementation without changing the transport
8
+ interface, so it can be composed with SMTP, HTTP API transports, pool
9
+ transports, and OpenTelemetry instrumentation.
10
+
11
+
12
+ Installation
13
+ ------------
14
+
15
+ ~~~~ bash
16
+ deno add jsr:@upyo/retry
17
+ pnpm add @upyo/retry
18
+ ~~~~
19
+
20
+
21
+ Usage
22
+ -----
23
+
24
+ ~~~~ typescript
25
+ import { RetryTransport } from "@upyo/retry";
26
+ import { MailgunTransport } from "@upyo/mailgun";
27
+
28
+ const baseTransport = new MailgunTransport({
29
+ apiKey: "your-mailgun-api-key",
30
+ domain: "mg.example.com",
31
+ });
32
+
33
+ const transport = new RetryTransport(baseTransport, {
34
+ maxAttempts: 3,
35
+ backoff: {
36
+ baseDelayMilliseconds: 1000,
37
+ maxDelayMilliseconds: 30000,
38
+ factor: 2,
39
+ },
40
+ });
41
+ ~~~~
42
+
43
+ The retry transport uses structured Upyo failure receipts to distinguish
44
+ transient failures, such as rate limits and temporary server errors, from
45
+ permanent failures, such as validation or authentication errors. It also
46
+ honors provider-supplied `Retry-After` metadata when available.
package/dist/index.cjs ADDED
@@ -0,0 +1,328 @@
1
+ //#region rolldown:runtime
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
10
+ key = keys[i];
11
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
12
+ get: ((k) => from[k]).bind(null, key),
13
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
14
+ });
15
+ }
16
+ return to;
17
+ };
18
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
19
+ value: mod,
20
+ enumerable: true
21
+ }) : target, mod));
22
+
23
+ //#endregion
24
+ const __upyo_core = __toESM(require("@upyo/core"));
25
+
26
+ //#region src/config.ts
27
+ /**
28
+ * Creates a resolved retry configuration.
29
+ *
30
+ * @param config Retry configuration.
31
+ * @returns The resolved configuration.
32
+ * @throws {RangeError} If a numeric option is out of range.
33
+ * @since 0.5.0
34
+ */
35
+ function createRetryConfig(config = {}) {
36
+ const maxAttempts = config.maxAttempts ?? 3;
37
+ const baseDelayMilliseconds = config.backoff?.baseDelayMilliseconds ?? 1e3;
38
+ const maxDelayMilliseconds = config.backoff?.maxDelayMilliseconds ?? 3e4;
39
+ const factor = config.backoff?.factor ?? 2;
40
+ const maxConcurrent = config.sendMany?.maxConcurrent ?? 1;
41
+ const intervalMilliseconds = config.sendMany?.intervalMilliseconds ?? 0;
42
+ assertIntegerAtLeast(maxAttempts, 1, "maxAttempts");
43
+ assertFiniteAtLeast(baseDelayMilliseconds, 0, "backoff.baseDelayMilliseconds");
44
+ assertFiniteAtLeast(maxDelayMilliseconds, 0, "backoff.maxDelayMilliseconds");
45
+ assertFiniteAtLeast(factor, 1, "backoff.factor");
46
+ assertIntegerAtLeast(maxConcurrent, 1, "sendMany.maxConcurrent");
47
+ assertFiniteAtLeast(intervalMilliseconds, 0, "sendMany.intervalMilliseconds");
48
+ return {
49
+ maxAttempts,
50
+ backoff: {
51
+ baseDelayMilliseconds,
52
+ maxDelayMilliseconds,
53
+ factor
54
+ },
55
+ jitter: config.jitter ?? "full",
56
+ random: config.random ?? Math.random,
57
+ shouldRetry: config.shouldRetry,
58
+ wait: config.wait ?? defaultWait,
59
+ sendMany: {
60
+ maxConcurrent,
61
+ intervalMilliseconds
62
+ }
63
+ };
64
+ }
65
+ async function defaultWait(context, signal) {
66
+ signal?.throwIfAborted();
67
+ if (context.delayMilliseconds <= 0) return;
68
+ await new Promise((resolve, reject) => {
69
+ const cleanup = () => signal?.removeEventListener("abort", abort);
70
+ const timeout = setTimeout(() => {
71
+ cleanup();
72
+ resolve();
73
+ }, context.delayMilliseconds);
74
+ const abort = () => {
75
+ clearTimeout(timeout);
76
+ cleanup();
77
+ reject(createAbortError());
78
+ };
79
+ if (signal?.aborted) {
80
+ abort();
81
+ return;
82
+ }
83
+ signal?.addEventListener("abort", abort, { once: true });
84
+ });
85
+ }
86
+ function assertIntegerAtLeast(value, minimum, name) {
87
+ if (!Number.isInteger(value) || value < minimum) throw new RangeError(`${name} must be an integer greater than or equal to ${minimum}.`);
88
+ }
89
+ function assertFiniteAtLeast(value, minimum, name) {
90
+ if (!Number.isFinite(value) || value < minimum) throw new RangeError(`${name} must be greater than or equal to ${minimum}.`);
91
+ }
92
+ function createAbortError() {
93
+ return new DOMException("The operation was aborted.", "AbortError");
94
+ }
95
+
96
+ //#endregion
97
+ //#region src/retry-transport.ts
98
+ /**
99
+ * Transport decorator that retries transient delivery failures.
100
+ *
101
+ * @since 0.5.0
102
+ */
103
+ var RetryTransport = class {
104
+ id;
105
+ /**
106
+ * Resolved retry configuration.
107
+ */
108
+ config;
109
+ wrappedTransport;
110
+ /**
111
+ * Creates a retrying transport around another transport.
112
+ *
113
+ * @param transport The transport to wrap.
114
+ * @param config Retry configuration.
115
+ * @throws {RangeError} If retry configuration is invalid.
116
+ */
117
+ constructor(transport, config = {}) {
118
+ this.wrappedTransport = transport;
119
+ this.id = transport.id;
120
+ this.config = createRetryConfig(config);
121
+ }
122
+ /**
123
+ * Sends one message, retrying retryable failures.
124
+ *
125
+ * @param message The message to send.
126
+ * @param options Optional transport options.
127
+ * @returns The final delivery receipt.
128
+ * @throws {DOMException} If the operation is aborted.
129
+ */
130
+ async send(message, options) {
131
+ options?.signal?.throwIfAborted();
132
+ let lastThrownError;
133
+ for (let attempt = 1; attempt <= this.config.maxAttempts; attempt++) {
134
+ options?.signal?.throwIfAborted();
135
+ try {
136
+ const receipt = await this.wrappedTransport.send(message, options);
137
+ if (receipt.successful) return this.withSuccessMetadata(receipt, attempt);
138
+ const failedReceipt = this.withFailureMetadata(receipt, attempt);
139
+ if (attempt >= this.config.maxAttempts || !this.shouldRetryReceipt(failedReceipt)) return failedReceipt;
140
+ await this.waitBeforeRetry({
141
+ attempt,
142
+ nextAttempt: attempt + 1,
143
+ maxAttempts: this.config.maxAttempts,
144
+ delayMilliseconds: this.calculateDelay(attempt, failedReceipt),
145
+ receipt: failedReceipt,
146
+ reason: "retry"
147
+ }, options?.signal);
148
+ } catch (error) {
149
+ options?.signal?.throwIfAborted();
150
+ lastThrownError = error;
151
+ if (attempt >= this.config.maxAttempts || !this.shouldRetryError(error)) return this.createThrownFailure(error, attempt);
152
+ await this.waitBeforeRetry({
153
+ attempt,
154
+ nextAttempt: attempt + 1,
155
+ maxAttempts: this.config.maxAttempts,
156
+ delayMilliseconds: this.calculateDelay(attempt),
157
+ error,
158
+ reason: "retry"
159
+ }, options?.signal);
160
+ }
161
+ }
162
+ return this.createThrownFailure(lastThrownError, this.config.maxAttempts);
163
+ }
164
+ /**
165
+ * Sends messages with per-message retry behavior.
166
+ *
167
+ * Receipts are yielded in the same order as input messages.
168
+ *
169
+ * @param messages Messages to send.
170
+ * @param options Optional transport options.
171
+ * @returns An async iterable of receipts.
172
+ * @throws {DOMException} If the operation is aborted.
173
+ */
174
+ async *sendMany(messages, options) {
175
+ const iterator = toAsyncIterator(messages);
176
+ const inFlight = /* @__PURE__ */ new Map();
177
+ const completed = /* @__PURE__ */ new Map();
178
+ let inputDone = false;
179
+ let nextLaunchIndex = 0;
180
+ let nextYieldIndex = 0;
181
+ const launchNext = async () => {
182
+ if (inputDone) return;
183
+ options?.signal?.throwIfAborted();
184
+ const next = await iterator.next();
185
+ if (next.done) {
186
+ inputDone = true;
187
+ return;
188
+ }
189
+ const index = nextLaunchIndex++;
190
+ if (index > 0) await this.waitBetweenSendMany(options?.signal);
191
+ const promise = this.send(next.value, options).then((receipt) => ({
192
+ index,
193
+ successful: true,
194
+ receipt
195
+ }), (error) => ({
196
+ index,
197
+ successful: false,
198
+ error
199
+ }));
200
+ inFlight.set(index, promise);
201
+ };
202
+ const launchAvailable = async () => {
203
+ while (!inputDone && inFlight.size < this.config.sendMany.maxConcurrent) await launchNext();
204
+ };
205
+ try {
206
+ await launchAvailable();
207
+ while (inFlight.size > 0 || completed.has(nextYieldIndex)) {
208
+ while (completed.has(nextYieldIndex)) {
209
+ const result$1 = completed.get(nextYieldIndex);
210
+ completed.delete(nextYieldIndex);
211
+ if (result$1 == null) break;
212
+ if (!result$1.successful) throw result$1.error;
213
+ yield result$1.receipt;
214
+ nextYieldIndex++;
215
+ await launchAvailable();
216
+ }
217
+ if (inFlight.size <= 0) break;
218
+ const result = await Promise.race(inFlight.values());
219
+ inFlight.delete(result.index);
220
+ completed.set(result.index, result);
221
+ if (result.index !== nextYieldIndex) await launchAvailable();
222
+ }
223
+ } finally {
224
+ if (!inputDone) await iterator.return?.();
225
+ }
226
+ }
227
+ /**
228
+ * Disposes the wrapped transport when it supports disposal.
229
+ *
230
+ * @since 0.5.0
231
+ */
232
+ async [Symbol.asyncDispose]() {
233
+ const asyncDisposable = this.wrappedTransport;
234
+ const asyncDispose = asyncDisposable[Symbol.asyncDispose];
235
+ if (typeof asyncDispose === "function") {
236
+ await asyncDispose.call(asyncDisposable);
237
+ return;
238
+ }
239
+ const disposable = this.wrappedTransport;
240
+ const dispose = disposable[Symbol.dispose];
241
+ if (typeof dispose === "function") dispose.call(disposable);
242
+ }
243
+ withSuccessMetadata(receipt, attempts) {
244
+ return {
245
+ ...receipt,
246
+ provider: receipt.provider ?? this.id,
247
+ attempts
248
+ };
249
+ }
250
+ withFailureMetadata(receipt, attempts) {
251
+ return {
252
+ ...receipt,
253
+ provider: receipt.provider ?? this.id,
254
+ retryable: receipt.retryable ?? this.hasRetryableError(receipt.errors),
255
+ attempts
256
+ };
257
+ }
258
+ shouldRetryReceipt(receipt) {
259
+ if (this.config.shouldRetry != null) return this.config.shouldRetry(receipt);
260
+ if (receipt.retryable != null) return receipt.retryable;
261
+ return this.hasRetryableError(receipt.errors);
262
+ }
263
+ shouldRetryError(error) {
264
+ if (this.config.shouldRetry != null) return this.config.shouldRetry(error);
265
+ return (0, __upyo_core.classifyReceiptError)(error).retryable;
266
+ }
267
+ hasRetryableError(errors) {
268
+ return errors?.some((error) => error.retryable) ?? false;
269
+ }
270
+ calculateDelay(attempt, receipt) {
271
+ const retryAfterMilliseconds = getRetryAfterMilliseconds(receipt);
272
+ const cappedRetryAfter = retryAfterMilliseconds == null ? void 0 : Math.min(retryAfterMilliseconds, this.config.backoff.maxDelayMilliseconds);
273
+ if (cappedRetryAfter != null) return cappedRetryAfter;
274
+ const computedDelay = Math.min(this.config.backoff.baseDelayMilliseconds * Math.pow(this.config.backoff.factor, attempt - 1), this.config.backoff.maxDelayMilliseconds);
275
+ if (this.config.jitter === false || this.config.jitter === "none") return computedDelay;
276
+ return Math.floor(this.config.random() * computedDelay);
277
+ }
278
+ waitBeforeRetry(context, signal) {
279
+ return this.config.wait(context, signal);
280
+ }
281
+ waitBetweenSendMany(signal) {
282
+ const delayMilliseconds = this.config.sendMany.intervalMilliseconds;
283
+ if (delayMilliseconds <= 0) return Promise.resolve();
284
+ return this.config.wait({
285
+ attempt: 0,
286
+ nextAttempt: 0,
287
+ maxAttempts: this.config.maxAttempts,
288
+ delayMilliseconds,
289
+ reason: "sendMany-throttle"
290
+ }, signal);
291
+ }
292
+ createThrownFailure(error, attempts) {
293
+ const message = error instanceof Error ? error.message : String(error);
294
+ const classification = (0, __upyo_core.classifyReceiptError)(error);
295
+ return (0, __upyo_core.createFailedReceipt)((0, __upyo_core.createReceiptError)(message, {
296
+ provider: this.id,
297
+ category: classification.category,
298
+ code: classification.code,
299
+ retryable: classification.retryable
300
+ }), {
301
+ provider: this.id,
302
+ attempts
303
+ });
304
+ }
305
+ };
306
+ /**
307
+ * Creates a retrying transport around another transport.
308
+ *
309
+ * @param baseTransport The transport to wrap.
310
+ * @param config Retry configuration.
311
+ * @returns A retrying transport decorator.
312
+ * @throws {RangeError} If retry configuration is invalid.
313
+ * @since 0.5.0
314
+ */
315
+ function createRetryTransport(baseTransport, config = {}) {
316
+ return new RetryTransport(baseTransport, config);
317
+ }
318
+ function getRetryAfterMilliseconds(receipt) {
319
+ const receiptDelay = receipt?.errors?.map((error) => error.retryAfterMilliseconds).find((delay) => delay != null);
320
+ return receiptDelay;
321
+ }
322
+ async function* toAsyncIterator(values) {
323
+ for await (const value of values) yield value;
324
+ }
325
+
326
+ //#endregion
327
+ exports.RetryTransport = RetryTransport;
328
+ exports.createRetryTransport = createRetryTransport;
@@ -0,0 +1,246 @@
1
+ import { Message, Receipt, Transport, TransportOptions } from "@upyo/core";
2
+
3
+ //#region src/config.d.ts
4
+
5
+ /**
6
+ * Jitter mode for computed retry delays.
7
+ *
8
+ * @since 0.5.0
9
+ */
10
+ type JitterConfig = false | "none" | "full";
11
+ /**
12
+ * Exponential backoff settings for retry delays.
13
+ *
14
+ * @since 0.5.0
15
+ */
16
+ interface BackoffConfig {
17
+ /**
18
+ * Delay before the first retry, in milliseconds.
19
+ *
20
+ * @default 1000
21
+ */
22
+ readonly baseDelayMilliseconds?: number;
23
+ /**
24
+ * Maximum computed delay, in milliseconds.
25
+ *
26
+ * @default 30000
27
+ */
28
+ readonly maxDelayMilliseconds?: number;
29
+ /**
30
+ * Multiplier applied after each failed attempt.
31
+ *
32
+ * @default 2
33
+ */
34
+ readonly factor?: number;
35
+ }
36
+ /**
37
+ * Context passed to custom retry wait functions.
38
+ *
39
+ * @since 0.5.0
40
+ */
41
+ interface DelayContext<TProviderId extends string = string> {
42
+ /**
43
+ * Attempt that just failed.
44
+ */
45
+ readonly attempt: number;
46
+ /**
47
+ * Attempt that will run after this delay.
48
+ */
49
+ readonly nextAttempt: number;
50
+ /**
51
+ * Maximum configured attempts.
52
+ */
53
+ readonly maxAttempts: number;
54
+ /**
55
+ * Delay to wait before retrying, in milliseconds.
56
+ */
57
+ readonly delayMilliseconds: number;
58
+ /**
59
+ * Failed receipt that caused the retry, when the transport returned one.
60
+ */
61
+ readonly receipt?: Receipt<TProviderId> & {
62
+ readonly successful: false;
63
+ };
64
+ /**
65
+ * Thrown error that caused the retry, when the transport rejected.
66
+ */
67
+ readonly error?: unknown;
68
+ /**
69
+ * Why the delay is being applied.
70
+ */
71
+ readonly reason: "retry" | "sendMany-throttle";
72
+ }
73
+ /**
74
+ * Function used to wait between attempts or throttled sends.
75
+ *
76
+ * @param context Information about the delay being applied.
77
+ * @param signal Abort signal that should cancel the wait.
78
+ * @returns A promise that resolves when the delay is complete.
79
+ * @throws {DOMException} If the wait is aborted.
80
+ * @since 0.5.0
81
+ */
82
+ type WaitFunction<TProviderId extends string = string> = (context: DelayContext<TProviderId>, signal?: AbortSignal) => Promise<void>;
83
+ /**
84
+ * Function that decides whether a failure should be retried.
85
+ *
86
+ * @param failure Failed receipt or thrown error.
87
+ * @returns Whether another attempt should be made.
88
+ * @since 0.5.0
89
+ */
90
+ type RetryClassifier<TProviderId extends string = string> = (failure: (Receipt<TProviderId> & {
91
+ readonly successful: false;
92
+ }) | unknown) => boolean;
93
+ /**
94
+ * Retry behavior for `sendMany()`.
95
+ *
96
+ * @since 0.5.0
97
+ */
98
+ interface SendManyRetryConfig {
99
+ /**
100
+ * Maximum number of messages to send at the same time.
101
+ *
102
+ * @default 1
103
+ */
104
+ readonly maxConcurrent?: number;
105
+ /**
106
+ * Minimum delay between launching message sends, in milliseconds.
107
+ *
108
+ * @default 0
109
+ */
110
+ readonly intervalMilliseconds?: number;
111
+ }
112
+ /**
113
+ * Configuration for the retry transport decorator.
114
+ *
115
+ * @since 0.5.0
116
+ */
117
+ interface RetryConfig<TProviderId extends string = string> {
118
+ /**
119
+ * Total number of send attempts, including the first one.
120
+ *
121
+ * @default 3
122
+ */
123
+ readonly maxAttempts?: number;
124
+ /**
125
+ * Exponential backoff settings.
126
+ */
127
+ readonly backoff?: BackoffConfig;
128
+ /**
129
+ * Jitter mode applied to computed backoff delays.
130
+ *
131
+ * `Retry-After` delays are not jittered.
132
+ *
133
+ * @default "full"
134
+ */
135
+ readonly jitter?: JitterConfig;
136
+ /**
137
+ * Random number source for jitter.
138
+ *
139
+ * @default Math.random
140
+ */
141
+ readonly random?: () => number;
142
+ /**
143
+ * Custom retry classifier.
144
+ */
145
+ readonly shouldRetry?: RetryClassifier<TProviderId>;
146
+ /**
147
+ * Custom wait function for tests or host-controlled scheduling.
148
+ */
149
+ readonly wait?: WaitFunction<TProviderId>;
150
+ /**
151
+ * Retry and throttling behavior for `sendMany()`.
152
+ */
153
+ readonly sendMany?: SendManyRetryConfig;
154
+ }
155
+ /**
156
+ * Resolved retry configuration with defaults applied.
157
+ *
158
+ * @since 0.5.0
159
+ */
160
+ interface ResolvedRetryConfig<TProviderId extends string = string> {
161
+ readonly maxAttempts: number;
162
+ readonly backoff: Required<BackoffConfig>;
163
+ readonly jitter: JitterConfig;
164
+ readonly random: () => number;
165
+ readonly shouldRetry?: RetryClassifier<TProviderId>;
166
+ readonly wait: WaitFunction<TProviderId>;
167
+ readonly sendMany: Required<SendManyRetryConfig>;
168
+ }
169
+ /**
170
+ * Creates a resolved retry configuration.
171
+ *
172
+ * @param config Retry configuration.
173
+ * @returns The resolved configuration.
174
+ * @throws {RangeError} If a numeric option is out of range.
175
+ * @since 0.5.0
176
+ */
177
+ //#endregion
178
+ //#region src/retry-transport.d.ts
179
+ /**
180
+ * Transport decorator that retries transient delivery failures.
181
+ *
182
+ * @since 0.5.0
183
+ */
184
+ declare class RetryTransport<TProviderId extends string = string> implements Transport<TProviderId>, AsyncDisposable {
185
+ readonly id: TProviderId;
186
+ /**
187
+ * Resolved retry configuration.
188
+ */
189
+ readonly config: ResolvedRetryConfig<TProviderId>;
190
+ private readonly wrappedTransport;
191
+ /**
192
+ * Creates a retrying transport around another transport.
193
+ *
194
+ * @param transport The transport to wrap.
195
+ * @param config Retry configuration.
196
+ * @throws {RangeError} If retry configuration is invalid.
197
+ */
198
+ constructor(transport: Transport<TProviderId>, config?: RetryConfig<TProviderId>);
199
+ /**
200
+ * Sends one message, retrying retryable failures.
201
+ *
202
+ * @param message The message to send.
203
+ * @param options Optional transport options.
204
+ * @returns The final delivery receipt.
205
+ * @throws {DOMException} If the operation is aborted.
206
+ */
207
+ send(message: Message, options?: TransportOptions): Promise<Receipt<TProviderId>>;
208
+ /**
209
+ * Sends messages with per-message retry behavior.
210
+ *
211
+ * Receipts are yielded in the same order as input messages.
212
+ *
213
+ * @param messages Messages to send.
214
+ * @param options Optional transport options.
215
+ * @returns An async iterable of receipts.
216
+ * @throws {DOMException} If the operation is aborted.
217
+ */
218
+ sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<Receipt<TProviderId>>;
219
+ /**
220
+ * Disposes the wrapped transport when it supports disposal.
221
+ *
222
+ * @since 0.5.0
223
+ */
224
+ [Symbol.asyncDispose](): Promise<void>;
225
+ private withSuccessMetadata;
226
+ private withFailureMetadata;
227
+ private shouldRetryReceipt;
228
+ private shouldRetryError;
229
+ private hasRetryableError;
230
+ private calculateDelay;
231
+ private waitBeforeRetry;
232
+ private waitBetweenSendMany;
233
+ private createThrownFailure;
234
+ }
235
+ /**
236
+ * Creates a retrying transport around another transport.
237
+ *
238
+ * @param baseTransport The transport to wrap.
239
+ * @param config Retry configuration.
240
+ * @returns A retrying transport decorator.
241
+ * @throws {RangeError} If retry configuration is invalid.
242
+ * @since 0.5.0
243
+ */
244
+ declare function createRetryTransport<TProviderId extends string = string>(baseTransport: Transport<TProviderId>, config?: RetryConfig<TProviderId>): RetryTransport<TProviderId>;
245
+ //#endregion
246
+ export { BackoffConfig, DelayContext, JitterConfig, RetryClassifier, RetryConfig, RetryTransport, SendManyRetryConfig, WaitFunction, createRetryTransport };
@@ -0,0 +1,246 @@
1
+ import { Message, Receipt, Transport, TransportOptions } from "@upyo/core";
2
+
3
+ //#region src/config.d.ts
4
+
5
+ /**
6
+ * Jitter mode for computed retry delays.
7
+ *
8
+ * @since 0.5.0
9
+ */
10
+ type JitterConfig = false | "none" | "full";
11
+ /**
12
+ * Exponential backoff settings for retry delays.
13
+ *
14
+ * @since 0.5.0
15
+ */
16
+ interface BackoffConfig {
17
+ /**
18
+ * Delay before the first retry, in milliseconds.
19
+ *
20
+ * @default 1000
21
+ */
22
+ readonly baseDelayMilliseconds?: number;
23
+ /**
24
+ * Maximum computed delay, in milliseconds.
25
+ *
26
+ * @default 30000
27
+ */
28
+ readonly maxDelayMilliseconds?: number;
29
+ /**
30
+ * Multiplier applied after each failed attempt.
31
+ *
32
+ * @default 2
33
+ */
34
+ readonly factor?: number;
35
+ }
36
+ /**
37
+ * Context passed to custom retry wait functions.
38
+ *
39
+ * @since 0.5.0
40
+ */
41
+ interface DelayContext<TProviderId extends string = string> {
42
+ /**
43
+ * Attempt that just failed.
44
+ */
45
+ readonly attempt: number;
46
+ /**
47
+ * Attempt that will run after this delay.
48
+ */
49
+ readonly nextAttempt: number;
50
+ /**
51
+ * Maximum configured attempts.
52
+ */
53
+ readonly maxAttempts: number;
54
+ /**
55
+ * Delay to wait before retrying, in milliseconds.
56
+ */
57
+ readonly delayMilliseconds: number;
58
+ /**
59
+ * Failed receipt that caused the retry, when the transport returned one.
60
+ */
61
+ readonly receipt?: Receipt<TProviderId> & {
62
+ readonly successful: false;
63
+ };
64
+ /**
65
+ * Thrown error that caused the retry, when the transport rejected.
66
+ */
67
+ readonly error?: unknown;
68
+ /**
69
+ * Why the delay is being applied.
70
+ */
71
+ readonly reason: "retry" | "sendMany-throttle";
72
+ }
73
+ /**
74
+ * Function used to wait between attempts or throttled sends.
75
+ *
76
+ * @param context Information about the delay being applied.
77
+ * @param signal Abort signal that should cancel the wait.
78
+ * @returns A promise that resolves when the delay is complete.
79
+ * @throws {DOMException} If the wait is aborted.
80
+ * @since 0.5.0
81
+ */
82
+ type WaitFunction<TProviderId extends string = string> = (context: DelayContext<TProviderId>, signal?: AbortSignal) => Promise<void>;
83
+ /**
84
+ * Function that decides whether a failure should be retried.
85
+ *
86
+ * @param failure Failed receipt or thrown error.
87
+ * @returns Whether another attempt should be made.
88
+ * @since 0.5.0
89
+ */
90
+ type RetryClassifier<TProviderId extends string = string> = (failure: (Receipt<TProviderId> & {
91
+ readonly successful: false;
92
+ }) | unknown) => boolean;
93
+ /**
94
+ * Retry behavior for `sendMany()`.
95
+ *
96
+ * @since 0.5.0
97
+ */
98
+ interface SendManyRetryConfig {
99
+ /**
100
+ * Maximum number of messages to send at the same time.
101
+ *
102
+ * @default 1
103
+ */
104
+ readonly maxConcurrent?: number;
105
+ /**
106
+ * Minimum delay between launching message sends, in milliseconds.
107
+ *
108
+ * @default 0
109
+ */
110
+ readonly intervalMilliseconds?: number;
111
+ }
112
+ /**
113
+ * Configuration for the retry transport decorator.
114
+ *
115
+ * @since 0.5.0
116
+ */
117
+ interface RetryConfig<TProviderId extends string = string> {
118
+ /**
119
+ * Total number of send attempts, including the first one.
120
+ *
121
+ * @default 3
122
+ */
123
+ readonly maxAttempts?: number;
124
+ /**
125
+ * Exponential backoff settings.
126
+ */
127
+ readonly backoff?: BackoffConfig;
128
+ /**
129
+ * Jitter mode applied to computed backoff delays.
130
+ *
131
+ * `Retry-After` delays are not jittered.
132
+ *
133
+ * @default "full"
134
+ */
135
+ readonly jitter?: JitterConfig;
136
+ /**
137
+ * Random number source for jitter.
138
+ *
139
+ * @default Math.random
140
+ */
141
+ readonly random?: () => number;
142
+ /**
143
+ * Custom retry classifier.
144
+ */
145
+ readonly shouldRetry?: RetryClassifier<TProviderId>;
146
+ /**
147
+ * Custom wait function for tests or host-controlled scheduling.
148
+ */
149
+ readonly wait?: WaitFunction<TProviderId>;
150
+ /**
151
+ * Retry and throttling behavior for `sendMany()`.
152
+ */
153
+ readonly sendMany?: SendManyRetryConfig;
154
+ }
155
+ /**
156
+ * Resolved retry configuration with defaults applied.
157
+ *
158
+ * @since 0.5.0
159
+ */
160
+ interface ResolvedRetryConfig<TProviderId extends string = string> {
161
+ readonly maxAttempts: number;
162
+ readonly backoff: Required<BackoffConfig>;
163
+ readonly jitter: JitterConfig;
164
+ readonly random: () => number;
165
+ readonly shouldRetry?: RetryClassifier<TProviderId>;
166
+ readonly wait: WaitFunction<TProviderId>;
167
+ readonly sendMany: Required<SendManyRetryConfig>;
168
+ }
169
+ /**
170
+ * Creates a resolved retry configuration.
171
+ *
172
+ * @param config Retry configuration.
173
+ * @returns The resolved configuration.
174
+ * @throws {RangeError} If a numeric option is out of range.
175
+ * @since 0.5.0
176
+ */
177
+ //#endregion
178
+ //#region src/retry-transport.d.ts
179
+ /**
180
+ * Transport decorator that retries transient delivery failures.
181
+ *
182
+ * @since 0.5.0
183
+ */
184
+ declare class RetryTransport<TProviderId extends string = string> implements Transport<TProviderId>, AsyncDisposable {
185
+ readonly id: TProviderId;
186
+ /**
187
+ * Resolved retry configuration.
188
+ */
189
+ readonly config: ResolvedRetryConfig<TProviderId>;
190
+ private readonly wrappedTransport;
191
+ /**
192
+ * Creates a retrying transport around another transport.
193
+ *
194
+ * @param transport The transport to wrap.
195
+ * @param config Retry configuration.
196
+ * @throws {RangeError} If retry configuration is invalid.
197
+ */
198
+ constructor(transport: Transport<TProviderId>, config?: RetryConfig<TProviderId>);
199
+ /**
200
+ * Sends one message, retrying retryable failures.
201
+ *
202
+ * @param message The message to send.
203
+ * @param options Optional transport options.
204
+ * @returns The final delivery receipt.
205
+ * @throws {DOMException} If the operation is aborted.
206
+ */
207
+ send(message: Message, options?: TransportOptions): Promise<Receipt<TProviderId>>;
208
+ /**
209
+ * Sends messages with per-message retry behavior.
210
+ *
211
+ * Receipts are yielded in the same order as input messages.
212
+ *
213
+ * @param messages Messages to send.
214
+ * @param options Optional transport options.
215
+ * @returns An async iterable of receipts.
216
+ * @throws {DOMException} If the operation is aborted.
217
+ */
218
+ sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<Receipt<TProviderId>>;
219
+ /**
220
+ * Disposes the wrapped transport when it supports disposal.
221
+ *
222
+ * @since 0.5.0
223
+ */
224
+ [Symbol.asyncDispose](): Promise<void>;
225
+ private withSuccessMetadata;
226
+ private withFailureMetadata;
227
+ private shouldRetryReceipt;
228
+ private shouldRetryError;
229
+ private hasRetryableError;
230
+ private calculateDelay;
231
+ private waitBeforeRetry;
232
+ private waitBetweenSendMany;
233
+ private createThrownFailure;
234
+ }
235
+ /**
236
+ * Creates a retrying transport around another transport.
237
+ *
238
+ * @param baseTransport The transport to wrap.
239
+ * @param config Retry configuration.
240
+ * @returns A retrying transport decorator.
241
+ * @throws {RangeError} If retry configuration is invalid.
242
+ * @since 0.5.0
243
+ */
244
+ declare function createRetryTransport<TProviderId extends string = string>(baseTransport: Transport<TProviderId>, config?: RetryConfig<TProviderId>): RetryTransport<TProviderId>;
245
+ //#endregion
246
+ export { BackoffConfig, DelayContext, JitterConfig, RetryClassifier, RetryConfig, RetryTransport, SendManyRetryConfig, WaitFunction, createRetryTransport };
package/dist/index.js ADDED
@@ -0,0 +1,304 @@
1
+ import { classifyReceiptError, createFailedReceipt, createReceiptError } from "@upyo/core";
2
+
3
+ //#region src/config.ts
4
+ /**
5
+ * Creates a resolved retry configuration.
6
+ *
7
+ * @param config Retry configuration.
8
+ * @returns The resolved configuration.
9
+ * @throws {RangeError} If a numeric option is out of range.
10
+ * @since 0.5.0
11
+ */
12
+ function createRetryConfig(config = {}) {
13
+ const maxAttempts = config.maxAttempts ?? 3;
14
+ const baseDelayMilliseconds = config.backoff?.baseDelayMilliseconds ?? 1e3;
15
+ const maxDelayMilliseconds = config.backoff?.maxDelayMilliseconds ?? 3e4;
16
+ const factor = config.backoff?.factor ?? 2;
17
+ const maxConcurrent = config.sendMany?.maxConcurrent ?? 1;
18
+ const intervalMilliseconds = config.sendMany?.intervalMilliseconds ?? 0;
19
+ assertIntegerAtLeast(maxAttempts, 1, "maxAttempts");
20
+ assertFiniteAtLeast(baseDelayMilliseconds, 0, "backoff.baseDelayMilliseconds");
21
+ assertFiniteAtLeast(maxDelayMilliseconds, 0, "backoff.maxDelayMilliseconds");
22
+ assertFiniteAtLeast(factor, 1, "backoff.factor");
23
+ assertIntegerAtLeast(maxConcurrent, 1, "sendMany.maxConcurrent");
24
+ assertFiniteAtLeast(intervalMilliseconds, 0, "sendMany.intervalMilliseconds");
25
+ return {
26
+ maxAttempts,
27
+ backoff: {
28
+ baseDelayMilliseconds,
29
+ maxDelayMilliseconds,
30
+ factor
31
+ },
32
+ jitter: config.jitter ?? "full",
33
+ random: config.random ?? Math.random,
34
+ shouldRetry: config.shouldRetry,
35
+ wait: config.wait ?? defaultWait,
36
+ sendMany: {
37
+ maxConcurrent,
38
+ intervalMilliseconds
39
+ }
40
+ };
41
+ }
42
+ async function defaultWait(context, signal) {
43
+ signal?.throwIfAborted();
44
+ if (context.delayMilliseconds <= 0) return;
45
+ await new Promise((resolve, reject) => {
46
+ const cleanup = () => signal?.removeEventListener("abort", abort);
47
+ const timeout = setTimeout(() => {
48
+ cleanup();
49
+ resolve();
50
+ }, context.delayMilliseconds);
51
+ const abort = () => {
52
+ clearTimeout(timeout);
53
+ cleanup();
54
+ reject(createAbortError());
55
+ };
56
+ if (signal?.aborted) {
57
+ abort();
58
+ return;
59
+ }
60
+ signal?.addEventListener("abort", abort, { once: true });
61
+ });
62
+ }
63
+ function assertIntegerAtLeast(value, minimum, name) {
64
+ if (!Number.isInteger(value) || value < minimum) throw new RangeError(`${name} must be an integer greater than or equal to ${minimum}.`);
65
+ }
66
+ function assertFiniteAtLeast(value, minimum, name) {
67
+ if (!Number.isFinite(value) || value < minimum) throw new RangeError(`${name} must be greater than or equal to ${minimum}.`);
68
+ }
69
+ function createAbortError() {
70
+ return new DOMException("The operation was aborted.", "AbortError");
71
+ }
72
+
73
+ //#endregion
74
+ //#region src/retry-transport.ts
75
+ /**
76
+ * Transport decorator that retries transient delivery failures.
77
+ *
78
+ * @since 0.5.0
79
+ */
80
+ var RetryTransport = class {
81
+ id;
82
+ /**
83
+ * Resolved retry configuration.
84
+ */
85
+ config;
86
+ wrappedTransport;
87
+ /**
88
+ * Creates a retrying transport around another transport.
89
+ *
90
+ * @param transport The transport to wrap.
91
+ * @param config Retry configuration.
92
+ * @throws {RangeError} If retry configuration is invalid.
93
+ */
94
+ constructor(transport, config = {}) {
95
+ this.wrappedTransport = transport;
96
+ this.id = transport.id;
97
+ this.config = createRetryConfig(config);
98
+ }
99
+ /**
100
+ * Sends one message, retrying retryable failures.
101
+ *
102
+ * @param message The message to send.
103
+ * @param options Optional transport options.
104
+ * @returns The final delivery receipt.
105
+ * @throws {DOMException} If the operation is aborted.
106
+ */
107
+ async send(message, options) {
108
+ options?.signal?.throwIfAborted();
109
+ let lastThrownError;
110
+ for (let attempt = 1; attempt <= this.config.maxAttempts; attempt++) {
111
+ options?.signal?.throwIfAborted();
112
+ try {
113
+ const receipt = await this.wrappedTransport.send(message, options);
114
+ if (receipt.successful) return this.withSuccessMetadata(receipt, attempt);
115
+ const failedReceipt = this.withFailureMetadata(receipt, attempt);
116
+ if (attempt >= this.config.maxAttempts || !this.shouldRetryReceipt(failedReceipt)) return failedReceipt;
117
+ await this.waitBeforeRetry({
118
+ attempt,
119
+ nextAttempt: attempt + 1,
120
+ maxAttempts: this.config.maxAttempts,
121
+ delayMilliseconds: this.calculateDelay(attempt, failedReceipt),
122
+ receipt: failedReceipt,
123
+ reason: "retry"
124
+ }, options?.signal);
125
+ } catch (error) {
126
+ options?.signal?.throwIfAborted();
127
+ lastThrownError = error;
128
+ if (attempt >= this.config.maxAttempts || !this.shouldRetryError(error)) return this.createThrownFailure(error, attempt);
129
+ await this.waitBeforeRetry({
130
+ attempt,
131
+ nextAttempt: attempt + 1,
132
+ maxAttempts: this.config.maxAttempts,
133
+ delayMilliseconds: this.calculateDelay(attempt),
134
+ error,
135
+ reason: "retry"
136
+ }, options?.signal);
137
+ }
138
+ }
139
+ return this.createThrownFailure(lastThrownError, this.config.maxAttempts);
140
+ }
141
+ /**
142
+ * Sends messages with per-message retry behavior.
143
+ *
144
+ * Receipts are yielded in the same order as input messages.
145
+ *
146
+ * @param messages Messages to send.
147
+ * @param options Optional transport options.
148
+ * @returns An async iterable of receipts.
149
+ * @throws {DOMException} If the operation is aborted.
150
+ */
151
+ async *sendMany(messages, options) {
152
+ const iterator = toAsyncIterator(messages);
153
+ const inFlight = /* @__PURE__ */ new Map();
154
+ const completed = /* @__PURE__ */ new Map();
155
+ let inputDone = false;
156
+ let nextLaunchIndex = 0;
157
+ let nextYieldIndex = 0;
158
+ const launchNext = async () => {
159
+ if (inputDone) return;
160
+ options?.signal?.throwIfAborted();
161
+ const next = await iterator.next();
162
+ if (next.done) {
163
+ inputDone = true;
164
+ return;
165
+ }
166
+ const index = nextLaunchIndex++;
167
+ if (index > 0) await this.waitBetweenSendMany(options?.signal);
168
+ const promise = this.send(next.value, options).then((receipt) => ({
169
+ index,
170
+ successful: true,
171
+ receipt
172
+ }), (error) => ({
173
+ index,
174
+ successful: false,
175
+ error
176
+ }));
177
+ inFlight.set(index, promise);
178
+ };
179
+ const launchAvailable = async () => {
180
+ while (!inputDone && inFlight.size < this.config.sendMany.maxConcurrent) await launchNext();
181
+ };
182
+ try {
183
+ await launchAvailable();
184
+ while (inFlight.size > 0 || completed.has(nextYieldIndex)) {
185
+ while (completed.has(nextYieldIndex)) {
186
+ const result$1 = completed.get(nextYieldIndex);
187
+ completed.delete(nextYieldIndex);
188
+ if (result$1 == null) break;
189
+ if (!result$1.successful) throw result$1.error;
190
+ yield result$1.receipt;
191
+ nextYieldIndex++;
192
+ await launchAvailable();
193
+ }
194
+ if (inFlight.size <= 0) break;
195
+ const result = await Promise.race(inFlight.values());
196
+ inFlight.delete(result.index);
197
+ completed.set(result.index, result);
198
+ if (result.index !== nextYieldIndex) await launchAvailable();
199
+ }
200
+ } finally {
201
+ if (!inputDone) await iterator.return?.();
202
+ }
203
+ }
204
+ /**
205
+ * Disposes the wrapped transport when it supports disposal.
206
+ *
207
+ * @since 0.5.0
208
+ */
209
+ async [Symbol.asyncDispose]() {
210
+ const asyncDisposable = this.wrappedTransport;
211
+ const asyncDispose = asyncDisposable[Symbol.asyncDispose];
212
+ if (typeof asyncDispose === "function") {
213
+ await asyncDispose.call(asyncDisposable);
214
+ return;
215
+ }
216
+ const disposable = this.wrappedTransport;
217
+ const dispose = disposable[Symbol.dispose];
218
+ if (typeof dispose === "function") dispose.call(disposable);
219
+ }
220
+ withSuccessMetadata(receipt, attempts) {
221
+ return {
222
+ ...receipt,
223
+ provider: receipt.provider ?? this.id,
224
+ attempts
225
+ };
226
+ }
227
+ withFailureMetadata(receipt, attempts) {
228
+ return {
229
+ ...receipt,
230
+ provider: receipt.provider ?? this.id,
231
+ retryable: receipt.retryable ?? this.hasRetryableError(receipt.errors),
232
+ attempts
233
+ };
234
+ }
235
+ shouldRetryReceipt(receipt) {
236
+ if (this.config.shouldRetry != null) return this.config.shouldRetry(receipt);
237
+ if (receipt.retryable != null) return receipt.retryable;
238
+ return this.hasRetryableError(receipt.errors);
239
+ }
240
+ shouldRetryError(error) {
241
+ if (this.config.shouldRetry != null) return this.config.shouldRetry(error);
242
+ return classifyReceiptError(error).retryable;
243
+ }
244
+ hasRetryableError(errors) {
245
+ return errors?.some((error) => error.retryable) ?? false;
246
+ }
247
+ calculateDelay(attempt, receipt) {
248
+ const retryAfterMilliseconds = getRetryAfterMilliseconds(receipt);
249
+ const cappedRetryAfter = retryAfterMilliseconds == null ? void 0 : Math.min(retryAfterMilliseconds, this.config.backoff.maxDelayMilliseconds);
250
+ if (cappedRetryAfter != null) return cappedRetryAfter;
251
+ const computedDelay = Math.min(this.config.backoff.baseDelayMilliseconds * Math.pow(this.config.backoff.factor, attempt - 1), this.config.backoff.maxDelayMilliseconds);
252
+ if (this.config.jitter === false || this.config.jitter === "none") return computedDelay;
253
+ return Math.floor(this.config.random() * computedDelay);
254
+ }
255
+ waitBeforeRetry(context, signal) {
256
+ return this.config.wait(context, signal);
257
+ }
258
+ waitBetweenSendMany(signal) {
259
+ const delayMilliseconds = this.config.sendMany.intervalMilliseconds;
260
+ if (delayMilliseconds <= 0) return Promise.resolve();
261
+ return this.config.wait({
262
+ attempt: 0,
263
+ nextAttempt: 0,
264
+ maxAttempts: this.config.maxAttempts,
265
+ delayMilliseconds,
266
+ reason: "sendMany-throttle"
267
+ }, signal);
268
+ }
269
+ createThrownFailure(error, attempts) {
270
+ const message = error instanceof Error ? error.message : String(error);
271
+ const classification = classifyReceiptError(error);
272
+ return createFailedReceipt(createReceiptError(message, {
273
+ provider: this.id,
274
+ category: classification.category,
275
+ code: classification.code,
276
+ retryable: classification.retryable
277
+ }), {
278
+ provider: this.id,
279
+ attempts
280
+ });
281
+ }
282
+ };
283
+ /**
284
+ * Creates a retrying transport around another transport.
285
+ *
286
+ * @param baseTransport The transport to wrap.
287
+ * @param config Retry configuration.
288
+ * @returns A retrying transport decorator.
289
+ * @throws {RangeError} If retry configuration is invalid.
290
+ * @since 0.5.0
291
+ */
292
+ function createRetryTransport(baseTransport, config = {}) {
293
+ return new RetryTransport(baseTransport, config);
294
+ }
295
+ function getRetryAfterMilliseconds(receipt) {
296
+ const receiptDelay = receipt?.errors?.map((error) => error.retryAfterMilliseconds).find((delay) => delay != null);
297
+ return receiptDelay;
298
+ }
299
+ async function* toAsyncIterator(values) {
300
+ for await (const value of values) yield value;
301
+ }
302
+
303
+ //#endregion
304
+ export { RetryTransport, createRetryTransport };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@upyo/retry",
3
+ "version": "0.5.0-dev.0",
4
+ "description": "Retry and backoff decorator transport for Upyo email library",
5
+ "keywords": [
6
+ "email",
7
+ "mail",
8
+ "retry",
9
+ "backoff",
10
+ "rate-limit"
11
+ ],
12
+ "license": "MIT",
13
+ "author": {
14
+ "name": "Hong Minhee",
15
+ "email": "hong@minhee.org",
16
+ "url": "https://hongminhee.org/"
17
+ },
18
+ "homepage": "https://upyo.org/transports/retry",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/dahlia/upyo.git",
22
+ "directory": "packages/retry/"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/dahlia/upyo/issues"
26
+ },
27
+ "funding": [
28
+ "https://github.com/sponsors/dahlia"
29
+ ],
30
+ "engines": {
31
+ "node": ">=20.0.0",
32
+ "bun": ">=1.2.0",
33
+ "deno": ">=2.3.0"
34
+ },
35
+ "files": [
36
+ "dist/",
37
+ "package.json",
38
+ "README.md"
39
+ ],
40
+ "type": "module",
41
+ "module": "./dist/index.js",
42
+ "main": "./dist/index.cjs",
43
+ "types": "./dist/index.d.ts",
44
+ "exports": {
45
+ ".": {
46
+ "types": {
47
+ "import": "./dist/index.d.ts",
48
+ "require": "./dist/index.d.cts"
49
+ },
50
+ "import": "./dist/index.js",
51
+ "require": "./dist/index.cjs"
52
+ },
53
+ "./package.json": "./package.json"
54
+ },
55
+ "sideEffects": false,
56
+ "peerDependencies": {
57
+ "@upyo/core": "0.5.0"
58
+ },
59
+ "devDependencies": {
60
+ "tsdown": "^0.12.7",
61
+ "typescript": "5.8.3"
62
+ },
63
+ "scripts": {
64
+ "prepublish": "mise run --no-deps :build"
65
+ }
66
+ }