@gcmdev/faxios 0.2.1 → 0.2.3

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.
Files changed (131) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/adapters/fetch-helpers.d.ts +1 -1
  6. package/dist/lib/adapters/fetch-helpers.d.ts.map +1 -1
  7. package/dist/lib/adapters/fetch.d.ts +4 -1
  8. package/dist/lib/adapters/fetch.d.ts.map +1 -1
  9. package/dist/lib/adapters/fetch.js +210 -92
  10. package/dist/lib/adapters/fetch.js.map +1 -1
  11. package/dist/lib/cancel/CanceledError.d.ts +1 -1
  12. package/dist/lib/cancel/CanceledError.d.ts.map +1 -1
  13. package/dist/lib/core/Faxios.d.ts +3 -3
  14. package/dist/lib/core/Faxios.d.ts.map +1 -1
  15. package/dist/lib/core/Faxios.js +8 -4
  16. package/dist/lib/core/Faxios.js.map +1 -1
  17. package/dist/lib/core/FaxiosError.d.ts +2 -2
  18. package/dist/lib/core/FaxiosError.d.ts.map +1 -1
  19. package/dist/lib/core/FaxiosHeaders.d.ts +8 -8
  20. package/dist/lib/core/FaxiosHeaders.d.ts.map +1 -1
  21. package/dist/lib/core/FaxiosHeaders.js +31 -7
  22. package/dist/lib/core/FaxiosHeaders.js.map +1 -1
  23. package/dist/lib/core/define.d.ts +3 -3
  24. package/dist/lib/core/define.d.ts.map +1 -1
  25. package/dist/lib/core/dispatchRequest.d.ts +1 -1
  26. package/dist/lib/core/dispatchRequest.d.ts.map +1 -1
  27. package/dist/lib/core/dispatchRequest.js +6 -1
  28. package/dist/lib/core/dispatchRequest.js.map +1 -1
  29. package/dist/lib/core/mergeConfig.d.ts +2 -2
  30. package/dist/lib/core/mergeConfig.d.ts.map +1 -1
  31. package/dist/lib/core/mergeConfig.js +40 -2
  32. package/dist/lib/core/mergeConfig.js.map +1 -1
  33. package/dist/lib/core/route.d.ts +1 -1
  34. package/dist/lib/core/route.d.ts.map +1 -1
  35. package/dist/lib/core/route.js +2 -5
  36. package/dist/lib/core/route.js.map +1 -1
  37. package/dist/lib/core/transformData.d.ts +2 -2
  38. package/dist/lib/core/transformData.d.ts.map +1 -1
  39. package/dist/lib/core/validateSchema.d.ts +2 -2
  40. package/dist/lib/core/validateSchema.d.ts.map +1 -1
  41. package/dist/lib/env/data.d.ts +1 -1
  42. package/dist/lib/env/data.js +1 -1
  43. package/dist/lib/faxios.d.ts +50 -50
  44. package/dist/lib/faxios.d.ts.map +1 -1
  45. package/dist/lib/helpers/cloneConfig.d.ts +0 -1
  46. package/dist/lib/helpers/cloneConfig.d.ts.map +1 -1
  47. package/dist/lib/helpers/cloneConfig.js +2 -2
  48. package/dist/lib/helpers/cloneConfig.js.map +1 -1
  49. package/dist/lib/helpers/cookies.d.ts +1 -1
  50. package/dist/lib/helpers/cookies.d.ts.map +1 -1
  51. package/dist/lib/helpers/formDataToJSON.d.ts +1 -1
  52. package/dist/lib/helpers/formDataToJSON.d.ts.map +1 -1
  53. package/dist/lib/helpers/formDataToJSON.js +1 -1
  54. package/dist/lib/helpers/formDataToJSON.js.map +1 -1
  55. package/dist/lib/helpers/parseHeaders.d.ts +1 -1
  56. package/dist/lib/helpers/parseHeaders.d.ts.map +1 -1
  57. package/dist/lib/helpers/progressEventReducer.d.ts.map +1 -1
  58. package/dist/lib/helpers/progressEventReducer.js +14 -1
  59. package/dist/lib/helpers/progressEventReducer.js.map +1 -1
  60. package/dist/lib/helpers/substitutePathParams.d.ts.map +1 -1
  61. package/dist/lib/helpers/substitutePathParams.js +11 -3
  62. package/dist/lib/helpers/substitutePathParams.js.map +1 -1
  63. package/dist/lib/helpers/toFormData.d.ts.map +1 -1
  64. package/dist/lib/helpers/toFormData.js +16 -20
  65. package/dist/lib/helpers/toFormData.js.map +1 -1
  66. package/dist/lib/helpers/trackStream.d.ts.map +1 -1
  67. package/dist/lib/helpers/trackStream.js +27 -3
  68. package/dist/lib/helpers/trackStream.js.map +1 -1
  69. package/dist/lib/helpers/validator.d.ts +1 -1
  70. package/dist/lib/helpers/validator.d.ts.map +1 -1
  71. package/dist/lib/platform.d.ts.map +1 -1
  72. package/dist/lib/plugins/authBearer.d.ts +9 -5
  73. package/dist/lib/plugins/authBearer.d.ts.map +1 -1
  74. package/dist/lib/plugins/authBearer.js +6 -0
  75. package/dist/lib/plugins/authBearer.js.map +1 -1
  76. package/dist/lib/plugins/definePlugin.d.ts +3 -3
  77. package/dist/lib/plugins/definePlugin.d.ts.map +1 -1
  78. package/dist/lib/plugins/retry.d.ts +23 -23
  79. package/dist/lib/plugins/retry.d.ts.map +1 -1
  80. package/dist/lib/plugins/timing.d.ts +8 -8
  81. package/dist/lib/plugins/timing.d.ts.map +1 -1
  82. package/dist/lib/types/standard-schema.d.ts +6 -6
  83. package/dist/lib/types/standard-schema.d.ts.map +1 -1
  84. package/dist/lib/types.d.ts +106 -92
  85. package/dist/lib/types.d.ts.map +1 -1
  86. package/dist/lib/utils.d.ts +4 -3
  87. package/dist/lib/utils.d.ts.map +1 -1
  88. package/dist/lib/utils.js +18 -5
  89. package/dist/lib/utils.js.map +1 -1
  90. package/package.json +7 -4
  91. package/skills/configure-requests/SKILL.md +14 -8
  92. package/skills/configure-requests/references/axios-differences.md +1 -1
  93. package/skills/configure-requests/references/plugins.md +37 -6
  94. package/src/index.ts +15 -15
  95. package/src/lib/adapters/fetch-helpers.ts +3 -3
  96. package/src/lib/adapters/fetch.ts +263 -144
  97. package/src/lib/cancel/CanceledError.ts +1 -1
  98. package/src/lib/core/Faxios.ts +17 -13
  99. package/src/lib/core/FaxiosError.ts +3 -3
  100. package/src/lib/core/FaxiosHeaders.ts +50 -23
  101. package/src/lib/core/define.ts +7 -7
  102. package/src/lib/core/dispatchRequest.ts +8 -3
  103. package/src/lib/core/mergeConfig.ts +42 -4
  104. package/src/lib/core/prepareRequest.ts +1 -1
  105. package/src/lib/core/route.ts +3 -6
  106. package/src/lib/core/transformData.ts +2 -2
  107. package/src/lib/core/validateSchema.ts +4 -4
  108. package/src/lib/defaults/index.ts +1 -1
  109. package/src/lib/env/data.ts +1 -1
  110. package/src/lib/faxios.ts +55 -55
  111. package/src/lib/helpers/HttpStatusCode.ts +1 -1
  112. package/src/lib/helpers/cloneConfig.ts +2 -3
  113. package/src/lib/helpers/composeSignals.ts +4 -4
  114. package/src/lib/helpers/cookies.ts +3 -3
  115. package/src/lib/helpers/formDataToJSON.ts +4 -4
  116. package/src/lib/helpers/parseHeaders.ts +2 -2
  117. package/src/lib/helpers/progressEventReducer.ts +15 -2
  118. package/src/lib/helpers/sanitizeHeaderValue.ts +1 -1
  119. package/src/lib/helpers/substitutePathParams.ts +13 -3
  120. package/src/lib/helpers/throttle.ts +1 -1
  121. package/src/lib/helpers/toFormData.ts +26 -28
  122. package/src/lib/helpers/trackStream.ts +40 -12
  123. package/src/lib/helpers/validator.ts +3 -3
  124. package/src/lib/platform.ts +4 -4
  125. package/src/lib/plugins/authBearer.ts +14 -9
  126. package/src/lib/plugins/definePlugin.ts +3 -3
  127. package/src/lib/plugins/retry.ts +27 -27
  128. package/src/lib/plugins/timing.ts +5 -5
  129. package/src/lib/types/standard-schema.ts +6 -6
  130. package/src/lib/types.ts +139 -125
  131. package/src/lib/utils.ts +30 -15
@@ -6,19 +6,19 @@ import { VERSION } from "../env/data.js";
6
6
  import composeSignals from "../helpers/composeSignals.js";
7
7
  import estimateDataURLDecodedBytes from "../helpers/estimateDataURLDecodedBytes.js";
8
8
  import {
9
- progressEventReducer,
9
+ asyncDecorator,
10
10
  progressEventDecorator,
11
- asyncDecorator
11
+ progressEventReducer
12
12
  } from "../helpers/progressEventReducer.js";
13
13
  import { toByteStringHeaderObject } from "../helpers/sanitizeHeaderValue.js";
14
14
  import { trackStream } from "../helpers/trackStream.js";
15
15
  import platform from "../platform.js";
16
16
  import type {
17
- InternalFaxiosRequestConfig,
18
- FaxiosRequestHeaders
17
+ FaxiosRequestHeaders,
18
+ InternalFaxiosRequestConfig
19
19
  } from "../types.js";
20
20
  import utils from "../utils.js";
21
- import { checkDeclaredContentLength, checkMaterializedSize, handleFetchCaughtError, cleanFormDataContentType, encodeBodyIfNeeded } from "./fetch-helpers.js";
21
+ import { checkDeclaredContentLength, checkMaterializedSize, cleanFormDataContentType, encodeBodyIfNeeded, handleFetchCaughtError } from "./fetch-helpers.js";
22
22
  import type { ComposedSignal, PendingBodyErrorRef } from "./fetch-helpers.js";
23
23
 
24
24
  // btoa is a global in Node 16+ and browsers; accessed via globalThis for no-DOM lib compat
@@ -28,29 +28,29 @@ const _btoa: (data: string) => string = (
28
28
  type AnyConstructor = new (...args: Array<unknown>) => unknown;
29
29
  type FetchFn = (input: unknown, init?: unknown) => Promise<AnyResponse>;
30
30
  type AnyResponse = {
31
+ [key: string]: unknown;
32
+ arrayBuffer: () => Promise<ArrayBuffer>;
31
33
  body: unknown;
32
34
  headers: {
33
- has: (name: string) => boolean;
34
- get: (name: string) => string | null;
35
35
  [key: string]: unknown;
36
+ get: (name: string) => null | string;
37
+ has: (name: string) => boolean;
36
38
  };
37
39
  status: number;
38
40
  statusText: string;
39
- arrayBuffer: () => Promise<ArrayBuffer>;
40
- [key: string]: unknown;
41
41
  };
42
42
  type AnyRequest = {
43
+ [key: string]: unknown;
44
+ arrayBuffer: () => Promise<ArrayBuffer>;
45
+ body: null | { cancel: () => Promise<void>; };
43
46
  headers: {
47
+ get: (name: string) => null | string;
44
48
  has: (name: string) => boolean;
45
- get: (name: string) => string | null;
46
49
  };
47
- body: { cancel: () => Promise<void>; } | null;
48
- arrayBuffer: () => Promise<ArrayBuffer>;
49
- [key: string]: unknown;
50
50
  };
51
51
  type AnyReadableStream = {
52
- cancel: () => Promise<void>;
53
52
  [key: string]: unknown;
53
+ cancel: () => Promise<void>;
54
54
  };
55
55
  type AnyTextEncoder = { encode: (str: string) => Uint8Array; };
56
56
 
@@ -58,10 +58,35 @@ const DEFAULT_CHUNK_SIZE = 64 * 1024;
58
58
 
59
59
  const { isFunction } = utils;
60
60
 
61
+ // Web stream bodies this adapter has sent. fetch can read a stream without leaving it locked,
62
+ // and there's no portable "disturbed" check, so a second send is caught here.
63
+ const sentStreamBodies = new WeakSet<object>();
64
+
65
+ // A stream body can be read once. Middleware that calls next() again after a try read it
66
+ // would send an empty body (Node stream) or get an uncoded TypeError from fetch (web stream).
67
+ const rejectUsedStreamBody = (data: unknown, config: InternalFaxiosRequestConfig): void => {
68
+ const body = data as { bodyUsed?: unknown; destroyed?: unknown; locked?: unknown; readableDidRead?: unknown; readableEnded?: unknown; };
69
+ const isWebStream = utils.isReadableStream!(data);
70
+ if (
71
+ (isWebStream && (body.locked === true || sentStreamBodies.has(body))) ||
72
+ ((utils.isRequest!(data) || utils.isResponse!(data)) && body.bodyUsed === true) ||
73
+ (utils.isStream(data) && (body.readableDidRead === true || body.readableEnded === true || body.destroyed === true))
74
+ ) {
75
+ throw new FaxiosError(
76
+ "Request body stream was already used and can't be sent again; pass a new body for each try",
77
+ FaxiosError.ERR_BAD_REQUEST,
78
+ config
79
+ );
80
+ }
81
+ if (isWebStream) sentStreamBodies.add(body);
82
+ };
83
+
84
+ const isBrowserRuntime = platform.hasStandardBrowserEnv || platform.hasStandardBrowserWebWorkerEnv;
85
+
61
86
  // Server runtimes default User-Agent to their own (e.g. "node"), so name faxios there.
62
87
  // Browsers send their own User-Agent; setting one makes every cross-origin request
63
88
  // need a CORS preflight that most APIs don't allow.
64
- const setsUserAgent = !platform.hasStandardBrowserEnv && !platform.hasStandardBrowserWebWorkerEnv;
89
+ const setsUserAgent = !isBrowserRuntime;
65
90
  const applyDefaultUserAgent = (headers: FaxiosRequestHeaders): void => {
66
91
  if (setsUserAgent) headers.set("User-Agent", "faxios/" + VERSION, false);
67
92
  };
@@ -116,8 +141,48 @@ const maybeWithAuthCredentials = (url: string): boolean => {
116
141
  return urlToCheck.includes("@");
117
142
  };
118
143
 
119
- // eslint-disable-next-line sonarjs/function-return-type
120
- const factory = (env: Record<string, unknown>) => {
144
+ // RequestInit members faxios sets itself; the same keys in `fetchOptions` are ignored.
145
+ const FAXIOS_INIT_KEYS: Record<string, true> = {
146
+ body: true,
147
+ credentials: true,
148
+ duplex: true,
149
+ headers: true,
150
+ method: true,
151
+ signal: true,
152
+ };
153
+
154
+ // Spec defaults written as own init members when the caller sets none. undici converts the init
155
+ // into a plain dictionary and reads absent members from it, so an absent member still picks up a
156
+ // polluted Object.prototype value; an own default leaves nothing to inherit.
157
+ const REQUEST_INIT_DEFAULTS: Record<string, unknown> = {
158
+ cache: "default",
159
+ integrity: "",
160
+ keepalive: false,
161
+ mode: "cors",
162
+ redirect: "follow",
163
+ referrer: "about:client",
164
+ referrerPolicy: "",
165
+ };
166
+
167
+ // Where undici (Node's fetch) keeps its global dispatcher.
168
+ const UNDICI_GLOBAL_DISPATCHER = Symbol.for("undici.globalDispatcher.1");
169
+
170
+ type FetchEnv = {
171
+ fetch?: unknown;
172
+ Request?: unknown;
173
+ Response?: unknown;
174
+ };
175
+
176
+ type FetchAdapter = (config: InternalFaxiosRequestConfig) => Promise<unknown>;
177
+
178
+ // Own reads only: a polluted Object.prototype.fetch must never become the transport.
179
+ const readOwn = (obj: unknown, key: string): unknown =>
180
+ typeof obj === "object" && obj !== null && utils.hasOwnProp(obj, key) ? Reflect.get(obj, key) : undefined;
181
+ const ownEnv = (config: unknown, key: keyof FetchEnv): unknown => readOwn(readOwn(config, "env"), key);
182
+
183
+ // Feature detection depends only on the Request and Response constructors, so one adapter
184
+ // serves every `env.fetch`. `env.fetch` is read for each request.
185
+ const factory = (env: Omit<FetchEnv, "fetch">): FetchAdapter => {
121
186
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
122
187
  const globalObject: Record<string, unknown> = utils.global ?? globalThis;
123
188
  const ReadableStream = globalObject["ReadableStream"] as
@@ -127,38 +192,36 @@ const factory = (env: Record<string, unknown>) => {
127
192
  | (new () => AnyTextEncoder)
128
193
  | undefined;
129
194
 
130
- env = utils.merge.call(
131
- {
132
- skipUndefined: true,
133
- },
134
- {
135
- Request: globalObject["Request"],
136
- Response: globalObject["Response"],
137
- },
138
- env
139
- );
140
-
141
- const {
142
- fetch: envFetch,
143
- Request,
144
- Response,
145
- } = env as {
146
- fetch?: FetchFn;
147
- Request?: AnyConstructor;
148
- Response?: AnyConstructor;
149
- };
150
- const _globalFetch = (globalThis as unknown as Record<string, unknown>)[
151
- "fetch"
152
- ];
153
- const isFetchSupported = envFetch
154
- ? isFunction(envFetch)
155
- : isFunction(_globalFetch);
195
+ // `undefined` falls back to the global constructor; `null` disables it.
196
+ const Request = (env.Request === undefined ? globalObject["Request"] : env.Request) as AnyConstructor | undefined;
197
+ const Response = (env.Response === undefined ? globalObject["Response"] : env.Response) as AnyConstructor | undefined;
156
198
  const isRequestSupported = isFunction(Request);
157
199
  const isResponseSupported = isFunction(Response);
158
200
 
159
- if (!isFetchSupported) {
160
- return false;
161
- }
201
+ // Only members this runtime's Request exposes, so an engine that rejects members it doesn't
202
+ // implement (Cloudflare Workers) is never sent one.
203
+ const requestInitDefaults = Object.entries(REQUEST_INIT_DEFAULTS).filter(([ key ]) =>
204
+ isRequestSupported && utils.hasOwnInPrototypeChain((Request as AnyConstructor).prototype, key));
205
+
206
+ // Null prototype: WebIDL reads every RequestInit member with Get, which walks the prototype
207
+ // chain, so a plain object literal would let a polluted Object.prototype.redirect, .integrity,
208
+ // .mode or undici .dispatcher apply to every request. Every own enumerable key of fetchOptions
209
+ // is copied, so runtime extensions (Next.js `next`, Workers `cf`, Bun `tls`) still pass through;
210
+ // inherited keys never do. The caller sets signal, method, headers, body, duplex, credentials.
211
+ const initFromFetchOptions = (fetchOptions: unknown, envFetch: FetchFn | undefined): Record<string, unknown> => {
212
+ const init: Record<string, unknown> = Object.create(null);
213
+ for (const [ key, value ] of requestInitDefaults) init[key] = value;
214
+ // Node's global fetch reads `dispatcher` the same way; pin it to undici's own global one.
215
+ if (!envFetch && utils.hasOwnProp(globalThis, UNDICI_GLOBAL_DISPATCHER)) {
216
+ init["dispatcher"] = (globalThis as Record<PropertyKey, unknown>)[UNDICI_GLOBAL_DISPATCHER];
217
+ }
218
+ if (utils.isObject(fetchOptions)) {
219
+ for (const key of Object.keys(fetchOptions)) {
220
+ if (!utils.hasOwnProp(FAXIOS_INIT_KEYS, key)) init[key] = (fetchOptions as Record<string, unknown>)[key];
221
+ }
222
+ }
223
+ return init;
224
+ };
162
225
 
163
226
  const isReadableStreamSupported = isFunction(ReadableStream);
164
227
 
@@ -179,7 +242,7 @@ const factory = (env: Record<string, unknown>) => {
179
242
  // `fetch(request, init)`. undici (Node 26.11+) starts reading an async iterable in both,
180
243
  // so the first chunk goes to the discarded copy. One web stream is read only once.
181
244
  // Web stream bodies must yield bytes, so string chunks (e.g. `Readable.from(["a"])`) are encoded.
182
- const from = (ReadableStream as { from?: (source: unknown) => unknown; } | undefined)?.from;
245
+ const from = (ReadableStream as undefined | { from?: (source: unknown) => unknown; })?.from;
183
246
  const streamFrom = typeof from === "function" ? from.bind(ReadableStream) : undefined;
184
247
  async function* toByteChunks(source: AsyncIterable<unknown>) {
185
248
  for await (const chunk of source) yield typeof chunk === "string" ? await encodeText(chunk) : chunk;
@@ -214,6 +277,13 @@ const factory = (env: Record<string, unknown>) => {
214
277
  return duplexAccessed && !hasContentType;
215
278
  });
216
279
 
280
+ // Outside browsers a FormData body under maxBodyLength is streamed and counted as fetch reads it;
281
+ // browsers can't send a stream body (see buildUploadStream).
282
+ const countsFormDataWhileSent = !isBrowserRuntime && supportsRequestStream;
283
+ // Bodies counted as fetch reads them when maxBodyLength is set.
284
+ const isCountedWhileSent = (body: unknown): boolean =>
285
+ utils.isReadableStream!(body) || utils.isStream(body) || (countsFormDataWhileSent && utils.isSpecCompliantForm(body));
286
+
217
287
  const supportsResponseStream =
218
288
  isResponseSupported &&
219
289
  isReadableStreamSupported &&
@@ -230,26 +300,22 @@ const factory = (env: Record<string, unknown>) => {
230
300
  stream: supportsResponseStream && ((res: AnyResponse) => res.body),
231
301
  };
232
302
 
233
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
234
- isFetchSupported &&
235
- (() => {
236
- [ "text", "arrayBuffer", "blob", "formData", "stream" ].forEach(type => {
237
- !resolvers[type] &&
238
- (resolvers[type] = (res: AnyResponse, config?: unknown) => {
239
- const method = (res as Record<string, unknown>)[type];
240
-
241
- if (method) {
242
- return (method as (this: AnyResponse) => unknown).call(res);
243
- }
244
-
245
- throw new FaxiosError(
246
- `Response type '${type}' is not supported`,
247
- FaxiosError.ERR_NOT_SUPPORT,
248
- config as InternalFaxiosRequestConfig
249
- );
250
- });
303
+ [ "text", "arrayBuffer", "blob", "formData", "stream" ].forEach(type => {
304
+ !resolvers[type] &&
305
+ (resolvers[type] = (res: AnyResponse, config?: unknown) => {
306
+ const method = (res as Record<string, unknown>)[type];
307
+
308
+ if (method) {
309
+ return (method as (this: AnyResponse) => unknown).call(res);
310
+ }
311
+
312
+ throw new FaxiosError(
313
+ `Response type '${type}' is not supported`,
314
+ FaxiosError.ERR_NOT_SUPPORT,
315
+ config as InternalFaxiosRequestConfig
316
+ );
251
317
  });
252
- })();
318
+ });
253
319
 
254
320
  const getBodyLength = async (body: unknown): Promise<number | undefined> => {
255
321
  if (body == null) {
@@ -260,16 +326,8 @@ const factory = (env: Record<string, unknown>) => {
260
326
  return (body as { size: number; }).size;
261
327
  }
262
328
 
263
- if (utils.isSpecCompliantForm(body)) {
264
- const _request = new (Request as AnyConstructor)(platform.origin, {
265
- method: "POST",
266
- body,
267
- }) as AnyRequest;
268
- return (await _request.arrayBuffer()).byteLength;
269
- }
270
-
271
329
  if (utils.isArrayBufferView(body) || utils.isArrayBuffer(body)) {
272
- return (body as ArrayBufferView | ArrayBuffer).byteLength;
330
+ return (body as ArrayBuffer | ArrayBufferView).byteLength;
273
331
  }
274
332
 
275
333
  if (utils.isURLSearchParams(body)) {
@@ -282,6 +340,23 @@ const factory = (env: Record<string, unknown>) => {
282
340
  return undefined;
283
341
  };
284
342
 
343
+ // The bytes of a FormData body's field values and files, from their sizes, without reading the
344
+ // body. A lower bound: the multipart body adds boundaries and part headers. Undefined for any
345
+ // other body.
346
+ const getFormDataFieldsLength = async (body: unknown): Promise<number | undefined> => {
347
+ if (!utils.isSpecCompliantForm(body)) {
348
+ return undefined;
349
+ }
350
+ let length = 0;
351
+ for (const [ , value ] of body as Iterable<[string, unknown]>) {
352
+ // A FormData value is a string or a File.
353
+ const size = utils.isString(value) ? undefined : utils.getSafeProp(value, "size");
354
+ // eslint-disable-next-line no-await-in-loop
355
+ length += typeof size === "number" ? size : (await encodeText(String(value))).byteLength;
356
+ }
357
+ return length;
358
+ };
359
+
285
360
  const resolveBodyLength = async (
286
361
  headers: FaxiosRequestHeaders,
287
362
  body: unknown
@@ -291,7 +366,7 @@ const factory = (env: Record<string, unknown>) => {
291
366
  return length == null ? getBodyLength(body) : length;
292
367
  };
293
368
 
294
- const normalizeCredentials = (wc: string | boolean): string => {
369
+ const normalizeCredentials = (wc: boolean | string): string => {
295
370
  if (utils.isString(wc)) return wc as string;
296
371
  return wc ? "include" : "omit";
297
372
  };
@@ -303,7 +378,7 @@ const factory = (env: Record<string, unknown>) => {
303
378
  headers: FaxiosRequestHeaders,
304
379
  own: (key: string) => unknown
305
380
  ): string => {
306
- let auth: { username: unknown; password: unknown; } | undefined;
381
+ let auth: undefined | { password: unknown; username: unknown; };
307
382
  const configAuth = own("auth");
308
383
  if (configAuth) {
309
384
  auth = {
@@ -372,10 +447,13 @@ const factory = (env: Record<string, unknown>) => {
372
447
  }
373
448
  // Enforce maxBodyLength against known-size bodies before dispatch using
374
449
  // the body's actual size — never a caller-declared Content-Length.
450
+ // A FormData body isn't measured, since that buffers it whole: its values alone over the limit
451
+ // reject it here, and where it's streamed the byte counter covers the multipart framing too.
375
452
  if (hasMaxBodyLength && method !== "get" && method !== "head") {
376
453
  const outboundLength = await getBodyLength(data);
377
- if (typeof outboundLength === "number" && isFinite(outboundLength)) {
378
- if (outboundLength > maxBodyLength) {
454
+ const checkedLength = outboundLength ?? await getFormDataFieldsLength(data);
455
+ if (typeof checkedLength === "number" && isFinite(checkedLength)) {
456
+ if (checkedLength > maxBodyLength) {
379
457
  throw new FaxiosError(
380
458
  "Request body larger than maxBodyLength limit",
381
459
  FaxiosError.ERR_BAD_REQUEST,
@@ -398,7 +476,7 @@ const factory = (env: Record<string, unknown>) => {
398
476
  onUploadProgress: unknown,
399
477
  _trackStream: (stream: unknown, onProgress?: (bytes: number) => void, flush?: () => void) => unknown
400
478
  ): unknown => {
401
- let contentTypeHeader: string | null;
479
+ let contentTypeHeader: null | string;
402
480
  if (
403
481
  utils.isFormData(data) &&
404
482
  (contentTypeHeader = _request.headers.get("content-type"))
@@ -437,6 +515,10 @@ const factory = (env: Record<string, unknown>) => {
437
515
  request: unknown,
438
516
  pendingBodyErrorRef: PendingBodyErrorRef
439
517
  ): Promise<{ data: unknown; requestContentLength: number | undefined; }> => {
518
+ // Browsers can't be given a stream body for upload progress: Chromium sends one only over
519
+ // HTTP/2 or later and WebKit not at all, so the upload would fail. onUploadProgress isn't
520
+ // called there (#209).
521
+ const uploadProgress = isBrowserRuntime ? undefined : onUploadProgress;
440
522
  const _makeBodyLengthError = () =>
441
523
  new FaxiosError(
442
524
  "Request body larger than maxBodyLength limit",
@@ -460,14 +542,21 @@ const factory = (env: Record<string, unknown>) => {
460
542
  }
461
543
  onProgress && onProgress(loadedBytes);
462
544
  },
463
- flush
545
+ (err?: unknown) => {
546
+ // A chunk trackStream rejected (not bytes) surfaces as the coded error, not ERR_NETWORK.
547
+ if (err instanceof FaxiosError && !pendingBodyErrorRef.value) {
548
+ err.config = config;
549
+ pendingBodyErrorRef.value = err;
550
+ }
551
+ flush && flush();
552
+ }
464
553
  );
465
554
 
466
555
  if (
467
556
  supportsRequestStream &&
468
557
  method !== "get" &&
469
558
  method !== "head" &&
470
- (onUploadProgress || mustEnforceStreamBody)
559
+ (uploadProgress || mustEnforceStreamBody)
471
560
  ) {
472
561
  requestContentLength =
473
562
  requestContentLength == null
@@ -482,7 +571,7 @@ const factory = (env: Record<string, unknown>) => {
482
571
  body: data,
483
572
  duplex: "half",
484
573
  }) as AnyRequest;
485
- data = applyUploadProgress(_request, data, headers, requestContentLength, onUploadProgress, _trackStream);
574
+ data = applyUploadProgress(_request, data, headers, requestContentLength, uploadProgress, _trackStream);
486
575
  }
487
576
  }
488
577
  else if (
@@ -520,11 +609,11 @@ const factory = (env: Record<string, unknown>) => {
520
609
  hasMaxContentLength: boolean,
521
610
  maxContentLength: number,
522
611
  isStreamResponse: boolean,
523
- unsubscribe: (() => void) | false | undefined,
612
+ release: (err?: unknown) => void,
524
613
  config: InternalFaxiosRequestConfig,
525
614
  request: unknown
526
615
  ): AnyResponse => {
527
- const needsWrapping = onDownloadProgress || hasMaxContentLength || (isStreamResponse && unsubscribe);
616
+ const needsWrapping = !!onDownloadProgress || hasMaxContentLength || isStreamResponse;
528
617
  if (!supportsResponseStream || !response.body || !needsWrapping) {
529
618
  return response;
530
619
  }
@@ -534,9 +623,15 @@ const factory = (env: Record<string, unknown>) => {
534
623
  options[prop] = response[prop];
535
624
  });
536
625
 
537
- const responseContentLength = utils.toFiniteNumber(
538
- (responseHeaders.getContentLength as () => unknown)()
539
- );
626
+ // Content-Length is the encoded size; the stream yields decoded bytes, so
627
+ // the total is unknown when the body is content-encoded.
628
+ const contentEncoding = String(responseHeaders.get("content-encoding") ?? "").trim()
629
+ .toLowerCase();
630
+ const responseContentLength = contentEncoding && contentEncoding !== "identity"
631
+ ? undefined
632
+ : utils.toFiniteNumber(
633
+ (responseHeaders.getContentLength as () => unknown)()
634
+ );
540
635
 
541
636
  const [ onProgress, flush ] = onDownloadProgress
542
637
  ? progressEventDecorator(
@@ -571,23 +666,24 @@ const factory = (env: Record<string, unknown>) => {
571
666
  response.body,
572
667
  DEFAULT_CHUNK_SIZE,
573
668
  onChunkProgress,
574
- () => {
669
+ (err?: unknown) => {
575
670
  flush && flush();
576
- unsubscribe && unsubscribe();
671
+ release(err);
577
672
  }
578
673
  ),
579
674
  options
580
675
  ) as AnyResponse;
581
676
  };
582
677
 
583
- const unsubscribeOnComplete = (isStreamResponse: boolean, unsubscribe: (() => void) | undefined) => {
584
- if (!isStreamResponse) unsubscribe?.();
678
+ // A stream body releases the request when it ends; a response without a body has nothing to wait for.
679
+ const unsubscribeOnComplete = (isStreamResponse: boolean, response: AnyResponse, unsubscribe: () => void) => {
680
+ if (!isStreamResponse || response.body == null) unsubscribe();
585
681
  };
586
682
 
587
683
  return async (config: InternalFaxiosRequestConfig) => {
588
684
  const _resolved = prepareRequest(config) as InternalFaxiosRequestConfig & {
589
685
  fetchOptions?: Record<string, unknown>;
590
- withCredentials?: string | boolean;
686
+ withCredentials?: boolean | string;
591
687
  };
592
688
  let {
593
689
  url,
@@ -604,7 +700,7 @@ const factory = (env: Record<string, unknown>) => {
604
700
  maxBodyLength,
605
701
  } = _resolved;
606
702
  let headers = _resolved.headers;
607
- let withCredentials: string | boolean =
703
+ let withCredentials: boolean | string =
608
704
  _resolved.withCredentials ?? "same-origin";
609
705
 
610
706
  const hasMaxContentLength =
@@ -616,6 +712,7 @@ const factory = (env: Record<string, unknown>) => {
616
712
  ? (config as unknown as Record<string, unknown>)[key]
617
713
  : undefined;
618
714
 
715
+ const envFetch = ownEnv(config, "fetch") as FetchFn | undefined;
619
716
  const _fetch: FetchFn = (envFetch || (globalThis as unknown as Record<string, unknown>)["fetch"]) as FetchFn;
620
717
 
621
718
  if (timeout != null && !Number.isFinite(Number(timeout))) {
@@ -630,18 +727,23 @@ const factory = (env: Record<string, unknown>) => {
630
727
  responseType ? (responseType + "").toLowerCase() : "text"
631
728
  ) as typeof responseType;
632
729
 
730
+ // Owns the request's cleanup: aborting it on a failure aborts fetch, which releases the
731
+ // connection and any body still being sent or received.
732
+ const requestController = new AbortController();
633
733
  const composedSignal = composeSignals(
634
- [ signal ],
734
+ [ signal, requestController.signal ],
635
735
  timeout,
636
736
  timeoutErrorMessage
637
- ) as ComposedSignal | undefined;
737
+ ) as ComposedSignal;
638
738
 
639
- const unsubscribe = composedSignal?.unsubscribe?.bind(composedSignal);
739
+ const unsubscribe = composedSignal.unsubscribe!.bind(composedSignal);
640
740
 
641
- let request: Request | null = null;
741
+ let request: null | Request = null;
642
742
  const pendingBodyErrorRef: PendingBodyErrorRef = { value: null };
643
743
 
644
744
  try {
745
+ rejectUsedStreamBody(data, config);
746
+
645
747
  url = applyAuthToRequest(url!, headers, own);
646
748
 
647
749
  const preflightContentLength = await checkPreflightLimits(
@@ -658,8 +760,7 @@ const factory = (env: Record<string, unknown>) => {
658
760
 
659
761
  // A streamed body under maxBodyLength must be counted as fetch consumes
660
762
  // it; its size is never trusted from a caller-declared Content-Length.
661
- const isStreamLike = utils.isReadableStream!(data) || utils.isStream(data);
662
- const mustEnforceStreamBody = hasMaxBodyLength && isStreamLike;
763
+ const mustEnforceStreamBody = hasMaxBodyLength && isCountedWhileSent(data);
663
764
 
664
765
  ({ data } = await buildUploadStream(
665
766
  data,
@@ -696,25 +797,20 @@ const factory = (env: Record<string, unknown>) => {
696
797
  // Case-insensitive check: toByteStringHeaderObject may normalize to lowercase.
697
798
  data = toWebStream(await encodeBodyIfNeeded(data, serializedHeaders, encodeText));
698
799
 
699
- const resolvedOptions = {
700
- ...fetchOptions,
701
- signal: composedSignal,
702
- method: (method as string).toUpperCase(),
703
- headers: serializedHeaders,
704
- body: data,
705
- duplex: "half",
706
- credentials: isCredentialsSupported ? withCredentials : undefined,
707
- };
800
+ const resolvedOptions = initFromFetchOptions(fetchOptions, envFetch);
801
+ resolvedOptions["signal"] = composedSignal;
802
+ resolvedOptions["method"] = (method as string).toUpperCase();
803
+ resolvedOptions["headers"] = serializedHeaders;
804
+ resolvedOptions["body"] = data;
805
+ resolvedOptions["duplex"] = "half";
806
+ resolvedOptions["credentials"] = isCredentialsSupported ? withCredentials : undefined;
708
807
 
709
808
  request = isRequestSupported
710
809
  ? new (Request as AnyConstructor)(url, resolvedOptions) as Request
711
810
  : null;
712
811
 
713
- // Pass resolvedOptions (all own-properties) as the fetch init even when a
714
- // Request object is supplied: undici re-reads RequestInit fields (method,
715
- // headers, body, credentials) off Object.prototype when init is absent,
716
- // so a polluted Object.prototype.method would override the Request's verb.
717
- // An explicit own-property init neutralizes every such gadget.
812
+ // Pass resolvedOptions as the fetch init even when a Request object is supplied, so the
813
+ // same null-prototype members apply to both calls.
718
814
  let response = await (isRequestSupported
719
815
  ? _fetch(request, resolvedOptions)
720
816
  : _fetch(url, resolvedOptions));
@@ -727,6 +823,14 @@ const factory = (env: Record<string, unknown>) => {
727
823
  supportsResponseStream &&
728
824
  (responseType === "stream" || responseType === "response");
729
825
 
826
+ // A stream body outlives this call, so its end, error or cancel releases the request.
827
+ // A buffered body is released below, or by the catch on failure.
828
+ const releaseStreamBody = (bodyErr?: unknown): void => {
829
+ if (!isStreamResponse) return;
830
+ if (bodyErr !== undefined) requestController.abort(bodyErr);
831
+ unsubscribe();
832
+ };
833
+
730
834
  response = buildDownloadStream(
731
835
  response,
732
836
  responseHeaders,
@@ -734,7 +838,7 @@ const factory = (env: Record<string, unknown>) => {
734
838
  hasMaxContentLength,
735
839
  maxContentLength!,
736
840
  isStreamResponse,
737
- unsubscribe,
841
+ releaseStreamBody,
738
842
  config,
739
843
  request
740
844
  );
@@ -755,12 +859,12 @@ const factory = (env: Record<string, unknown>) => {
755
859
  request
756
860
  );
757
861
 
758
- unsubscribeOnComplete(isStreamResponse, unsubscribe);
862
+ unsubscribeOnComplete(isStreamResponse, response, unsubscribe);
759
863
 
760
864
  return await new Promise((resolve, reject) => {
761
865
  settle(resolve, reject, {
762
866
  data: responseData,
763
- headers: FaxiosHeaders.from(response.headers),
867
+ headers: responseHeaders,
764
868
  status: response.status,
765
869
  statusText: response.statusText,
766
870
  config,
@@ -769,42 +873,57 @@ const factory = (env: Record<string, unknown>) => {
769
873
  });
770
874
  }
771
875
  catch (err) {
772
- unsubscribe && unsubscribe();
773
- throw handleFetchCaughtError(err, composedSignal, pendingBodyErrorRef, config, request);
876
+ let error: unknown = err;
877
+ try {
878
+ handleFetchCaughtError(err, composedSignal, pendingBodyErrorRef, config, request);
879
+ }
880
+ catch (classified) {
881
+ error = classified;
882
+ }
883
+ // Classify first: the abort below sets the composed signal's reason.
884
+ requestController.abort(error);
885
+ unsubscribe();
886
+ throw error;
774
887
  }
775
888
  };
776
889
  };
777
890
 
778
- const seedCache = new Map<unknown, unknown>();
891
+ // One adapter per Request/Response pair. Weak keys let a per-request `env.Request` or
892
+ // `env.Response` be collected. `undefined` (use the global) and any non-function (disabled)
893
+ // get stand-in keys.
894
+ const GLOBAL_CTOR = {};
895
+ const NO_CTOR = {};
896
+ const adapters = new WeakMap<object, WeakMap<object, FetchAdapter>>();
779
897
 
780
- export const getFetch = (config?: { env?: Record<string, unknown>; }) => {
781
- const env: Record<string, unknown> = (config && config.env) || {};
782
- const { fetch, Request, Response } = env as {
783
- fetch?: unknown;
784
- Request?: unknown;
785
- Response?: unknown;
786
- };
787
- const seeds: Array<unknown> = [ Request, Response, fetch ];
788
-
789
- const len = seeds.length;
790
- let i = len;
791
- let seed: unknown;
792
- let target: unknown;
793
- let map: Map<unknown, unknown> = seedCache;
898
+ const ctorKey = (ctor: unknown): object => {
899
+ if (ctor === undefined) return GLOBAL_CTOR;
900
+ return isFunction(ctor) ? (ctor as object) : NO_CTOR;
901
+ };
794
902
 
795
- while (i--) {
796
- seed = seeds[i];
797
- target = map.get(seed);
903
+ // `false` tells dispatchRequest that this runtime has no fetch.
904
+ // eslint-disable-next-line sonarjs/function-return-type
905
+ export const getFetch = (config?: { env?: Record<string, unknown>; }): false | FetchAdapter => {
906
+ const fetch = ownEnv(config, "fetch");
907
+ if (!isFunction(fetch || (globalThis as unknown as Record<string, unknown>)["fetch"])) {
908
+ return false;
909
+ }
798
910
 
799
- if (target === undefined) {
800
- target = i ? new Map<unknown, unknown>() : factory(env);
801
- map.set(seed, target);
802
- }
911
+ const Request = ownEnv(config, "Request");
912
+ const Response = ownEnv(config, "Response");
913
+ const requestKey = ctorKey(Request);
914
+ const responseKey = ctorKey(Response);
803
915
 
804
- map = target as Map<unknown, unknown>;
916
+ let byResponse = adapters.get(requestKey);
917
+ if (!byResponse) {
918
+ byResponse = new WeakMap();
919
+ adapters.set(requestKey, byResponse);
805
920
  }
806
-
807
- return target;
921
+ let adapter = byResponse.get(responseKey);
922
+ if (!adapter) {
923
+ adapter = factory({ Request, Response });
924
+ byResponse.set(responseKey, adapter);
925
+ }
926
+ return adapter;
808
927
  };
809
928
 
810
929
  getFetch();