@fetchkit/ffetch 5.5.3 → 5.6.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.
@@ -25,6 +25,25 @@ __export(dedupe_exports, {
25
25
  });
26
26
  module.exports = __toCommonJS(dedupe_exports);
27
27
 
28
+ // src/error.ts
29
+ var BaseError = class extends Error {
30
+ constructor(name, message, cause) {
31
+ super(message);
32
+ this.name = name;
33
+ if (cause !== void 0) this.cause = cause;
34
+ }
35
+ };
36
+ var TimeoutError = class extends BaseError {
37
+ constructor(message = "Request timed out", cause) {
38
+ super("TimeoutError", message, cause);
39
+ }
40
+ };
41
+ var AbortError = class extends BaseError {
42
+ constructor(message = "Request was aborted", cause) {
43
+ super("AbortError", message, cause);
44
+ }
45
+ };
46
+
28
47
  // src/dedupeRequestHash.ts
29
48
  function toBase64(bytes) {
30
49
  const maybeBuffer = globalThis.Buffer;
@@ -76,7 +95,7 @@ function contextToHashParams(ctx) {
76
95
  return {
77
96
  method: ctx.request.method,
78
97
  url: ctx.request.url,
79
- body: ctx.init.body ?? null,
98
+ body: ctx.init.body ?? ctx.request.body,
80
99
  headers: ctx.request.headers,
81
100
  signal: ctx.init.signal === void 0 || ctx.init.signal === null ? void 0 : ctx.init.signal,
82
101
  requestInit: ctx.init,
@@ -92,6 +111,17 @@ function dedupePlugin(options = {}) {
92
111
  } = options;
93
112
  const inFlight = /* @__PURE__ */ new Map();
94
113
  let sweeper;
114
+ function makeCancellationError(ctx) {
115
+ const { signals } = ctx.metadata;
116
+ if (signals.user?.aborted) {
117
+ return new AbortError("Request was aborted by user", signals.user.reason);
118
+ }
119
+ if (signals.timeout?.aborted) {
120
+ return new TimeoutError("signal timed out", signals.timeout.reason);
121
+ }
122
+ const signal = signals.combined ?? ctx.request.signal;
123
+ return new AbortError("Request was aborted", signal.reason);
124
+ }
95
125
  function startSweeper() {
96
126
  if (sweeper || typeof ttl !== "number" || ttl <= 0) return;
97
127
  sweeper = setInterval(() => {
@@ -125,7 +155,24 @@ function dedupePlugin(options = {}) {
125
155
  const existing = inFlight.get(key);
126
156
  if (existing) {
127
157
  return new Promise((resolve, reject) => {
128
- existing.waiters.push({ resolve, reject });
158
+ const signal = ctx.metadata.signals.combined ?? ctx.request.signal;
159
+ const waiter = { resolve, reject, cleanup: () => {
160
+ } };
161
+ const onAbort = () => {
162
+ const index = existing.waiters.indexOf(waiter);
163
+ existing.waiters.splice(index, 1);
164
+ waiter.cleanup();
165
+ reject(makeCancellationError(ctx));
166
+ };
167
+ waiter.cleanup = () => {
168
+ signal.removeEventListener("abort", onAbort);
169
+ };
170
+ if (signal.aborted) {
171
+ reject(makeCancellationError(ctx));
172
+ return;
173
+ }
174
+ signal.addEventListener("abort", onAbort, { once: true });
175
+ existing.waiters.push(waiter);
129
176
  });
130
177
  }
131
178
  const waiters = [];
@@ -138,12 +185,14 @@ function dedupePlugin(options = {}) {
138
185
  startSweeper();
139
186
  actualPromise.then(
140
187
  (result) => {
141
- for (const waiter of waiters) {
188
+ for (const waiter of waiters.splice(0)) {
189
+ waiter.cleanup();
142
190
  waiter.resolve(result.clone());
143
191
  }
144
192
  },
145
193
  (error) => {
146
- for (const waiter of waiters) {
194
+ for (const waiter of waiters.splice(0)) {
195
+ waiter.cleanup();
147
196
  waiter.reject(error);
148
197
  }
149
198
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/plugins/dedupe.ts","../../src/dedupeRequestHash.ts"],"sourcesContent":["import type { ClientPlugin, PluginRequestContext } from '../plugins.js'\nimport {\n dedupeRequestHash,\n type DedupeHashParams,\n} from '../dedupeRequestHash.js'\n\ntype Waiter = {\n resolve: (value: Response) => void\n reject: (reason?: unknown) => void\n}\n\ntype DedupeEntry = {\n promise: Promise<Response>\n waiters: Waiter[]\n createdAt: number\n}\n\nexport type DedupePluginOptions = {\n hashFn?: (params: DedupeHashParams) => string | undefined\n ttl?: number\n sweepInterval?: number\n order?: number\n}\n\nfunction contextToHashParams(ctx: PluginRequestContext): DedupeHashParams {\n return {\n method: ctx.request.method,\n url: ctx.request.url,\n body: (ctx.init.body ?? null) as DedupeHashParams['body'],\n headers: ctx.request.headers,\n signal:\n ctx.init.signal === undefined || ctx.init.signal === null\n ? undefined\n : ctx.init.signal,\n requestInit: ctx.init,\n request: ctx.request,\n }\n}\n\nexport function dedupePlugin(options: DedupePluginOptions = {}): ClientPlugin {\n const {\n hashFn = dedupeRequestHash,\n ttl,\n sweepInterval = 5000,\n order = 10,\n } = options\n\n const inFlight = new Map<string, DedupeEntry>()\n let sweeper: ReturnType<typeof setInterval> | undefined\n\n function startSweeper() {\n if (sweeper || typeof ttl !== 'number' || ttl <= 0) return\n sweeper = setInterval(() => {\n const now = Date.now()\n for (const [key, entry] of inFlight.entries()) {\n if (now - entry.createdAt > ttl) {\n inFlight.delete(key)\n }\n }\n if (inFlight.size === 0 && sweeper) {\n clearInterval(sweeper)\n sweeper = undefined\n }\n }, sweepInterval)\n }\n\n function stopSweeperIfIdle() {\n if (inFlight.size === 0 && sweeper) {\n clearInterval(sweeper)\n sweeper = undefined\n }\n }\n\n return {\n name: 'dedupe',\n order,\n wrapDispatch: (next) => async (ctx) => {\n const key = hashFn(contextToHashParams(ctx))\n ctx.state.dedupeKey = key\n\n if (!key) {\n return next(ctx)\n }\n\n const existing = inFlight.get(key)\n if (existing) {\n return new Promise<Response>((resolve, reject) => {\n existing.waiters.push({ resolve, reject })\n })\n }\n\n const waiters: Waiter[] = []\n const actualPromise = next(ctx)\n\n inFlight.set(key, {\n promise: actualPromise,\n waiters,\n createdAt: Date.now(),\n })\n startSweeper()\n\n actualPromise.then(\n (result) => {\n for (const waiter of waiters) {\n waiter.resolve(result.clone())\n }\n },\n (error) => {\n for (const waiter of waiters) {\n waiter.reject(error)\n }\n }\n )\n\n return actualPromise.finally(() => {\n const current = inFlight.get(key)\n if (current?.promise === actualPromise) {\n inFlight.delete(key)\n stopSweeperIfIdle()\n }\n })\n },\n }\n}\n\nexport { dedupeRequestHash }\nexport type { DedupeHashParams }\n","export type DedupeHashParams = {\n method: string\n url: string\n body:\n | string\n | FormData\n | URLSearchParams\n | Blob\n | ArrayBuffer\n | BufferSource\n | null\n | ReadableStream<unknown>\n headers?: Headers | Record<string, string>\n signal?: AbortSignal\n requestInit?: RequestInit\n request?: Request\n}\n\nfunction toBase64(bytes: Uint8Array): string {\n // Use Node Buffer when available, otherwise fall back to btoa for browser-like runtimes.\n const maybeBuffer = (\n globalThis as {\n Buffer?: {\n from(input: Uint8Array): { toString(encoding: string): string }\n }\n }\n ).Buffer\n if (maybeBuffer) {\n return maybeBuffer.from(bytes).toString('base64')\n }\n\n let binary = ''\n for (const byte of bytes) {\n binary += String.fromCharCode(byte)\n }\n if (typeof btoa === 'function') {\n return btoa(binary)\n }\n\n throw new Error('Base64 encoding is not available in this runtime')\n}\n\nexport function dedupeRequestHash(\n params: DedupeHashParams\n): string | undefined {\n const { method, url, body } = params\n let bodyString: string\n if (body instanceof FormData) {\n // Skip deduplication for FormData\n return undefined\n }\n // Skip deduplication for ReadableStream\n if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {\n return undefined\n }\n if (typeof body === 'string') {\n bodyString = body\n } else if (body instanceof URLSearchParams) {\n bodyString = body.toString()\n } else if (body instanceof ArrayBuffer) {\n bodyString = toBase64(new Uint8Array(body))\n } else if (body instanceof Uint8Array) {\n bodyString = toBase64(body)\n } else if (body instanceof Blob) {\n bodyString = `[blob:${body.type}:${body.size}]`\n } else if (body == null) {\n bodyString = ''\n } else {\n try {\n bodyString = JSON.stringify(body)\n } catch {\n bodyString = '[unserializable-body]'\n }\n }\n return `${method.toUpperCase()}|${url}|${bodyString}`\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/plugins/dedupe.ts","../../src/error.ts","../../src/dedupeRequestHash.ts"],"sourcesContent":["import type { ClientPlugin, PluginRequestContext } from '../plugins.js'\nimport { AbortError, TimeoutError } from '../error.js'\nimport {\n dedupeRequestHash,\n type DedupeHashParams,\n} from '../dedupeRequestHash.js'\n\ntype Waiter = {\n resolve: (value: Response) => void\n reject: (reason?: unknown) => void\n cleanup: () => void\n}\n\ntype DedupeEntry = {\n promise: Promise<Response>\n waiters: Waiter[]\n createdAt: number\n}\n\nexport type DedupePluginOptions = {\n hashFn?: (params: DedupeHashParams) => string | undefined\n ttl?: number\n sweepInterval?: number\n order?: number\n}\n\nfunction contextToHashParams(ctx: PluginRequestContext): DedupeHashParams {\n return {\n method: ctx.request.method,\n url: ctx.request.url,\n body: (ctx.init.body ?? ctx.request.body) as DedupeHashParams['body'],\n headers: ctx.request.headers,\n signal:\n ctx.init.signal === undefined || ctx.init.signal === null\n ? undefined\n : ctx.init.signal,\n requestInit: ctx.init,\n request: ctx.request,\n }\n}\n\nexport function dedupePlugin(options: DedupePluginOptions = {}): ClientPlugin {\n const {\n hashFn = dedupeRequestHash,\n ttl,\n sweepInterval = 5000,\n order = 10,\n } = options\n\n const inFlight = new Map<string, DedupeEntry>()\n let sweeper: ReturnType<typeof setInterval> | undefined\n\n function makeCancellationError(ctx: PluginRequestContext): Error {\n const { signals } = ctx.metadata\n if (signals.user?.aborted) {\n return new AbortError('Request was aborted by user', signals.user.reason)\n }\n if (signals.timeout?.aborted) {\n return new TimeoutError('signal timed out', signals.timeout.reason)\n }\n const signal = signals.combined ?? ctx.request.signal\n return new AbortError('Request was aborted', signal.reason)\n }\n\n function startSweeper() {\n if (sweeper || typeof ttl !== 'number' || ttl <= 0) return\n sweeper = setInterval(() => {\n const now = Date.now()\n for (const [key, entry] of inFlight.entries()) {\n if (now - entry.createdAt > ttl) {\n inFlight.delete(key)\n }\n }\n if (inFlight.size === 0 && sweeper) {\n clearInterval(sweeper)\n sweeper = undefined\n }\n }, sweepInterval)\n }\n\n function stopSweeperIfIdle() {\n if (inFlight.size === 0 && sweeper) {\n clearInterval(sweeper)\n sweeper = undefined\n }\n }\n\n return {\n name: 'dedupe',\n order,\n wrapDispatch: (next) => async (ctx) => {\n const key = hashFn(contextToHashParams(ctx))\n ctx.state.dedupeKey = key\n\n if (!key) {\n return next(ctx)\n }\n\n const existing = inFlight.get(key)\n if (existing) {\n return new Promise<Response>((resolve, reject) => {\n const signal = ctx.metadata.signals.combined ?? ctx.request.signal\n const waiter: Waiter = { resolve, reject, cleanup: () => {} }\n const onAbort = () => {\n const index = existing.waiters.indexOf(waiter)\n existing.waiters.splice(index, 1)\n waiter.cleanup()\n reject(makeCancellationError(ctx))\n }\n\n waiter.cleanup = () => {\n signal.removeEventListener('abort', onAbort)\n }\n\n if (signal.aborted) {\n reject(makeCancellationError(ctx))\n return\n }\n\n signal.addEventListener('abort', onAbort, { once: true })\n existing.waiters.push(waiter)\n })\n }\n\n const waiters: Waiter[] = []\n const actualPromise = next(ctx)\n\n inFlight.set(key, {\n promise: actualPromise,\n waiters,\n createdAt: Date.now(),\n })\n startSweeper()\n\n actualPromise.then(\n (result) => {\n for (const waiter of waiters.splice(0)) {\n waiter.cleanup()\n waiter.resolve(result.clone())\n }\n },\n (error) => {\n for (const waiter of waiters.splice(0)) {\n waiter.cleanup()\n waiter.reject(error)\n }\n }\n )\n\n return actualPromise.finally(() => {\n const current = inFlight.get(key)\n if (current?.promise === actualPromise) {\n inFlight.delete(key)\n stopSweeperIfIdle()\n }\n })\n },\n }\n}\n\nexport { dedupeRequestHash }\nexport type { DedupeHashParams }\n","// Base error class to reduce duplication\nclass BaseError extends Error {\n public cause?: unknown\n constructor(name: string, message: string, cause?: unknown) {\n super(message)\n this.name = name\n if (cause !== undefined) this.cause = cause\n }\n}\n\nexport class TimeoutError extends BaseError {\n constructor(message = 'Request timed out', cause?: unknown) {\n super('TimeoutError', message, cause)\n }\n}\n\nexport class CircuitOpenError extends BaseError {\n constructor(message = 'Circuit is open', cause?: unknown) {\n super('CircuitOpenError', message, cause)\n }\n}\n\nexport class BulkheadFullError extends BaseError {\n constructor(message = 'Bulkhead queue is full', cause?: unknown) {\n super('BulkheadFullError', message, cause)\n }\n}\n\nexport class AbortError extends BaseError {\n constructor(message = 'Request was aborted', cause?: unknown) {\n super('AbortError', message, cause)\n }\n}\n\nexport class RetryLimitError extends BaseError {\n constructor(message = 'Retry limit reached', cause?: unknown) {\n super('RetryLimitError', message, cause)\n }\n}\n\nexport class NetworkError extends BaseError {\n constructor(message = 'Network error occurred', cause?: unknown) {\n super('NetworkError', message, cause)\n }\n}\n\nexport class HttpError extends BaseError {\n constructor(message = 'HTTP error occurred', cause?: unknown) {\n super('HttpError', message, cause)\n }\n}\n","export type DedupeHashParams = {\n method: string\n url: string\n body:\n | string\n | FormData\n | URLSearchParams\n | Blob\n | ArrayBuffer\n | BufferSource\n | null\n | ReadableStream<unknown>\n headers?: Headers | Record<string, string>\n signal?: AbortSignal\n requestInit?: RequestInit\n request?: Request\n}\n\nfunction toBase64(bytes: Uint8Array): string {\n // Use Node Buffer when available, otherwise fall back to btoa for browser-like runtimes.\n const maybeBuffer = (\n globalThis as {\n Buffer?: {\n from(input: Uint8Array): { toString(encoding: string): string }\n }\n }\n ).Buffer\n if (maybeBuffer) {\n return maybeBuffer.from(bytes).toString('base64')\n }\n\n let binary = ''\n for (const byte of bytes) {\n binary += String.fromCharCode(byte)\n }\n if (typeof btoa === 'function') {\n return btoa(binary)\n }\n\n throw new Error('Base64 encoding is not available in this runtime')\n}\n\nexport function dedupeRequestHash(\n params: DedupeHashParams\n): string | undefined {\n const { method, url, body } = params\n let bodyString: string\n if (body instanceof FormData) {\n // Skip deduplication for FormData\n return undefined\n }\n // Skip deduplication for ReadableStream\n if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {\n return undefined\n }\n if (typeof body === 'string') {\n bodyString = body\n } else if (body instanceof URLSearchParams) {\n bodyString = body.toString()\n } else if (body instanceof ArrayBuffer) {\n bodyString = toBase64(new Uint8Array(body))\n } else if (body instanceof Uint8Array) {\n bodyString = toBase64(body)\n } else if (body instanceof Blob) {\n bodyString = `[blob:${body.type}:${body.size}]`\n } else if (body == null) {\n bodyString = ''\n } else {\n try {\n bodyString = JSON.stringify(body)\n } catch {\n bodyString = '[unserializable-body]'\n }\n }\n return `${method.toUpperCase()}|${url}|${bodyString}`\n}\n"],"mappings":"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","names":[]}
@@ -1,3 +1,8 @@
1
+ import {
2
+ AbortError,
3
+ TimeoutError
4
+ } from "../chunk-ZIJ2DZYZ.js";
5
+
1
6
  // src/dedupeRequestHash.ts
2
7
  function toBase64(bytes) {
3
8
  const maybeBuffer = globalThis.Buffer;
@@ -49,7 +54,7 @@ function contextToHashParams(ctx) {
49
54
  return {
50
55
  method: ctx.request.method,
51
56
  url: ctx.request.url,
52
- body: ctx.init.body ?? null,
57
+ body: ctx.init.body ?? ctx.request.body,
53
58
  headers: ctx.request.headers,
54
59
  signal: ctx.init.signal === void 0 || ctx.init.signal === null ? void 0 : ctx.init.signal,
55
60
  requestInit: ctx.init,
@@ -65,6 +70,17 @@ function dedupePlugin(options = {}) {
65
70
  } = options;
66
71
  const inFlight = /* @__PURE__ */ new Map();
67
72
  let sweeper;
73
+ function makeCancellationError(ctx) {
74
+ const { signals } = ctx.metadata;
75
+ if (signals.user?.aborted) {
76
+ return new AbortError("Request was aborted by user", signals.user.reason);
77
+ }
78
+ if (signals.timeout?.aborted) {
79
+ return new TimeoutError("signal timed out", signals.timeout.reason);
80
+ }
81
+ const signal = signals.combined ?? ctx.request.signal;
82
+ return new AbortError("Request was aborted", signal.reason);
83
+ }
68
84
  function startSweeper() {
69
85
  if (sweeper || typeof ttl !== "number" || ttl <= 0) return;
70
86
  sweeper = setInterval(() => {
@@ -98,7 +114,24 @@ function dedupePlugin(options = {}) {
98
114
  const existing = inFlight.get(key);
99
115
  if (existing) {
100
116
  return new Promise((resolve, reject) => {
101
- existing.waiters.push({ resolve, reject });
117
+ const signal = ctx.metadata.signals.combined ?? ctx.request.signal;
118
+ const waiter = { resolve, reject, cleanup: () => {
119
+ } };
120
+ const onAbort = () => {
121
+ const index = existing.waiters.indexOf(waiter);
122
+ existing.waiters.splice(index, 1);
123
+ waiter.cleanup();
124
+ reject(makeCancellationError(ctx));
125
+ };
126
+ waiter.cleanup = () => {
127
+ signal.removeEventListener("abort", onAbort);
128
+ };
129
+ if (signal.aborted) {
130
+ reject(makeCancellationError(ctx));
131
+ return;
132
+ }
133
+ signal.addEventListener("abort", onAbort, { once: true });
134
+ existing.waiters.push(waiter);
102
135
  });
103
136
  }
104
137
  const waiters = [];
@@ -111,12 +144,14 @@ function dedupePlugin(options = {}) {
111
144
  startSweeper();
112
145
  actualPromise.then(
113
146
  (result) => {
114
- for (const waiter of waiters) {
147
+ for (const waiter of waiters.splice(0)) {
148
+ waiter.cleanup();
115
149
  waiter.resolve(result.clone());
116
150
  }
117
151
  },
118
152
  (error) => {
119
- for (const waiter of waiters) {
153
+ for (const waiter of waiters.splice(0)) {
154
+ waiter.cleanup();
120
155
  waiter.reject(error);
121
156
  }
122
157
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/dedupeRequestHash.ts","../../src/plugins/dedupe.ts"],"sourcesContent":["export type DedupeHashParams = {\n method: string\n url: string\n body:\n | string\n | FormData\n | URLSearchParams\n | Blob\n | ArrayBuffer\n | BufferSource\n | null\n | ReadableStream<unknown>\n headers?: Headers | Record<string, string>\n signal?: AbortSignal\n requestInit?: RequestInit\n request?: Request\n}\n\nfunction toBase64(bytes: Uint8Array): string {\n // Use Node Buffer when available, otherwise fall back to btoa for browser-like runtimes.\n const maybeBuffer = (\n globalThis as {\n Buffer?: {\n from(input: Uint8Array): { toString(encoding: string): string }\n }\n }\n ).Buffer\n if (maybeBuffer) {\n return maybeBuffer.from(bytes).toString('base64')\n }\n\n let binary = ''\n for (const byte of bytes) {\n binary += String.fromCharCode(byte)\n }\n if (typeof btoa === 'function') {\n return btoa(binary)\n }\n\n throw new Error('Base64 encoding is not available in this runtime')\n}\n\nexport function dedupeRequestHash(\n params: DedupeHashParams\n): string | undefined {\n const { method, url, body } = params\n let bodyString: string\n if (body instanceof FormData) {\n // Skip deduplication for FormData\n return undefined\n }\n // Skip deduplication for ReadableStream\n if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {\n return undefined\n }\n if (typeof body === 'string') {\n bodyString = body\n } else if (body instanceof URLSearchParams) {\n bodyString = body.toString()\n } else if (body instanceof ArrayBuffer) {\n bodyString = toBase64(new Uint8Array(body))\n } else if (body instanceof Uint8Array) {\n bodyString = toBase64(body)\n } else if (body instanceof Blob) {\n bodyString = `[blob:${body.type}:${body.size}]`\n } else if (body == null) {\n bodyString = ''\n } else {\n try {\n bodyString = JSON.stringify(body)\n } catch {\n bodyString = '[unserializable-body]'\n }\n }\n return `${method.toUpperCase()}|${url}|${bodyString}`\n}\n","import type { ClientPlugin, PluginRequestContext } from '../plugins.js'\nimport {\n dedupeRequestHash,\n type DedupeHashParams,\n} from '../dedupeRequestHash.js'\n\ntype Waiter = {\n resolve: (value: Response) => void\n reject: (reason?: unknown) => void\n}\n\ntype DedupeEntry = {\n promise: Promise<Response>\n waiters: Waiter[]\n createdAt: number\n}\n\nexport type DedupePluginOptions = {\n hashFn?: (params: DedupeHashParams) => string | undefined\n ttl?: number\n sweepInterval?: number\n order?: number\n}\n\nfunction contextToHashParams(ctx: PluginRequestContext): DedupeHashParams {\n return {\n method: ctx.request.method,\n url: ctx.request.url,\n body: (ctx.init.body ?? null) as DedupeHashParams['body'],\n headers: ctx.request.headers,\n signal:\n ctx.init.signal === undefined || ctx.init.signal === null\n ? undefined\n : ctx.init.signal,\n requestInit: ctx.init,\n request: ctx.request,\n }\n}\n\nexport function dedupePlugin(options: DedupePluginOptions = {}): ClientPlugin {\n const {\n hashFn = dedupeRequestHash,\n ttl,\n sweepInterval = 5000,\n order = 10,\n } = options\n\n const inFlight = new Map<string, DedupeEntry>()\n let sweeper: ReturnType<typeof setInterval> | undefined\n\n function startSweeper() {\n if (sweeper || typeof ttl !== 'number' || ttl <= 0) return\n sweeper = setInterval(() => {\n const now = Date.now()\n for (const [key, entry] of inFlight.entries()) {\n if (now - entry.createdAt > ttl) {\n inFlight.delete(key)\n }\n }\n if (inFlight.size === 0 && sweeper) {\n clearInterval(sweeper)\n sweeper = undefined\n }\n }, sweepInterval)\n }\n\n function stopSweeperIfIdle() {\n if (inFlight.size === 0 && sweeper) {\n clearInterval(sweeper)\n sweeper = undefined\n }\n }\n\n return {\n name: 'dedupe',\n order,\n wrapDispatch: (next) => async (ctx) => {\n const key = hashFn(contextToHashParams(ctx))\n ctx.state.dedupeKey = key\n\n if (!key) {\n return next(ctx)\n }\n\n const existing = inFlight.get(key)\n if (existing) {\n return new Promise<Response>((resolve, reject) => {\n existing.waiters.push({ resolve, reject })\n })\n }\n\n const waiters: Waiter[] = []\n const actualPromise = next(ctx)\n\n inFlight.set(key, {\n promise: actualPromise,\n waiters,\n createdAt: Date.now(),\n })\n startSweeper()\n\n actualPromise.then(\n (result) => {\n for (const waiter of waiters) {\n waiter.resolve(result.clone())\n }\n },\n (error) => {\n for (const waiter of waiters) {\n waiter.reject(error)\n }\n }\n )\n\n return actualPromise.finally(() => {\n const current = inFlight.get(key)\n if (current?.promise === actualPromise) {\n inFlight.delete(key)\n stopSweeperIfIdle()\n }\n })\n },\n }\n}\n\nexport { dedupeRequestHash }\nexport type { DedupeHashParams }\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/dedupeRequestHash.ts","../../src/plugins/dedupe.ts"],"sourcesContent":["export type DedupeHashParams = {\n method: string\n url: string\n body:\n | string\n | FormData\n | URLSearchParams\n | Blob\n | ArrayBuffer\n | BufferSource\n | null\n | ReadableStream<unknown>\n headers?: Headers | Record<string, string>\n signal?: AbortSignal\n requestInit?: RequestInit\n request?: Request\n}\n\nfunction toBase64(bytes: Uint8Array): string {\n // Use Node Buffer when available, otherwise fall back to btoa for browser-like runtimes.\n const maybeBuffer = (\n globalThis as {\n Buffer?: {\n from(input: Uint8Array): { toString(encoding: string): string }\n }\n }\n ).Buffer\n if (maybeBuffer) {\n return maybeBuffer.from(bytes).toString('base64')\n }\n\n let binary = ''\n for (const byte of bytes) {\n binary += String.fromCharCode(byte)\n }\n if (typeof btoa === 'function') {\n return btoa(binary)\n }\n\n throw new Error('Base64 encoding is not available in this runtime')\n}\n\nexport function dedupeRequestHash(\n params: DedupeHashParams\n): string | undefined {\n const { method, url, body } = params\n let bodyString: string\n if (body instanceof FormData) {\n // Skip deduplication for FormData\n return undefined\n }\n // Skip deduplication for ReadableStream\n if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {\n return undefined\n }\n if (typeof body === 'string') {\n bodyString = body\n } else if (body instanceof URLSearchParams) {\n bodyString = body.toString()\n } else if (body instanceof ArrayBuffer) {\n bodyString = toBase64(new Uint8Array(body))\n } else if (body instanceof Uint8Array) {\n bodyString = toBase64(body)\n } else if (body instanceof Blob) {\n bodyString = `[blob:${body.type}:${body.size}]`\n } else if (body == null) {\n bodyString = ''\n } else {\n try {\n bodyString = JSON.stringify(body)\n } catch {\n bodyString = '[unserializable-body]'\n }\n }\n return `${method.toUpperCase()}|${url}|${bodyString}`\n}\n","import type { ClientPlugin, PluginRequestContext } from '../plugins.js'\nimport { AbortError, TimeoutError } from '../error.js'\nimport {\n dedupeRequestHash,\n type DedupeHashParams,\n} from '../dedupeRequestHash.js'\n\ntype Waiter = {\n resolve: (value: Response) => void\n reject: (reason?: unknown) => void\n cleanup: () => void\n}\n\ntype DedupeEntry = {\n promise: Promise<Response>\n waiters: Waiter[]\n createdAt: number\n}\n\nexport type DedupePluginOptions = {\n hashFn?: (params: DedupeHashParams) => string | undefined\n ttl?: number\n sweepInterval?: number\n order?: number\n}\n\nfunction contextToHashParams(ctx: PluginRequestContext): DedupeHashParams {\n return {\n method: ctx.request.method,\n url: ctx.request.url,\n body: (ctx.init.body ?? ctx.request.body) as DedupeHashParams['body'],\n headers: ctx.request.headers,\n signal:\n ctx.init.signal === undefined || ctx.init.signal === null\n ? undefined\n : ctx.init.signal,\n requestInit: ctx.init,\n request: ctx.request,\n }\n}\n\nexport function dedupePlugin(options: DedupePluginOptions = {}): ClientPlugin {\n const {\n hashFn = dedupeRequestHash,\n ttl,\n sweepInterval = 5000,\n order = 10,\n } = options\n\n const inFlight = new Map<string, DedupeEntry>()\n let sweeper: ReturnType<typeof setInterval> | undefined\n\n function makeCancellationError(ctx: PluginRequestContext): Error {\n const { signals } = ctx.metadata\n if (signals.user?.aborted) {\n return new AbortError('Request was aborted by user', signals.user.reason)\n }\n if (signals.timeout?.aborted) {\n return new TimeoutError('signal timed out', signals.timeout.reason)\n }\n const signal = signals.combined ?? ctx.request.signal\n return new AbortError('Request was aborted', signal.reason)\n }\n\n function startSweeper() {\n if (sweeper || typeof ttl !== 'number' || ttl <= 0) return\n sweeper = setInterval(() => {\n const now = Date.now()\n for (const [key, entry] of inFlight.entries()) {\n if (now - entry.createdAt > ttl) {\n inFlight.delete(key)\n }\n }\n if (inFlight.size === 0 && sweeper) {\n clearInterval(sweeper)\n sweeper = undefined\n }\n }, sweepInterval)\n }\n\n function stopSweeperIfIdle() {\n if (inFlight.size === 0 && sweeper) {\n clearInterval(sweeper)\n sweeper = undefined\n }\n }\n\n return {\n name: 'dedupe',\n order,\n wrapDispatch: (next) => async (ctx) => {\n const key = hashFn(contextToHashParams(ctx))\n ctx.state.dedupeKey = key\n\n if (!key) {\n return next(ctx)\n }\n\n const existing = inFlight.get(key)\n if (existing) {\n return new Promise<Response>((resolve, reject) => {\n const signal = ctx.metadata.signals.combined ?? ctx.request.signal\n const waiter: Waiter = { resolve, reject, cleanup: () => {} }\n const onAbort = () => {\n const index = existing.waiters.indexOf(waiter)\n existing.waiters.splice(index, 1)\n waiter.cleanup()\n reject(makeCancellationError(ctx))\n }\n\n waiter.cleanup = () => {\n signal.removeEventListener('abort', onAbort)\n }\n\n if (signal.aborted) {\n reject(makeCancellationError(ctx))\n return\n }\n\n signal.addEventListener('abort', onAbort, { once: true })\n existing.waiters.push(waiter)\n })\n }\n\n const waiters: Waiter[] = []\n const actualPromise = next(ctx)\n\n inFlight.set(key, {\n promise: actualPromise,\n waiters,\n createdAt: Date.now(),\n })\n startSweeper()\n\n actualPromise.then(\n (result) => {\n for (const waiter of waiters.splice(0)) {\n waiter.cleanup()\n waiter.resolve(result.clone())\n }\n },\n (error) => {\n for (const waiter of waiters.splice(0)) {\n waiter.cleanup()\n waiter.reject(error)\n }\n }\n )\n\n return actualPromise.finally(() => {\n const current = inFlight.get(key)\n if (current?.promise === actualPromise) {\n inFlight.delete(key)\n stopSweeperIfIdle()\n }\n })\n },\n }\n}\n\nexport { dedupeRequestHash }\nexport type { DedupeHashParams }\n"],"mappings":"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","names":[]}
@@ -32,13 +32,14 @@ function downloadProgressPlugin(onProgress) {
32
32
  return response;
33
33
  }
34
34
  const contentLength = response.headers.get("content-length");
35
- const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
35
+ const parsedContentLength = contentLength && /^\d+$/.test(contentLength) ? Number(contentLength) : 0;
36
+ const totalBytes = Number.isSafeInteger(parsedContentLength) ? parsedContentLength : 0;
36
37
  let transferredBytes = 0;
37
38
  const stream = response.body.pipeThrough(
38
39
  new TransformStream({
39
40
  transform(chunk, controller) {
40
41
  transferredBytes += chunk.byteLength;
41
- const percent = totalBytes > 0 ? transferredBytes / totalBytes : 0;
42
+ const percent = totalBytes > 0 ? Math.min(transferredBytes / totalBytes, 1) : 0;
42
43
  onProgress({ percent, transferredBytes, totalBytes }, chunk);
43
44
  controller.enqueue(chunk);
44
45
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/plugins/download-progress.ts"],"sourcesContent":["import type { ClientPlugin } from '../plugins.js'\n\nexport type DownloadProgressEvent = {\n percent: number\n transferredBytes: number\n totalBytes: number\n}\n\nexport type DownloadProgressCallback = (\n progress: DownloadProgressEvent,\n chunk: Uint8Array\n) => void\n\nexport function downloadProgressPlugin(\n onProgress: DownloadProgressCallback\n): ClientPlugin {\n return {\n name: 'downloadProgress',\n wrapDispatch: (next) => async (ctx) => {\n const response = await next(ctx)\n\n if (!response.body) {\n return response\n }\n\n const contentLength = response.headers.get('content-length')\n const totalBytes = contentLength ? parseInt(contentLength, 10) : 0\n let transferredBytes = 0\n\n const stream = response.body.pipeThrough(\n new TransformStream<Uint8Array, Uint8Array>({\n transform(chunk, controller) {\n transferredBytes += chunk.byteLength\n const percent = totalBytes > 0 ? transferredBytes / totalBytes : 0\n onProgress({ percent, transferredBytes, totalBytes }, chunk)\n controller.enqueue(chunk)\n },\n })\n )\n\n return new Response(stream, {\n status: response.status,\n statusText: response.statusText,\n headers: response.headers,\n })\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAaO,SAAS,uBACd,YACc;AACd,SAAO;AAAA,IACL,MAAM;AAAA,IACN,cAAc,CAAC,SAAS,OAAO,QAAQ;AACrC,YAAM,WAAW,MAAM,KAAK,GAAG;AAE/B,UAAI,CAAC,SAAS,MAAM;AAClB,eAAO;AAAA,MACT;AAEA,YAAM,gBAAgB,SAAS,QAAQ,IAAI,gBAAgB;AAC3D,YAAM,aAAa,gBAAgB,SAAS,eAAe,EAAE,IAAI;AACjE,UAAI,mBAAmB;AAEvB,YAAM,SAAS,SAAS,KAAK;AAAA,QAC3B,IAAI,gBAAwC;AAAA,UAC1C,UAAU,OAAO,YAAY;AAC3B,gCAAoB,MAAM;AAC1B,kBAAM,UAAU,aAAa,IAAI,mBAAmB,aAAa;AACjE,uBAAW,EAAE,SAAS,kBAAkB,WAAW,GAAG,KAAK;AAC3D,uBAAW,QAAQ,KAAK;AAAA,UAC1B;AAAA,QACF,CAAC;AAAA,MACH;AAEA,aAAO,IAAI,SAAS,QAAQ;AAAA,QAC1B,QAAQ,SAAS;AAAA,QACjB,YAAY,SAAS;AAAA,QACrB,SAAS,SAAS;AAAA,MACpB,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/plugins/download-progress.ts"],"sourcesContent":["import type { ClientPlugin } from '../plugins.js'\n\nexport type DownloadProgressEvent = {\n percent: number\n transferredBytes: number\n totalBytes: number\n}\n\nexport type DownloadProgressCallback = (\n progress: DownloadProgressEvent,\n chunk: Uint8Array\n) => void\n\nexport function downloadProgressPlugin(\n onProgress: DownloadProgressCallback\n): ClientPlugin {\n return {\n name: 'downloadProgress',\n wrapDispatch: (next) => async (ctx) => {\n const response = await next(ctx)\n\n if (!response.body) {\n return response\n }\n\n const contentLength = response.headers.get('content-length')\n const parsedContentLength =\n contentLength && /^\\d+$/.test(contentLength) ? Number(contentLength) : 0\n const totalBytes = Number.isSafeInteger(parsedContentLength)\n ? parsedContentLength\n : 0\n let transferredBytes = 0\n\n const stream = response.body.pipeThrough(\n new TransformStream<Uint8Array, Uint8Array>({\n transform(chunk, controller) {\n transferredBytes += chunk.byteLength\n const percent =\n totalBytes > 0 ? Math.min(transferredBytes / totalBytes, 1) : 0\n onProgress({ percent, transferredBytes, totalBytes }, chunk)\n controller.enqueue(chunk)\n },\n })\n )\n\n return new Response(stream, {\n status: response.status,\n statusText: response.statusText,\n headers: response.headers,\n })\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAaO,SAAS,uBACd,YACc;AACd,SAAO;AAAA,IACL,MAAM;AAAA,IACN,cAAc,CAAC,SAAS,OAAO,QAAQ;AACrC,YAAM,WAAW,MAAM,KAAK,GAAG;AAE/B,UAAI,CAAC,SAAS,MAAM;AAClB,eAAO;AAAA,MACT;AAEA,YAAM,gBAAgB,SAAS,QAAQ,IAAI,gBAAgB;AAC3D,YAAM,sBACJ,iBAAiB,QAAQ,KAAK,aAAa,IAAI,OAAO,aAAa,IAAI;AACzE,YAAM,aAAa,OAAO,cAAc,mBAAmB,IACvD,sBACA;AACJ,UAAI,mBAAmB;AAEvB,YAAM,SAAS,SAAS,KAAK;AAAA,QAC3B,IAAI,gBAAwC;AAAA,UAC1C,UAAU,OAAO,YAAY;AAC3B,gCAAoB,MAAM;AAC1B,kBAAM,UACJ,aAAa,IAAI,KAAK,IAAI,mBAAmB,YAAY,CAAC,IAAI;AAChE,uBAAW,EAAE,SAAS,kBAAkB,WAAW,GAAG,KAAK;AAC3D,uBAAW,QAAQ,KAAK;AAAA,UAC1B;AAAA,QACF,CAAC;AAAA,MACH;AAEA,aAAO,IAAI,SAAS,QAAQ;AAAA,QAC1B,QAAQ,SAAS;AAAA,QACjB,YAAY,SAAS;AAAA,QACrB,SAAS,SAAS;AAAA,MACpB,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}
@@ -8,13 +8,14 @@ function downloadProgressPlugin(onProgress) {
8
8
  return response;
9
9
  }
10
10
  const contentLength = response.headers.get("content-length");
11
- const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
11
+ const parsedContentLength = contentLength && /^\d+$/.test(contentLength) ? Number(contentLength) : 0;
12
+ const totalBytes = Number.isSafeInteger(parsedContentLength) ? parsedContentLength : 0;
12
13
  let transferredBytes = 0;
13
14
  const stream = response.body.pipeThrough(
14
15
  new TransformStream({
15
16
  transform(chunk, controller) {
16
17
  transferredBytes += chunk.byteLength;
17
- const percent = totalBytes > 0 ? transferredBytes / totalBytes : 0;
18
+ const percent = totalBytes > 0 ? Math.min(transferredBytes / totalBytes, 1) : 0;
18
19
  onProgress({ percent, transferredBytes, totalBytes }, chunk);
19
20
  controller.enqueue(chunk);
20
21
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/plugins/download-progress.ts"],"sourcesContent":["import type { ClientPlugin } from '../plugins.js'\n\nexport type DownloadProgressEvent = {\n percent: number\n transferredBytes: number\n totalBytes: number\n}\n\nexport type DownloadProgressCallback = (\n progress: DownloadProgressEvent,\n chunk: Uint8Array\n) => void\n\nexport function downloadProgressPlugin(\n onProgress: DownloadProgressCallback\n): ClientPlugin {\n return {\n name: 'downloadProgress',\n wrapDispatch: (next) => async (ctx) => {\n const response = await next(ctx)\n\n if (!response.body) {\n return response\n }\n\n const contentLength = response.headers.get('content-length')\n const totalBytes = contentLength ? parseInt(contentLength, 10) : 0\n let transferredBytes = 0\n\n const stream = response.body.pipeThrough(\n new TransformStream<Uint8Array, Uint8Array>({\n transform(chunk, controller) {\n transferredBytes += chunk.byteLength\n const percent = totalBytes > 0 ? transferredBytes / totalBytes : 0\n onProgress({ percent, transferredBytes, totalBytes }, chunk)\n controller.enqueue(chunk)\n },\n })\n )\n\n return new Response(stream, {\n status: response.status,\n statusText: response.statusText,\n headers: response.headers,\n })\n },\n }\n}\n"],"mappings":";AAaO,SAAS,uBACd,YACc;AACd,SAAO;AAAA,IACL,MAAM;AAAA,IACN,cAAc,CAAC,SAAS,OAAO,QAAQ;AACrC,YAAM,WAAW,MAAM,KAAK,GAAG;AAE/B,UAAI,CAAC,SAAS,MAAM;AAClB,eAAO;AAAA,MACT;AAEA,YAAM,gBAAgB,SAAS,QAAQ,IAAI,gBAAgB;AAC3D,YAAM,aAAa,gBAAgB,SAAS,eAAe,EAAE,IAAI;AACjE,UAAI,mBAAmB;AAEvB,YAAM,SAAS,SAAS,KAAK;AAAA,QAC3B,IAAI,gBAAwC;AAAA,UAC1C,UAAU,OAAO,YAAY;AAC3B,gCAAoB,MAAM;AAC1B,kBAAM,UAAU,aAAa,IAAI,mBAAmB,aAAa;AACjE,uBAAW,EAAE,SAAS,kBAAkB,WAAW,GAAG,KAAK;AAC3D,uBAAW,QAAQ,KAAK;AAAA,UAC1B;AAAA,QACF,CAAC;AAAA,MACH;AAEA,aAAO,IAAI,SAAS,QAAQ;AAAA,QAC1B,QAAQ,SAAS;AAAA,QACjB,YAAY,SAAS;AAAA,QACrB,SAAS,SAAS;AAAA,MACpB,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/plugins/download-progress.ts"],"sourcesContent":["import type { ClientPlugin } from '../plugins.js'\n\nexport type DownloadProgressEvent = {\n percent: number\n transferredBytes: number\n totalBytes: number\n}\n\nexport type DownloadProgressCallback = (\n progress: DownloadProgressEvent,\n chunk: Uint8Array\n) => void\n\nexport function downloadProgressPlugin(\n onProgress: DownloadProgressCallback\n): ClientPlugin {\n return {\n name: 'downloadProgress',\n wrapDispatch: (next) => async (ctx) => {\n const response = await next(ctx)\n\n if (!response.body) {\n return response\n }\n\n const contentLength = response.headers.get('content-length')\n const parsedContentLength =\n contentLength && /^\\d+$/.test(contentLength) ? Number(contentLength) : 0\n const totalBytes = Number.isSafeInteger(parsedContentLength)\n ? parsedContentLength\n : 0\n let transferredBytes = 0\n\n const stream = response.body.pipeThrough(\n new TransformStream<Uint8Array, Uint8Array>({\n transform(chunk, controller) {\n transferredBytes += chunk.byteLength\n const percent =\n totalBytes > 0 ? Math.min(transferredBytes / totalBytes, 1) : 0\n onProgress({ percent, transferredBytes, totalBytes }, chunk)\n controller.enqueue(chunk)\n },\n })\n )\n\n return new Response(stream, {\n status: response.status,\n statusText: response.statusText,\n headers: response.headers,\n })\n },\n }\n}\n"],"mappings":";AAaO,SAAS,uBACd,YACc;AACd,SAAO;AAAA,IACL,MAAM;AAAA,IACN,cAAc,CAAC,SAAS,OAAO,QAAQ;AACrC,YAAM,WAAW,MAAM,KAAK,GAAG;AAE/B,UAAI,CAAC,SAAS,MAAM;AAClB,eAAO;AAAA,MACT;AAEA,YAAM,gBAAgB,SAAS,QAAQ,IAAI,gBAAgB;AAC3D,YAAM,sBACJ,iBAAiB,QAAQ,KAAK,aAAa,IAAI,OAAO,aAAa,IAAI;AACzE,YAAM,aAAa,OAAO,cAAc,mBAAmB,IACvD,sBACA;AACJ,UAAI,mBAAmB;AAEvB,YAAM,SAAS,SAAS,KAAK;AAAA,QAC3B,IAAI,gBAAwC;AAAA,UAC1C,UAAU,OAAO,YAAY;AAC3B,gCAAoB,MAAM;AAC1B,kBAAM,UACJ,aAAa,IAAI,KAAK,IAAI,mBAAmB,YAAY,CAAC,IAAI;AAChE,uBAAW,EAAE,SAAS,kBAAkB,WAAW,GAAG,KAAK;AAC3D,uBAAW,QAAQ,KAAK;AAAA,UAC1B;AAAA,QACF,CAAC;AAAA,MACH;AAEA,aAAO,IAAI,SAAS,QAAQ;AAAA,QAC1B,QAAQ,SAAS;AAAA,QACjB,YAAY,SAAS;AAAA,QACrB,SAAS,SAAS;AAAA,MACpB,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}
@@ -45,8 +45,9 @@ function hedgePlugin(options) {
45
45
  function launch(attemptIndex) {
46
46
  const controller = new AbortController();
47
47
  controllers.push(controller);
48
- const signal = AbortSignal.any([ctx.request.signal, controller.signal]);
49
- const req = new Request(ctx.request, { signal });
48
+ const parentSignal = ctx.metadata.signals.combined ?? ctx.request.signal;
49
+ const signal = AbortSignal.any([parentSignal, controller.signal]);
50
+ const req = new Request(ctx.request.clone(), { signal });
50
51
  attempts.push(next({ ...ctx, request: req }));
51
52
  if (attemptIndex > 0) {
52
53
  onHedge?.(ctx.request, attemptIndex);
@@ -61,6 +62,9 @@ function hedgePlugin(options) {
61
62
  return new Promise((resolve, reject) => {
62
63
  let settled = false;
63
64
  let launched = 1;
65
+ let completed = 0;
66
+ let fallbackResponse;
67
+ let lastError;
64
68
  const timers = [];
65
69
  function settle(winnerIndex, value) {
66
70
  settled = true;
@@ -68,37 +72,33 @@ function hedgePlugin(options) {
68
72
  abortLosers(winnerIndex);
69
73
  resolve(value);
70
74
  }
71
- function tryReject(err) {
72
- Promise.allSettled(attempts).then((results) => {
73
- if (settled) return;
75
+ function finishWithoutWinner() {
76
+ if (settled || completed < launched) return;
77
+ const allAttemptsLaunched = launched >= maxHedges + 1;
78
+ if (fallbackResponse && allAttemptsLaunched) {
79
+ settle(attempts.length - 1, fallbackResponse);
80
+ return;
81
+ }
82
+ if (!fallbackResponse && lastError !== void 0) {
74
83
  settled = true;
75
84
  timers.forEach(clearTimeout);
76
- let lastError = err;
77
- for (const r of results) {
78
- if (r.status === "rejected") lastError = r.reason;
79
- }
80
85
  reject(lastError);
81
- });
86
+ }
82
87
  }
83
88
  function onAttemptSettled(index, result) {
84
89
  if (settled) return;
90
+ completed++;
85
91
  if (result.status === "fulfilled") {
86
92
  const res = result.value;
87
- const isLastAttempt = launched >= maxHedges + 1 && index === attempts.length - 1;
88
- if (res.ok || res.status < 500 && res.status !== 429 || isLastAttempt) {
93
+ if (res.ok || res.status < 500 && res.status !== 429) {
89
94
  settle(index, res);
95
+ return;
90
96
  }
97
+ fallbackResponse = res;
91
98
  } else {
92
- Promise.allSettled(attempts).then((results) => {
93
- if (settled) return;
94
- const pending = results.some(
95
- (r) => r.status === "pending"
96
- );
97
- if (!pending) {
98
- tryReject(result.reason);
99
- }
100
- });
99
+ lastError = result.reason;
101
100
  }
101
+ finishWithoutWinner();
102
102
  }
103
103
  function watchAttempt(index) {
104
104
  attempts[index].then(
@@ -110,9 +110,9 @@ function hedgePlugin(options) {
110
110
  for (let h = 1; h <= maxHedges; h++) {
111
111
  const hedgeIndex = h;
112
112
  const t = setTimeout(() => {
113
+ launched++;
113
114
  launch(hedgeIndex);
114
115
  watchAttempt(hedgeIndex);
115
- launched++;
116
116
  }, delayMs * hedgeIndex);
117
117
  timers.push(t);
118
118
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/plugins/hedge.ts"],"sourcesContent":["import type { ClientPlugin } from '../plugins.js'\n\nconst SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'])\n\nexport type HedgePluginOptions = {\n delay: number | ((req: Request) => number)\n maxHedges?: number\n shouldHedge?: (req: Request) => boolean\n onHedge?: (req: Request, attempt: number) => void | Promise<void>\n order?: number\n}\n\nexport function hedgePlugin(options: HedgePluginOptions): ClientPlugin {\n const {\n delay,\n maxHedges = 1,\n shouldHedge = (req) => SAFE_METHODS.has(req.method),\n onHedge,\n order = 15,\n } = options\n\n return {\n name: 'hedge',\n order,\n wrapDispatch: (next) => async (ctx) => {\n if (!shouldHedge(ctx.request)) {\n return next(ctx)\n }\n\n const delayMs = typeof delay === 'function' ? delay(ctx.request) : delay\n\n const controllers: AbortController[] = []\n const attempts: Promise<Response>[] = []\n\n function launch(attemptIndex: number): void {\n const controller = new AbortController()\n controllers.push(controller)\n\n const signal = AbortSignal.any([ctx.request.signal, controller.signal])\n\n const req = new Request(ctx.request, { signal })\n attempts.push(next({ ...ctx, request: req }))\n\n if (attemptIndex > 0) {\n onHedge?.(ctx.request, attemptIndex)\n }\n }\n\n function abortLosers(winnerIndex: number): void {\n controllers.forEach((c, i) => {\n if (i !== winnerIndex) c.abort()\n })\n }\n\n // Launch initial attempt\n launch(0)\n\n return new Promise<Response>((resolve, reject) => {\n let settled = false\n let launched = 1\n const timers: ReturnType<typeof setTimeout>[] = []\n\n function settle(winnerIndex: number, value: Response): void {\n settled = true\n timers.forEach(clearTimeout)\n abortLosers(winnerIndex)\n resolve(value)\n }\n\n function tryReject(err: unknown): void {\n Promise.allSettled(attempts).then((results) => {\n if (settled) return\n settled = true\n timers.forEach(clearTimeout)\n let lastError = err\n for (const r of results) {\n if (r.status === 'rejected') lastError = r.reason\n }\n reject(lastError)\n })\n }\n\n function onAttemptSettled(\n index: number,\n result: PromiseSettledResult<Response>\n ): void {\n if (settled) return\n\n if (result.status === 'fulfilled') {\n const res = result.value\n // Treat 5xx and 429 as non-winners unless it's the last attempt\n const isLastAttempt =\n launched >= maxHedges + 1 && index === attempts.length - 1\n if (\n res.ok ||\n (res.status < 500 && res.status !== 429) ||\n isLastAttempt\n ) {\n settle(index, res)\n }\n // Otherwise wait for other attempts; if all settle without a winner\n // the last settler will resolve with whatever is left\n } else {\n // Check if all currently launched attempts have settled\n Promise.allSettled(attempts).then((results) => {\n if (settled) return\n const pending = results.some(\n (r) => r.status === ('pending' as never)\n )\n if (!pending) {\n tryReject(result.reason)\n }\n })\n }\n }\n\n function watchAttempt(index: number): void {\n attempts[index].then(\n (res) =>\n onAttemptSettled(index, { status: 'fulfilled', value: res }),\n (err) =>\n onAttemptSettled(index, { status: 'rejected', reason: err })\n )\n }\n\n watchAttempt(0)\n\n for (let h = 1; h <= maxHedges; h++) {\n const hedgeIndex = h\n const t = setTimeout(() => {\n launch(hedgeIndex)\n watchAttempt(hedgeIndex)\n launched++\n }, delayMs * hedgeIndex)\n timers.push(t)\n }\n })\n },\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/plugins/hedge.ts"],"sourcesContent":["import type { ClientPlugin } from '../plugins.js'\n\nconst SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'])\n\nexport type HedgePluginOptions = {\n delay: number | ((req: Request) => number)\n maxHedges?: number\n shouldHedge?: (req: Request) => boolean\n onHedge?: (req: Request, attempt: number) => void | Promise<void>\n order?: number\n}\n\nexport function hedgePlugin(options: HedgePluginOptions): ClientPlugin {\n const {\n delay,\n maxHedges = 1,\n shouldHedge = (req) => SAFE_METHODS.has(req.method),\n onHedge,\n order = 15,\n } = options\n\n return {\n name: 'hedge',\n order,\n wrapDispatch: (next) => async (ctx) => {\n if (!shouldHedge(ctx.request)) {\n return next(ctx)\n }\n\n const delayMs = typeof delay === 'function' ? delay(ctx.request) : delay\n\n const controllers: AbortController[] = []\n const attempts: Promise<Response>[] = []\n\n function launch(attemptIndex: number): void {\n const controller = new AbortController()\n controllers.push(controller)\n\n const parentSignal = ctx.metadata.signals.combined ?? ctx.request.signal\n const signal = AbortSignal.any([parentSignal, controller.signal])\n\n const req = new Request(ctx.request.clone(), { signal })\n attempts.push(next({ ...ctx, request: req }))\n\n if (attemptIndex > 0) {\n onHedge?.(ctx.request, attemptIndex)\n }\n }\n\n function abortLosers(winnerIndex: number): void {\n controllers.forEach((c, i) => {\n if (i !== winnerIndex) c.abort()\n })\n }\n\n // Launch initial attempt\n launch(0)\n\n return new Promise<Response>((resolve, reject) => {\n let settled = false\n let launched = 1\n let completed = 0\n let fallbackResponse: Response | undefined\n let lastError: unknown\n const timers: ReturnType<typeof setTimeout>[] = []\n\n function settle(winnerIndex: number, value: Response): void {\n settled = true\n timers.forEach(clearTimeout)\n abortLosers(winnerIndex)\n resolve(value)\n }\n\n function finishWithoutWinner(): void {\n if (settled || completed < launched) return\n\n const allAttemptsLaunched = launched >= maxHedges + 1\n if (fallbackResponse && allAttemptsLaunched) {\n settle(attempts.length - 1, fallbackResponse)\n return\n }\n\n // Preserve the existing fail-fast behavior for transport errors. A\n // retryable HTTP response, however, waits for all scheduled hedges.\n if (!fallbackResponse && lastError !== undefined) {\n settled = true\n timers.forEach(clearTimeout)\n reject(lastError)\n }\n }\n\n function onAttemptSettled(\n index: number,\n result: PromiseSettledResult<Response>\n ): void {\n if (settled) return\n\n completed++\n\n if (result.status === 'fulfilled') {\n const res = result.value\n // A retryable response is only a fallback. Its attempt index says\n // when it was launched, not whether a better attempt is pending.\n if (res.ok || (res.status < 500 && res.status !== 429)) {\n settle(index, res)\n return\n }\n fallbackResponse = res\n } else {\n lastError = result.reason\n }\n\n finishWithoutWinner()\n }\n\n function watchAttempt(index: number): void {\n attempts[index].then(\n (res) =>\n onAttemptSettled(index, { status: 'fulfilled', value: res }),\n (err) =>\n onAttemptSettled(index, { status: 'rejected', reason: err })\n )\n }\n\n watchAttempt(0)\n\n for (let h = 1; h <= maxHedges; h++) {\n const hedgeIndex = h\n const t = setTimeout(() => {\n launched++\n launch(hedgeIndex)\n watchAttempt(hedgeIndex)\n }, delayMs * hedgeIndex)\n timers.push(t)\n }\n })\n },\n }\n}\n"],"mappings":"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","names":[]}
@@ -21,8 +21,9 @@ function hedgePlugin(options) {
21
21
  function launch(attemptIndex) {
22
22
  const controller = new AbortController();
23
23
  controllers.push(controller);
24
- const signal = AbortSignal.any([ctx.request.signal, controller.signal]);
25
- const req = new Request(ctx.request, { signal });
24
+ const parentSignal = ctx.metadata.signals.combined ?? ctx.request.signal;
25
+ const signal = AbortSignal.any([parentSignal, controller.signal]);
26
+ const req = new Request(ctx.request.clone(), { signal });
26
27
  attempts.push(next({ ...ctx, request: req }));
27
28
  if (attemptIndex > 0) {
28
29
  onHedge?.(ctx.request, attemptIndex);
@@ -37,6 +38,9 @@ function hedgePlugin(options) {
37
38
  return new Promise((resolve, reject) => {
38
39
  let settled = false;
39
40
  let launched = 1;
41
+ let completed = 0;
42
+ let fallbackResponse;
43
+ let lastError;
40
44
  const timers = [];
41
45
  function settle(winnerIndex, value) {
42
46
  settled = true;
@@ -44,37 +48,33 @@ function hedgePlugin(options) {
44
48
  abortLosers(winnerIndex);
45
49
  resolve(value);
46
50
  }
47
- function tryReject(err) {
48
- Promise.allSettled(attempts).then((results) => {
49
- if (settled) return;
51
+ function finishWithoutWinner() {
52
+ if (settled || completed < launched) return;
53
+ const allAttemptsLaunched = launched >= maxHedges + 1;
54
+ if (fallbackResponse && allAttemptsLaunched) {
55
+ settle(attempts.length - 1, fallbackResponse);
56
+ return;
57
+ }
58
+ if (!fallbackResponse && lastError !== void 0) {
50
59
  settled = true;
51
60
  timers.forEach(clearTimeout);
52
- let lastError = err;
53
- for (const r of results) {
54
- if (r.status === "rejected") lastError = r.reason;
55
- }
56
61
  reject(lastError);
57
- });
62
+ }
58
63
  }
59
64
  function onAttemptSettled(index, result) {
60
65
  if (settled) return;
66
+ completed++;
61
67
  if (result.status === "fulfilled") {
62
68
  const res = result.value;
63
- const isLastAttempt = launched >= maxHedges + 1 && index === attempts.length - 1;
64
- if (res.ok || res.status < 500 && res.status !== 429 || isLastAttempt) {
69
+ if (res.ok || res.status < 500 && res.status !== 429) {
65
70
  settle(index, res);
71
+ return;
66
72
  }
73
+ fallbackResponse = res;
67
74
  } else {
68
- Promise.allSettled(attempts).then((results) => {
69
- if (settled) return;
70
- const pending = results.some(
71
- (r) => r.status === "pending"
72
- );
73
- if (!pending) {
74
- tryReject(result.reason);
75
- }
76
- });
75
+ lastError = result.reason;
77
76
  }
77
+ finishWithoutWinner();
78
78
  }
79
79
  function watchAttempt(index) {
80
80
  attempts[index].then(
@@ -86,9 +86,9 @@ function hedgePlugin(options) {
86
86
  for (let h = 1; h <= maxHedges; h++) {
87
87
  const hedgeIndex = h;
88
88
  const t = setTimeout(() => {
89
+ launched++;
89
90
  launch(hedgeIndex);
90
91
  watchAttempt(hedgeIndex);
91
- launched++;
92
92
  }, delayMs * hedgeIndex);
93
93
  timers.push(t);
94
94
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/plugins/hedge.ts"],"sourcesContent":["import type { ClientPlugin } from '../plugins.js'\n\nconst SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'])\n\nexport type HedgePluginOptions = {\n delay: number | ((req: Request) => number)\n maxHedges?: number\n shouldHedge?: (req: Request) => boolean\n onHedge?: (req: Request, attempt: number) => void | Promise<void>\n order?: number\n}\n\nexport function hedgePlugin(options: HedgePluginOptions): ClientPlugin {\n const {\n delay,\n maxHedges = 1,\n shouldHedge = (req) => SAFE_METHODS.has(req.method),\n onHedge,\n order = 15,\n } = options\n\n return {\n name: 'hedge',\n order,\n wrapDispatch: (next) => async (ctx) => {\n if (!shouldHedge(ctx.request)) {\n return next(ctx)\n }\n\n const delayMs = typeof delay === 'function' ? delay(ctx.request) : delay\n\n const controllers: AbortController[] = []\n const attempts: Promise<Response>[] = []\n\n function launch(attemptIndex: number): void {\n const controller = new AbortController()\n controllers.push(controller)\n\n const signal = AbortSignal.any([ctx.request.signal, controller.signal])\n\n const req = new Request(ctx.request, { signal })\n attempts.push(next({ ...ctx, request: req }))\n\n if (attemptIndex > 0) {\n onHedge?.(ctx.request, attemptIndex)\n }\n }\n\n function abortLosers(winnerIndex: number): void {\n controllers.forEach((c, i) => {\n if (i !== winnerIndex) c.abort()\n })\n }\n\n // Launch initial attempt\n launch(0)\n\n return new Promise<Response>((resolve, reject) => {\n let settled = false\n let launched = 1\n const timers: ReturnType<typeof setTimeout>[] = []\n\n function settle(winnerIndex: number, value: Response): void {\n settled = true\n timers.forEach(clearTimeout)\n abortLosers(winnerIndex)\n resolve(value)\n }\n\n function tryReject(err: unknown): void {\n Promise.allSettled(attempts).then((results) => {\n if (settled) return\n settled = true\n timers.forEach(clearTimeout)\n let lastError = err\n for (const r of results) {\n if (r.status === 'rejected') lastError = r.reason\n }\n reject(lastError)\n })\n }\n\n function onAttemptSettled(\n index: number,\n result: PromiseSettledResult<Response>\n ): void {\n if (settled) return\n\n if (result.status === 'fulfilled') {\n const res = result.value\n // Treat 5xx and 429 as non-winners unless it's the last attempt\n const isLastAttempt =\n launched >= maxHedges + 1 && index === attempts.length - 1\n if (\n res.ok ||\n (res.status < 500 && res.status !== 429) ||\n isLastAttempt\n ) {\n settle(index, res)\n }\n // Otherwise wait for other attempts; if all settle without a winner\n // the last settler will resolve with whatever is left\n } else {\n // Check if all currently launched attempts have settled\n Promise.allSettled(attempts).then((results) => {\n if (settled) return\n const pending = results.some(\n (r) => r.status === ('pending' as never)\n )\n if (!pending) {\n tryReject(result.reason)\n }\n })\n }\n }\n\n function watchAttempt(index: number): void {\n attempts[index].then(\n (res) =>\n onAttemptSettled(index, { status: 'fulfilled', value: res }),\n (err) =>\n onAttemptSettled(index, { status: 'rejected', reason: err })\n )\n }\n\n watchAttempt(0)\n\n for (let h = 1; h <= maxHedges; h++) {\n const hedgeIndex = h\n const t = setTimeout(() => {\n launch(hedgeIndex)\n watchAttempt(hedgeIndex)\n launched++\n }, delayMs * hedgeIndex)\n timers.push(t)\n }\n })\n },\n }\n}\n"],"mappings":";AAEA,IAAM,eAAe,oBAAI,IAAI,CAAC,OAAO,QAAQ,WAAW,OAAO,QAAQ,CAAC;AAUjE,SAAS,YAAY,SAA2C;AACrE,QAAM;AAAA,IACJ;AAAA,IACA,YAAY;AAAA,IACZ,cAAc,CAAC,QAAQ,aAAa,IAAI,IAAI,MAAM;AAAA,IAClD;AAAA,IACA,QAAQ;AAAA,EACV,IAAI;AAEJ,SAAO;AAAA,IACL,MAAM;AAAA,IACN;AAAA,IACA,cAAc,CAAC,SAAS,OAAO,QAAQ;AACrC,UAAI,CAAC,YAAY,IAAI,OAAO,GAAG;AAC7B,eAAO,KAAK,GAAG;AAAA,MACjB;AAEA,YAAM,UAAU,OAAO,UAAU,aAAa,MAAM,IAAI,OAAO,IAAI;AAEnE,YAAM,cAAiC,CAAC;AACxC,YAAM,WAAgC,CAAC;AAEvC,eAAS,OAAO,cAA4B;AAC1C,cAAM,aAAa,IAAI,gBAAgB;AACvC,oBAAY,KAAK,UAAU;AAE3B,cAAM,SAAS,YAAY,IAAI,CAAC,IAAI,QAAQ,QAAQ,WAAW,MAAM,CAAC;AAEtE,cAAM,MAAM,IAAI,QAAQ,IAAI,SAAS,EAAE,OAAO,CAAC;AAC/C,iBAAS,KAAK,KAAK,EAAE,GAAG,KAAK,SAAS,IAAI,CAAC,CAAC;AAE5C,YAAI,eAAe,GAAG;AACpB,oBAAU,IAAI,SAAS,YAAY;AAAA,QACrC;AAAA,MACF;AAEA,eAAS,YAAY,aAA2B;AAC9C,oBAAY,QAAQ,CAAC,GAAG,MAAM;AAC5B,cAAI,MAAM,YAAa,GAAE,MAAM;AAAA,QACjC,CAAC;AAAA,MACH;AAGA,aAAO,CAAC;AAER,aAAO,IAAI,QAAkB,CAAC,SAAS,WAAW;AAChD,YAAI,UAAU;AACd,YAAI,WAAW;AACf,cAAM,SAA0C,CAAC;AAEjD,iBAAS,OAAO,aAAqB,OAAuB;AAC1D,oBAAU;AACV,iBAAO,QAAQ,YAAY;AAC3B,sBAAY,WAAW;AACvB,kBAAQ,KAAK;AAAA,QACf;AAEA,iBAAS,UAAU,KAAoB;AACrC,kBAAQ,WAAW,QAAQ,EAAE,KAAK,CAAC,YAAY;AAC7C,gBAAI,QAAS;AACb,sBAAU;AACV,mBAAO,QAAQ,YAAY;AAC3B,gBAAI,YAAY;AAChB,uBAAW,KAAK,SAAS;AACvB,kBAAI,EAAE,WAAW,WAAY,aAAY,EAAE;AAAA,YAC7C;AACA,mBAAO,SAAS;AAAA,UAClB,CAAC;AAAA,QACH;AAEA,iBAAS,iBACP,OACA,QACM;AACN,cAAI,QAAS;AAEb,cAAI,OAAO,WAAW,aAAa;AACjC,kBAAM,MAAM,OAAO;AAEnB,kBAAM,gBACJ,YAAY,YAAY,KAAK,UAAU,SAAS,SAAS;AAC3D,gBACE,IAAI,MACH,IAAI,SAAS,OAAO,IAAI,WAAW,OACpC,eACA;AACA,qBAAO,OAAO,GAAG;AAAA,YACnB;AAAA,UAGF,OAAO;AAEL,oBAAQ,WAAW,QAAQ,EAAE,KAAK,CAAC,YAAY;AAC7C,kBAAI,QAAS;AACb,oBAAM,UAAU,QAAQ;AAAA,gBACtB,CAAC,MAAM,EAAE,WAAY;AAAA,cACvB;AACA,kBAAI,CAAC,SAAS;AACZ,0BAAU,OAAO,MAAM;AAAA,cACzB;AAAA,YACF,CAAC;AAAA,UACH;AAAA,QACF;AAEA,iBAAS,aAAa,OAAqB;AACzC,mBAAS,KAAK,EAAE;AAAA,YACd,CAAC,QACC,iBAAiB,OAAO,EAAE,QAAQ,aAAa,OAAO,IAAI,CAAC;AAAA,YAC7D,CAAC,QACC,iBAAiB,OAAO,EAAE,QAAQ,YAAY,QAAQ,IAAI,CAAC;AAAA,UAC/D;AAAA,QACF;AAEA,qBAAa,CAAC;AAEd,iBAAS,IAAI,GAAG,KAAK,WAAW,KAAK;AACnC,gBAAM,aAAa;AACnB,gBAAM,IAAI,WAAW,MAAM;AACzB,mBAAO,UAAU;AACjB,yBAAa,UAAU;AACvB;AAAA,UACF,GAAG,UAAU,UAAU;AACvB,iBAAO,KAAK,CAAC;AAAA,QACf;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/plugins/hedge.ts"],"sourcesContent":["import type { ClientPlugin } from '../plugins.js'\n\nconst SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'])\n\nexport type HedgePluginOptions = {\n delay: number | ((req: Request) => number)\n maxHedges?: number\n shouldHedge?: (req: Request) => boolean\n onHedge?: (req: Request, attempt: number) => void | Promise<void>\n order?: number\n}\n\nexport function hedgePlugin(options: HedgePluginOptions): ClientPlugin {\n const {\n delay,\n maxHedges = 1,\n shouldHedge = (req) => SAFE_METHODS.has(req.method),\n onHedge,\n order = 15,\n } = options\n\n return {\n name: 'hedge',\n order,\n wrapDispatch: (next) => async (ctx) => {\n if (!shouldHedge(ctx.request)) {\n return next(ctx)\n }\n\n const delayMs = typeof delay === 'function' ? delay(ctx.request) : delay\n\n const controllers: AbortController[] = []\n const attempts: Promise<Response>[] = []\n\n function launch(attemptIndex: number): void {\n const controller = new AbortController()\n controllers.push(controller)\n\n const parentSignal = ctx.metadata.signals.combined ?? ctx.request.signal\n const signal = AbortSignal.any([parentSignal, controller.signal])\n\n const req = new Request(ctx.request.clone(), { signal })\n attempts.push(next({ ...ctx, request: req }))\n\n if (attemptIndex > 0) {\n onHedge?.(ctx.request, attemptIndex)\n }\n }\n\n function abortLosers(winnerIndex: number): void {\n controllers.forEach((c, i) => {\n if (i !== winnerIndex) c.abort()\n })\n }\n\n // Launch initial attempt\n launch(0)\n\n return new Promise<Response>((resolve, reject) => {\n let settled = false\n let launched = 1\n let completed = 0\n let fallbackResponse: Response | undefined\n let lastError: unknown\n const timers: ReturnType<typeof setTimeout>[] = []\n\n function settle(winnerIndex: number, value: Response): void {\n settled = true\n timers.forEach(clearTimeout)\n abortLosers(winnerIndex)\n resolve(value)\n }\n\n function finishWithoutWinner(): void {\n if (settled || completed < launched) return\n\n const allAttemptsLaunched = launched >= maxHedges + 1\n if (fallbackResponse && allAttemptsLaunched) {\n settle(attempts.length - 1, fallbackResponse)\n return\n }\n\n // Preserve the existing fail-fast behavior for transport errors. A\n // retryable HTTP response, however, waits for all scheduled hedges.\n if (!fallbackResponse && lastError !== undefined) {\n settled = true\n timers.forEach(clearTimeout)\n reject(lastError)\n }\n }\n\n function onAttemptSettled(\n index: number,\n result: PromiseSettledResult<Response>\n ): void {\n if (settled) return\n\n completed++\n\n if (result.status === 'fulfilled') {\n const res = result.value\n // A retryable response is only a fallback. Its attempt index says\n // when it was launched, not whether a better attempt is pending.\n if (res.ok || (res.status < 500 && res.status !== 429)) {\n settle(index, res)\n return\n }\n fallbackResponse = res\n } else {\n lastError = result.reason\n }\n\n finishWithoutWinner()\n }\n\n function watchAttempt(index: number): void {\n attempts[index].then(\n (res) =>\n onAttemptSettled(index, { status: 'fulfilled', value: res }),\n (err) =>\n onAttemptSettled(index, { status: 'rejected', reason: err })\n )\n }\n\n watchAttempt(0)\n\n for (let h = 1; h <= maxHedges; h++) {\n const hedgeIndex = h\n const t = setTimeout(() => {\n launched++\n launch(hedgeIndex)\n watchAttempt(hedgeIndex)\n }, delayMs * hedgeIndex)\n timers.push(t)\n }\n })\n },\n }\n}\n"],"mappings":";AAEA,IAAM,eAAe,oBAAI,IAAI,CAAC,OAAO,QAAQ,WAAW,OAAO,QAAQ,CAAC;AAUjE,SAAS,YAAY,SAA2C;AACrE,QAAM;AAAA,IACJ;AAAA,IACA,YAAY;AAAA,IACZ,cAAc,CAAC,QAAQ,aAAa,IAAI,IAAI,MAAM;AAAA,IAClD;AAAA,IACA,QAAQ;AAAA,EACV,IAAI;AAEJ,SAAO;AAAA,IACL,MAAM;AAAA,IACN;AAAA,IACA,cAAc,CAAC,SAAS,OAAO,QAAQ;AACrC,UAAI,CAAC,YAAY,IAAI,OAAO,GAAG;AAC7B,eAAO,KAAK,GAAG;AAAA,MACjB;AAEA,YAAM,UAAU,OAAO,UAAU,aAAa,MAAM,IAAI,OAAO,IAAI;AAEnE,YAAM,cAAiC,CAAC;AACxC,YAAM,WAAgC,CAAC;AAEvC,eAAS,OAAO,cAA4B;AAC1C,cAAM,aAAa,IAAI,gBAAgB;AACvC,oBAAY,KAAK,UAAU;AAE3B,cAAM,eAAe,IAAI,SAAS,QAAQ,YAAY,IAAI,QAAQ;AAClE,cAAM,SAAS,YAAY,IAAI,CAAC,cAAc,WAAW,MAAM,CAAC;AAEhE,cAAM,MAAM,IAAI,QAAQ,IAAI,QAAQ,MAAM,GAAG,EAAE,OAAO,CAAC;AACvD,iBAAS,KAAK,KAAK,EAAE,GAAG,KAAK,SAAS,IAAI,CAAC,CAAC;AAE5C,YAAI,eAAe,GAAG;AACpB,oBAAU,IAAI,SAAS,YAAY;AAAA,QACrC;AAAA,MACF;AAEA,eAAS,YAAY,aAA2B;AAC9C,oBAAY,QAAQ,CAAC,GAAG,MAAM;AAC5B,cAAI,MAAM,YAAa,GAAE,MAAM;AAAA,QACjC,CAAC;AAAA,MACH;AAGA,aAAO,CAAC;AAER,aAAO,IAAI,QAAkB,CAAC,SAAS,WAAW;AAChD,YAAI,UAAU;AACd,YAAI,WAAW;AACf,YAAI,YAAY;AAChB,YAAI;AACJ,YAAI;AACJ,cAAM,SAA0C,CAAC;AAEjD,iBAAS,OAAO,aAAqB,OAAuB;AAC1D,oBAAU;AACV,iBAAO,QAAQ,YAAY;AAC3B,sBAAY,WAAW;AACvB,kBAAQ,KAAK;AAAA,QACf;AAEA,iBAAS,sBAA4B;AACnC,cAAI,WAAW,YAAY,SAAU;AAErC,gBAAM,sBAAsB,YAAY,YAAY;AACpD,cAAI,oBAAoB,qBAAqB;AAC3C,mBAAO,SAAS,SAAS,GAAG,gBAAgB;AAC5C;AAAA,UACF;AAIA,cAAI,CAAC,oBAAoB,cAAc,QAAW;AAChD,sBAAU;AACV,mBAAO,QAAQ,YAAY;AAC3B,mBAAO,SAAS;AAAA,UAClB;AAAA,QACF;AAEA,iBAAS,iBACP,OACA,QACM;AACN,cAAI,QAAS;AAEb;AAEA,cAAI,OAAO,WAAW,aAAa;AACjC,kBAAM,MAAM,OAAO;AAGnB,gBAAI,IAAI,MAAO,IAAI,SAAS,OAAO,IAAI,WAAW,KAAM;AACtD,qBAAO,OAAO,GAAG;AACjB;AAAA,YACF;AACA,+BAAmB;AAAA,UACrB,OAAO;AACL,wBAAY,OAAO;AAAA,UACrB;AAEA,8BAAoB;AAAA,QACtB;AAEA,iBAAS,aAAa,OAAqB;AACzC,mBAAS,KAAK,EAAE;AAAA,YACd,CAAC,QACC,iBAAiB,OAAO,EAAE,QAAQ,aAAa,OAAO,IAAI,CAAC;AAAA,YAC7D,CAAC,QACC,iBAAiB,OAAO,EAAE,QAAQ,YAAY,QAAQ,IAAI,CAAC;AAAA,UAC/D;AAAA,QACF;AAEA,qBAAa,CAAC;AAEd,iBAAS,IAAI,GAAG,KAAK,WAAW,KAAK;AACnC,gBAAM,aAAa;AACnB,gBAAM,IAAI,WAAW,MAAM;AACzB;AACA,mBAAO,UAAU;AACjB,yBAAa,UAAU;AAAA,UACzB,GAAG,UAAU,UAAU;AACvB,iBAAO,KAAK,CAAC;AAAA,QACf;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fetchkit/ffetch",
3
- "version": "5.5.3",
3
+ "version": "5.6.0",
4
4
  "description": "Fetch wrapper with configurable timeouts, retries, and TypeScript-first DX",
5
5
  "keywords": [
6
6
  "fetch",
@@ -95,7 +95,7 @@
95
95
  "@typescript-eslint/parser": "^8.39.0",
96
96
  "@vitest/coverage-v8": "^4.1.8",
97
97
  "eslint": "^10.4.1",
98
- "happy-dom": "^20.9.0",
98
+ "fast-check": "^4.9.0",
99
99
  "husky": "^9.1.7",
100
100
  "jsr": "^0.14.3",
101
101
  "lint-staged": "^17.0.5",