@gcmdev/faxios 0.2.2 → 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 (38) hide show
  1. package/dist/lib/adapters/fetch.d.ts +4 -1
  2. package/dist/lib/adapters/fetch.d.ts.map +1 -1
  3. package/dist/lib/adapters/fetch.js +134 -76
  4. package/dist/lib/adapters/fetch.js.map +1 -1
  5. package/dist/lib/core/FaxiosHeaders.d.ts.map +1 -1
  6. package/dist/lib/core/FaxiosHeaders.js +31 -7
  7. package/dist/lib/core/FaxiosHeaders.js.map +1 -1
  8. package/dist/lib/core/dispatchRequest.d.ts.map +1 -1
  9. package/dist/lib/core/dispatchRequest.js +6 -1
  10. package/dist/lib/core/dispatchRequest.js.map +1 -1
  11. package/dist/lib/env/data.d.ts +1 -1
  12. package/dist/lib/env/data.js +1 -1
  13. package/dist/lib/helpers/progressEventReducer.d.ts.map +1 -1
  14. package/dist/lib/helpers/progressEventReducer.js +14 -1
  15. package/dist/lib/helpers/progressEventReducer.js.map +1 -1
  16. package/dist/lib/helpers/toFormData.d.ts.map +1 -1
  17. package/dist/lib/helpers/toFormData.js +12 -18
  18. package/dist/lib/helpers/toFormData.js.map +1 -1
  19. package/dist/lib/helpers/trackStream.d.ts.map +1 -1
  20. package/dist/lib/helpers/trackStream.js +27 -3
  21. package/dist/lib/helpers/trackStream.js.map +1 -1
  22. package/dist/lib/types.d.ts +14 -0
  23. package/dist/lib/types.d.ts.map +1 -1
  24. package/dist/lib/utils.d.ts.map +1 -1
  25. package/dist/lib/utils.js +9 -3
  26. package/dist/lib/utils.js.map +1 -1
  27. package/package.json +1 -1
  28. package/skills/configure-requests/SKILL.md +6 -3
  29. package/skills/configure-requests/references/axios-differences.md +1 -1
  30. package/src/lib/adapters/fetch.ts +152 -94
  31. package/src/lib/core/FaxiosHeaders.ts +34 -7
  32. package/src/lib/core/dispatchRequest.ts +6 -1
  33. package/src/lib/env/data.ts +1 -1
  34. package/src/lib/helpers/progressEventReducer.ts +14 -1
  35. package/src/lib/helpers/toFormData.ts +15 -19
  36. package/src/lib/helpers/trackStream.ts +38 -10
  37. package/src/lib/types.ts +14 -0
  38. package/src/lib/utils.ts +9 -3
@@ -202,6 +202,9 @@ catch (error) {
202
202
  schema step and right before `fetch`, so an abort during pre-flight work sends nothing.
203
203
  - A timeout always rejects with `ETIMEDOUT`. `ECONNABORTED` is never raised, and
204
204
  `transitional.clarifyTimeoutError` has no effect.
205
+ - With `responseType: "stream"`, `timeout` and `signal` stay active until the body is fully
206
+ read or cancelled. Reading a stalled body rejects with `ETIMEDOUT` or `ERR_CANCELED`.
207
+ Cancel a body you don't read so the timer and signal listener are released.
205
208
 
206
209
  ### Handle errors by code
207
210
 
@@ -356,9 +359,9 @@ export only. Instances from `create()` do not have them; import them by name ins
356
359
  `faxios.plugins` is undefined. Import from `@gcmdev/faxios/plugins/<name>`.
357
360
  7. **MEDIUM: Calling statics on an instance.** `api.isCancel(e)` is undefined on a `create()`
358
361
  instance. Use the named import.
359
- 8. **MEDIUM: Expecting `onUploadProgress` everywhere.** It fires only where `fetch` can stream a
360
- request body (`duplex: "half"`, for example Node). Browsers generally do not report upload
361
- progress through fetch; do not promise it there.
362
+ 8. **MEDIUM: Expecting `onUploadProgress` everywhere.** It fires only where faxios can stream
363
+ the request body (Node; not Deno or Bun). In browsers and web workers faxios never streams the
364
+ body for progress, so the callback is not called; do not promise it there. Source: `src/lib/adapters/fetch.ts` (`isBrowserRuntime`).
362
365
  9. **MEDIUM: Retrying POST by accident or by assumption.** The retry plugin skips POST and PATCH
363
366
  unless they are listed in `methods`; a custom `retryOn` cannot override that.
364
367
  10. **MEDIUM: Downloading a binary body without `responseType`.** The bytes are decoded as UTF-8
@@ -23,7 +23,7 @@ behaviour outside this list as unverified until the installed types and a test p
23
23
  | `proxy: {...}` / `httpAgent` / `httpsAgent` | Runtime-level proxy config. In Node the project docs pass an `undici` `ProxyAgent` as `fetchOptions.dispatcher`, using the app's own `undici` dependency | Not faxios options; faxios passes the dispatcher to `fetch` but does not test proxying |
24
24
  | `maxRedirects`, `beforeRedirect` | fetch follows redirects; `fetchOptions: { redirect: "manual" }` to stop | `ERR_FR_TOO_MANY_REDIRECTS` is never raised |
25
25
  | `maxRate`, `socketPath`, `decompress`, `insecureHTTPParser`, `httpVersion`, HTTP/2 options, `lookup`, `family` | none | Removed; ignored at runtime when untyped |
26
- | `onUploadProgress` | same option | Fires only where fetch streams request bodies (Node); not a browser guarantee |
26
+ | `onUploadProgress` | same option | Fires only where fetch streams request bodies (Node); never called in browsers or web workers |
27
27
  | `responseType: "document"` | none in practice | Falls back to text |
28
28
  | retry via `axios-retry` | `retry()` from `@gcmdev/faxios/plugins/retry` | POST/PATCH excluded by default |
29
29
  | auth header interceptor | `authBearer()` from `@gcmdev/faxios/plugins/auth-bearer` | Sends only to the baseURL origin by default |
@@ -81,10 +81,12 @@ const rejectUsedStreamBody = (data: unknown, config: InternalFaxiosRequestConfig
81
81
  if (isWebStream) sentStreamBodies.add(body);
82
82
  };
83
83
 
84
+ const isBrowserRuntime = platform.hasStandardBrowserEnv || platform.hasStandardBrowserWebWorkerEnv;
85
+
84
86
  // Server runtimes default User-Agent to their own (e.g. "node"), so name faxios there.
85
87
  // Browsers send their own User-Agent; setting one makes every cross-origin request
86
88
  // need a CORS preflight that most APIs don't allow.
87
- const setsUserAgent = !platform.hasStandardBrowserEnv && !platform.hasStandardBrowserWebWorkerEnv;
89
+ const setsUserAgent = !isBrowserRuntime;
88
90
  const applyDefaultUserAgent = (headers: FaxiosRequestHeaders): void => {
89
91
  if (setsUserAgent) headers.set("User-Agent", "faxios/" + VERSION, false);
90
92
  };
@@ -171,8 +173,16 @@ type FetchEnv = {
171
173
  Response?: unknown;
172
174
  };
173
175
 
174
- // eslint-disable-next-line sonarjs/function-return-type
175
- const factory = (env: FetchEnv) => {
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 => {
176
186
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
177
187
  const globalObject: Record<string, unknown> = utils.global ?? globalThis;
178
188
  const ReadableStream = globalObject["ReadableStream"] as
@@ -183,22 +193,11 @@ const factory = (env: FetchEnv) => {
183
193
  | undefined;
184
194
 
185
195
  // `undefined` falls back to the global constructor; `null` disables it.
186
- const envFetch = env.fetch as FetchFn | undefined;
187
196
  const Request = (env.Request === undefined ? globalObject["Request"] : env.Request) as AnyConstructor | undefined;
188
197
  const Response = (env.Response === undefined ? globalObject["Response"] : env.Response) as AnyConstructor | undefined;
189
- const _globalFetch = (globalThis as unknown as Record<string, unknown>)[
190
- "fetch"
191
- ];
192
- const isFetchSupported = envFetch
193
- ? isFunction(envFetch)
194
- : isFunction(_globalFetch);
195
198
  const isRequestSupported = isFunction(Request);
196
199
  const isResponseSupported = isFunction(Response);
197
200
 
198
- if (!isFetchSupported) {
199
- return false;
200
- }
201
-
202
201
  // Only members this runtime's Request exposes, so an engine that rejects members it doesn't
203
202
  // implement (Cloudflare Workers) is never sent one.
204
203
  const requestInitDefaults = Object.entries(REQUEST_INIT_DEFAULTS).filter(([ key ]) =>
@@ -209,7 +208,7 @@ const factory = (env: FetchEnv) => {
209
208
  // .mode or undici .dispatcher apply to every request. Every own enumerable key of fetchOptions
210
209
  // is copied, so runtime extensions (Next.js `next`, Workers `cf`, Bun `tls`) still pass through;
211
210
  // inherited keys never do. The caller sets signal, method, headers, body, duplex, credentials.
212
- const initFromFetchOptions = (fetchOptions: unknown): Record<string, unknown> => {
211
+ const initFromFetchOptions = (fetchOptions: unknown, envFetch: FetchFn | undefined): Record<string, unknown> => {
213
212
  const init: Record<string, unknown> = Object.create(null);
214
213
  for (const [ key, value ] of requestInitDefaults) init[key] = value;
215
214
  // Node's global fetch reads `dispatcher` the same way; pin it to undici's own global one.
@@ -278,6 +277,13 @@ const factory = (env: FetchEnv) => {
278
277
  return duplexAccessed && !hasContentType;
279
278
  });
280
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
+
281
287
  const supportsResponseStream =
282
288
  isResponseSupported &&
283
289
  isReadableStreamSupported &&
@@ -294,26 +300,22 @@ const factory = (env: FetchEnv) => {
294
300
  stream: supportsResponseStream && ((res: AnyResponse) => res.body),
295
301
  };
296
302
 
297
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
298
- isFetchSupported &&
299
- (() => {
300
- [ "text", "arrayBuffer", "blob", "formData", "stream" ].forEach(type => {
301
- !resolvers[type] &&
302
- (resolvers[type] = (res: AnyResponse, config?: unknown) => {
303
- const method = (res as Record<string, unknown>)[type];
304
-
305
- if (method) {
306
- return (method as (this: AnyResponse) => unknown).call(res);
307
- }
308
-
309
- throw new FaxiosError(
310
- `Response type '${type}' is not supported`,
311
- FaxiosError.ERR_NOT_SUPPORT,
312
- config as InternalFaxiosRequestConfig
313
- );
314
- });
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
+ );
315
317
  });
316
- })();
318
+ });
317
319
 
318
320
  const getBodyLength = async (body: unknown): Promise<number | undefined> => {
319
321
  if (body == null) {
@@ -324,14 +326,6 @@ const factory = (env: FetchEnv) => {
324
326
  return (body as { size: number; }).size;
325
327
  }
326
328
 
327
- if (utils.isSpecCompliantForm(body)) {
328
- const _request = new (Request as AnyConstructor)(platform.origin, {
329
- method: "POST",
330
- body,
331
- }) as AnyRequest;
332
- return (await _request.arrayBuffer()).byteLength;
333
- }
334
-
335
329
  if (utils.isArrayBufferView(body) || utils.isArrayBuffer(body)) {
336
330
  return (body as ArrayBuffer | ArrayBufferView).byteLength;
337
331
  }
@@ -346,6 +340,23 @@ const factory = (env: FetchEnv) => {
346
340
  return undefined;
347
341
  };
348
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
+
349
360
  const resolveBodyLength = async (
350
361
  headers: FaxiosRequestHeaders,
351
362
  body: unknown
@@ -436,10 +447,13 @@ const factory = (env: FetchEnv) => {
436
447
  }
437
448
  // Enforce maxBodyLength against known-size bodies before dispatch using
438
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.
439
452
  if (hasMaxBodyLength && method !== "get" && method !== "head") {
440
453
  const outboundLength = await getBodyLength(data);
441
- if (typeof outboundLength === "number" && isFinite(outboundLength)) {
442
- if (outboundLength > maxBodyLength) {
454
+ const checkedLength = outboundLength ?? await getFormDataFieldsLength(data);
455
+ if (typeof checkedLength === "number" && isFinite(checkedLength)) {
456
+ if (checkedLength > maxBodyLength) {
443
457
  throw new FaxiosError(
444
458
  "Request body larger than maxBodyLength limit",
445
459
  FaxiosError.ERR_BAD_REQUEST,
@@ -501,6 +515,10 @@ const factory = (env: FetchEnv) => {
501
515
  request: unknown,
502
516
  pendingBodyErrorRef: PendingBodyErrorRef
503
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;
504
522
  const _makeBodyLengthError = () =>
505
523
  new FaxiosError(
506
524
  "Request body larger than maxBodyLength limit",
@@ -524,14 +542,21 @@ const factory = (env: FetchEnv) => {
524
542
  }
525
543
  onProgress && onProgress(loadedBytes);
526
544
  },
527
- 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
+ }
528
553
  );
529
554
 
530
555
  if (
531
556
  supportsRequestStream &&
532
557
  method !== "get" &&
533
558
  method !== "head" &&
534
- (onUploadProgress || mustEnforceStreamBody)
559
+ (uploadProgress || mustEnforceStreamBody)
535
560
  ) {
536
561
  requestContentLength =
537
562
  requestContentLength == null
@@ -546,7 +571,7 @@ const factory = (env: FetchEnv) => {
546
571
  body: data,
547
572
  duplex: "half",
548
573
  }) as AnyRequest;
549
- data = applyUploadProgress(_request, data, headers, requestContentLength, onUploadProgress, _trackStream);
574
+ data = applyUploadProgress(_request, data, headers, requestContentLength, uploadProgress, _trackStream);
550
575
  }
551
576
  }
552
577
  else if (
@@ -584,11 +609,11 @@ const factory = (env: FetchEnv) => {
584
609
  hasMaxContentLength: boolean,
585
610
  maxContentLength: number,
586
611
  isStreamResponse: boolean,
587
- unsubscribe: (() => void) | false | undefined,
612
+ release: (err?: unknown) => void,
588
613
  config: InternalFaxiosRequestConfig,
589
614
  request: unknown
590
615
  ): AnyResponse => {
591
- const needsWrapping = onDownloadProgress || hasMaxContentLength || (isStreamResponse && unsubscribe);
616
+ const needsWrapping = !!onDownloadProgress || hasMaxContentLength || isStreamResponse;
592
617
  if (!supportsResponseStream || !response.body || !needsWrapping) {
593
618
  return response;
594
619
  }
@@ -598,9 +623,15 @@ const factory = (env: FetchEnv) => {
598
623
  options[prop] = response[prop];
599
624
  });
600
625
 
601
- const responseContentLength = utils.toFiniteNumber(
602
- (responseHeaders.getContentLength as () => unknown)()
603
- );
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
+ );
604
635
 
605
636
  const [ onProgress, flush ] = onDownloadProgress
606
637
  ? progressEventDecorator(
@@ -635,17 +666,18 @@ const factory = (env: FetchEnv) => {
635
666
  response.body,
636
667
  DEFAULT_CHUNK_SIZE,
637
668
  onChunkProgress,
638
- () => {
669
+ (err?: unknown) => {
639
670
  flush && flush();
640
- unsubscribe && unsubscribe();
671
+ release(err);
641
672
  }
642
673
  ),
643
674
  options
644
675
  ) as AnyResponse;
645
676
  };
646
677
 
647
- const unsubscribeOnComplete = (isStreamResponse: boolean, unsubscribe: (() => void) | undefined) => {
648
- 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();
649
681
  };
650
682
 
651
683
  return async (config: InternalFaxiosRequestConfig) => {
@@ -680,6 +712,7 @@ const factory = (env: FetchEnv) => {
680
712
  ? (config as unknown as Record<string, unknown>)[key]
681
713
  : undefined;
682
714
 
715
+ const envFetch = ownEnv(config, "fetch") as FetchFn | undefined;
683
716
  const _fetch: FetchFn = (envFetch || (globalThis as unknown as Record<string, unknown>)["fetch"]) as FetchFn;
684
717
 
685
718
  if (timeout != null && !Number.isFinite(Number(timeout))) {
@@ -694,13 +727,16 @@ const factory = (env: FetchEnv) => {
694
727
  responseType ? (responseType + "").toLowerCase() : "text"
695
728
  ) as typeof responseType;
696
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();
697
733
  const composedSignal = composeSignals(
698
- [ signal ],
734
+ [ signal, requestController.signal ],
699
735
  timeout,
700
736
  timeoutErrorMessage
701
- ) as ComposedSignal | undefined;
737
+ ) as ComposedSignal;
702
738
 
703
- const unsubscribe = composedSignal?.unsubscribe?.bind(composedSignal);
739
+ const unsubscribe = composedSignal.unsubscribe!.bind(composedSignal);
704
740
 
705
741
  let request: null | Request = null;
706
742
  const pendingBodyErrorRef: PendingBodyErrorRef = { value: null };
@@ -724,8 +760,7 @@ const factory = (env: FetchEnv) => {
724
760
 
725
761
  // A streamed body under maxBodyLength must be counted as fetch consumes
726
762
  // it; its size is never trusted from a caller-declared Content-Length.
727
- const isStreamLike = utils.isReadableStream!(data) || utils.isStream(data);
728
- const mustEnforceStreamBody = hasMaxBodyLength && isStreamLike;
763
+ const mustEnforceStreamBody = hasMaxBodyLength && isCountedWhileSent(data);
729
764
 
730
765
  ({ data } = await buildUploadStream(
731
766
  data,
@@ -762,7 +797,7 @@ const factory = (env: FetchEnv) => {
762
797
  // Case-insensitive check: toByteStringHeaderObject may normalize to lowercase.
763
798
  data = toWebStream(await encodeBodyIfNeeded(data, serializedHeaders, encodeText));
764
799
 
765
- const resolvedOptions = initFromFetchOptions(fetchOptions);
800
+ const resolvedOptions = initFromFetchOptions(fetchOptions, envFetch);
766
801
  resolvedOptions["signal"] = composedSignal;
767
802
  resolvedOptions["method"] = (method as string).toUpperCase();
768
803
  resolvedOptions["headers"] = serializedHeaders;
@@ -788,6 +823,14 @@ const factory = (env: FetchEnv) => {
788
823
  supportsResponseStream &&
789
824
  (responseType === "stream" || responseType === "response");
790
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
+
791
834
  response = buildDownloadStream(
792
835
  response,
793
836
  responseHeaders,
@@ -795,7 +838,7 @@ const factory = (env: FetchEnv) => {
795
838
  hasMaxContentLength,
796
839
  maxContentLength!,
797
840
  isStreamResponse,
798
- unsubscribe,
841
+ releaseStreamBody,
799
842
  config,
800
843
  request
801
844
  );
@@ -816,12 +859,12 @@ const factory = (env: FetchEnv) => {
816
859
  request
817
860
  );
818
861
 
819
- unsubscribeOnComplete(isStreamResponse, unsubscribe);
862
+ unsubscribeOnComplete(isStreamResponse, response, unsubscribe);
820
863
 
821
864
  return await new Promise((resolve, reject) => {
822
865
  settle(resolve, reject, {
823
866
  data: responseData,
824
- headers: FaxiosHeaders.from(response.headers),
867
+ headers: responseHeaders,
825
868
  status: response.status,
826
869
  statusText: response.statusText,
827
870
  config,
@@ -830,42 +873,57 @@ const factory = (env: FetchEnv) => {
830
873
  });
831
874
  }
832
875
  catch (err) {
833
- unsubscribe && unsubscribe();
834
- 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;
835
887
  }
836
888
  };
837
889
  };
838
890
 
839
- const seedCache = new Map<unknown, unknown>();
840
-
841
- export const getFetch = (config?: { env?: Record<string, unknown>; }) => {
842
- // Own reads only: a polluted Object.prototype.fetch must never become the transport.
843
- const env = config && utils.hasOwnProp(config, "env") ? config.env : undefined;
844
- const own = (key: keyof FetchEnv): unknown => (env && utils.hasOwnProp(env, key) ? env[key] : undefined);
845
- const fetch = own("fetch");
846
- const Request = own("Request");
847
- const Response = own("Response");
848
- const seeds: Array<unknown> = [ Request, Response, fetch ];
849
-
850
- const len = seeds.length;
851
- let i = len;
852
- let seed: unknown;
853
- let target: unknown;
854
- let map: Map<unknown, unknown> = seedCache;
855
-
856
- while (i--) {
857
- seed = seeds[i];
858
- target = map.get(seed);
859
-
860
- if (target === undefined) {
861
- target = i ? new Map<unknown, unknown>() : factory({ fetch, Request, Response });
862
- map.set(seed, target);
863
- }
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>>();
864
897
 
865
- map = target as Map<unknown, unknown>;
898
+ const ctorKey = (ctor: unknown): object => {
899
+ if (ctor === undefined) return GLOBAL_CTOR;
900
+ return isFunction(ctor) ? (ctor as object) : NO_CTOR;
901
+ };
902
+
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;
866
909
  }
867
910
 
868
- return target;
911
+ const Request = ownEnv(config, "Request");
912
+ const Response = ownEnv(config, "Response");
913
+ const requestKey = ctorKey(Request);
914
+ const responseKey = ctorKey(Response);
915
+
916
+ let byResponse = adapters.get(requestKey);
917
+ if (!byResponse) {
918
+ byResponse = new WeakMap();
919
+ adapters.set(requestKey, byResponse);
920
+ }
921
+ let adapter = byResponse.get(responseKey);
922
+ if (!adapter) {
923
+ adapter = factory({ Request, Response });
924
+ byResponse.set(responseKey, adapter);
925
+ }
926
+ return adapter;
869
927
  };
870
928
 
871
929
  getFetch();
@@ -28,6 +28,25 @@ function normalizeHeader(header: string): string {
28
28
  .toLowerCase();
29
29
  }
30
30
 
31
+ // Per instance: lowercased own key -> own key. Callers may also write and delete headers as plain
32
+ // properties, so an entry is only trusted while its key is still own, and a miss rebuilds the index.
33
+ const keyIndexes = new WeakMap<object, Map<string, string>>();
34
+
35
+ function indexKeys(headers: object): Map<string, string> {
36
+ const index = new Map<string, string>();
37
+ // Later keys overwrite earlier ones, matching utils.findKey, which scans from the end.
38
+ for (const key of Object.keys(headers)) {
39
+ index.set(key.toLowerCase(), key);
40
+ }
41
+ keyIndexes.set(headers, index);
42
+ return index;
43
+ }
44
+
45
+ function findHeaderKey(headers: object, lHeader: string): string | undefined {
46
+ const key = keyIndexes.get(headers)?.get(lHeader);
47
+ return key !== undefined && utils.hasOwnProp(headers, key) ? key : indexKeys(headers).get(lHeader);
48
+ }
49
+
31
50
  // eslint-disable-next-line sonarjs/function-return-type
32
51
  function normalizeValue(
33
52
  value: FaxiosHeaderValue | undefined
@@ -187,6 +206,9 @@ class FaxiosHeaders {
187
206
  rewrite?: boolean
188
207
  ): this {
189
208
  const self = this;
209
+ // Set once a lookup misses, which rebuilds the index from the own keys. Only setHeader writes
210
+ // to self until this call returns, so from then on the index is complete and a miss is final.
211
+ let index: Map<string, string> | undefined;
190
212
 
191
213
  function setHeader(
192
214
  _value: unknown,
@@ -199,7 +221,11 @@ class FaxiosHeaders {
199
221
  return;
200
222
  }
201
223
 
202
- const key = utils.findKey(self, lHeader) as string | undefined;
224
+ const key = index ? index.get(lHeader) : findHeaderKey(self, lHeader);
225
+ if (key === undefined) {
226
+ index ??= keyIndexes.get(self)!;
227
+ index.set(String(_header).toLowerCase(), _header);
228
+ }
203
229
 
204
230
  if (
205
231
  !key ||
@@ -260,7 +286,7 @@ class FaxiosHeaders {
260
286
  header = normalizeHeader(header);
261
287
 
262
288
  if (header) {
263
- const key = utils.findKey(this, header) as string | undefined;
289
+ const key = findHeaderKey(this, header);
264
290
 
265
291
  if (key) {
266
292
  const value = (this as Record<string, unknown>)[
@@ -299,7 +325,7 @@ class FaxiosHeaders {
299
325
  header = normalizeHeader(header);
300
326
 
301
327
  if (header) {
302
- const key = utils.findKey(this, header) as string | undefined;
328
+ const key = findHeaderKey(this, header);
303
329
 
304
330
  return !!(
305
331
  key &&
@@ -325,7 +351,7 @@ class FaxiosHeaders {
325
351
  _header = normalizeHeader(_header);
326
352
 
327
353
  if (_header) {
328
- const key = utils.findKey(self, _header) as string | undefined;
354
+ const key = findHeaderKey(self, _header);
329
355
 
330
356
  if (
331
357
  key &&
@@ -381,11 +407,12 @@ class FaxiosHeaders {
381
407
 
382
408
  normalize(format: boolean = false): this {
383
409
  const self = this;
384
- const headers: Record<string, unknown> = {};
410
+ // Lowercased normalized name -> normalized name, for names already kept.
411
+ const kept = new Map<string, string>();
385
412
 
386
413
  utils.forEach(this, (value: unknown, _header: unknown) => {
387
414
  const header = _header as string;
388
- const key = utils.findKey(headers, header) as string | undefined;
415
+ const key = kept.get(header.toLowerCase());
389
416
 
390
417
  if (key) {
391
418
  (self as Record<string, unknown>)[key] = normalizeValue(
@@ -405,7 +432,7 @@ class FaxiosHeaders {
405
432
  value as FaxiosHeaderValue
406
433
  );
407
434
 
408
- headers[normalized] = true;
435
+ kept.set(normalized.toLowerCase(), normalized);
409
436
  });
410
437
 
411
438
  return this;
@@ -104,7 +104,12 @@ export default async function dispatchRequest(this: unknown, config: InternalFax
104
104
 
105
105
  config.data = transformData.call(config, config.transformRequest);
106
106
 
107
- if (config.data != null && !utils.isFormData(config.data) && [ "post", "put", "patch" ].indexOf(config.method as string) !== -1) {
107
+ const data: unknown = config.data;
108
+ // Binary and stream bodies keep their own type: fetch uses `Blob.type` or sends none.
109
+ const isBinaryOrStream = utils.isBlob(data) || utils.isFile(data) || utils.isArrayBuffer(data) || utils.isArrayBufferView(data) ||
110
+ utils.isReadableStream?.(data) || utils.isStream(data) ||
111
+ (typeof data === "object" && data !== null && Symbol.asyncIterator in data);
112
+ if (data != null && !utils.isFormData(data) && !isBinaryOrStream && [ "post", "put", "patch" ].indexOf(config.method as string) !== -1) {
108
113
  (config.headers as unknown as { setContentType: (v: string, r: boolean) => void; }).setContentType("application/x-www-form-urlencoded", false);
109
114
  }
110
115
 
@@ -1 +1 @@
1
- export const VERSION = "0.2.2";
1
+ export const VERSION = "0.2.3";
@@ -51,7 +51,20 @@ export const progressEventDecorator = (total: number | undefined, throttled: Ret
51
51
  ];
52
52
  };
53
53
 
54
+ // Progress listeners are caller code: any error they throw (or a promise they
55
+ // reject) is caught and ignored so it can't become an uncaught exception or
56
+ // change how the request settles.
54
57
  export const asyncDecorator =
55
58
  (fn: (...args: Array<unknown>) => unknown) =>
56
59
  (...args: Array<unknown>) =>
57
- utils.asap(() => fn(...args));
60
+ utils.asap(() => {
61
+ try {
62
+ const result = fn(...args);
63
+ if (utils.isThenable(result)) {
64
+ (result as PromiseLike<unknown>).then(undefined, () => {});
65
+ }
66
+ }
67
+ catch {
68
+ // Ignored by design: see the onUploadProgress/onDownloadProgress docs.
69
+ }
70
+ });