@atproto-labs/fetch 0.0.1 → 0.1.1

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.
@@ -1,27 +1,58 @@
1
- import { Transformer, compose } from '@atproto-labs/transformer'
2
- import { z } from 'zod'
1
+ import { Transformer, pipe } from '@atproto-labs/pipe'
3
2
 
4
- import { FetchError, FetchErrorOptions } from './fetch-error.js'
5
- import { Json, ifObject, ifString } from './util.js'
3
+ // optional dependency for typing purposes
4
+ import type { ZodTypeAny, ParseParams, TypeOf } from 'zod'
5
+
6
+ import { FetchError } from './fetch-error.js'
6
7
  import { TransformedResponse } from './transformed-response.js'
8
+ import {
9
+ Json,
10
+ MaxBytesTransformStream,
11
+ cancelBody,
12
+ ifObject,
13
+ ifString,
14
+ logCancellationError,
15
+ } from './util.js'
7
16
 
8
17
  export type ResponseTranformer = Transformer<Response>
9
18
  export type ResponseMessageGetter = Transformer<Response, string | undefined>
10
19
 
11
- const extractResponseMessage: ResponseMessageGetter = async (response) => {
12
- if (!response.body) return undefined
20
+ export class FetchResponseError extends FetchError {
21
+ constructor(
22
+ public readonly response: Response,
23
+ statusCode: number = response.status,
24
+ message: string = response.statusText,
25
+ options?: ErrorOptions,
26
+ ) {
27
+ super(statusCode, message, options)
28
+ }
13
29
 
14
- const contentType = response.headers.get('content-type')
15
- if (!contentType) return undefined
30
+ static async from(
31
+ response: Response,
32
+ customMessage: string | ResponseMessageGetter = extractResponseMessage,
33
+ statusCode = response.status,
34
+ options?: ErrorOptions,
35
+ ) {
36
+ const message =
37
+ typeof customMessage === 'string'
38
+ ? customMessage
39
+ : typeof customMessage === 'function'
40
+ ? await customMessage(response)
41
+ : undefined
42
+
43
+ return new FetchResponseError(response, statusCode, message, options)
44
+ }
45
+ }
16
46
 
17
- const mimeType = contentType.split(';')[0].trim()
47
+ const extractResponseMessage: ResponseMessageGetter = async (response) => {
48
+ const mimeType = extractMime(response)
18
49
  if (!mimeType) return undefined
19
50
 
20
51
  try {
21
52
  if (mimeType === 'text/plain') {
22
53
  return await response.text()
23
54
  } else if (/^application\/(?:[^+]+\+)?json$/i.test(mimeType)) {
24
- const json = await response.json()
55
+ const json: unknown = await response.json()
25
56
 
26
57
  if (typeof json === 'string') return json
27
58
 
@@ -41,127 +72,170 @@ const extractResponseMessage: ResponseMessageGetter = async (response) => {
41
72
  return undefined
42
73
  }
43
74
 
44
- export class FetchResponseError extends FetchError {
45
- constructor(
46
- response: Response,
47
- statusCode: number = response.status,
48
- message: string = response.statusText,
49
- options?: Omit<FetchErrorOptions, 'response'>,
50
- ) {
51
- super(statusCode, message, { response, ...options })
75
+ export async function peekJson(
76
+ response: Response,
77
+ maxSize = Infinity,
78
+ ): Promise<undefined | Json> {
79
+ const type = extractMime(response)
80
+ if (type !== 'application/json') return undefined
81
+ checkLength(response, maxSize)
82
+
83
+ // 1) Clone the request so we can consume the body
84
+ const clonedResponse = response.clone()
85
+
86
+ // 2) Make sure the request's body is not too large
87
+ const limitedResponse =
88
+ response.body && maxSize < Infinity
89
+ ? new TransformedResponse(
90
+ clonedResponse,
91
+ new MaxBytesTransformStream(maxSize),
92
+ )
93
+ : // Note: some runtimes (e.g. react-native) don't expose a body property
94
+ clonedResponse
95
+
96
+ // 3) Parse the JSON
97
+ return limitedResponse.json()
98
+ }
99
+
100
+ export function checkLength(response: Response, maxBytes: number) {
101
+ // Note: negation accounts for invalid value types (NaN, non numbers)
102
+ if (!(maxBytes >= 0)) {
103
+ throw new TypeError('maxBytes must be a non-negative number')
104
+ }
105
+ const length = extractLength(response)
106
+ if (length != null && length > maxBytes) {
107
+ throw new FetchResponseError(response, 502, 'Response too large')
52
108
  }
109
+ return length
110
+ }
53
111
 
54
- static async from(
55
- response: Response,
56
- statusCode = response.status,
57
- customMessage: string | ResponseMessageGetter = extractResponseMessage,
58
- options?: Omit<FetchErrorOptions, 'response'>,
59
- ) {
60
- const message =
61
- typeof customMessage === 'string'
62
- ? customMessage
63
- : typeof customMessage === 'function'
64
- ? await customMessage(response)
65
- : undefined
112
+ export function extractLength(response: Response) {
113
+ const contentLength = response.headers.get('Content-Length')
114
+ if (contentLength == null) return undefined
115
+ if (!/^\d+$/.test(contentLength)) {
116
+ throw new FetchResponseError(response, 502, 'Invalid Content-Length')
117
+ }
118
+ const length = Number(contentLength)
119
+ if (!Number.isSafeInteger(length)) {
120
+ throw new FetchResponseError(response, 502, 'Content-Length too large')
121
+ }
122
+ return length
123
+ }
66
124
 
67
- // Make sure the body gets consumed as, in some environments (Node 👀), the
68
- // response will not automatically be GC'd.
69
- if (!response.bodyUsed) await response.body?.cancel()
125
+ export function extractMime(response: Response) {
126
+ const contentType = response.headers.get('Content-Type')
127
+ if (contentType == null) return undefined
70
128
 
71
- return new FetchResponseError(response, statusCode, message, options)
129
+ return contentType.split(';', 1)[0]!.trim()
130
+ }
131
+
132
+ /**
133
+ * If the transformer results in an error, ensure that the response body is
134
+ * consumed as, in some environments (Node 👀), the response will not
135
+ * automatically be GC'd.
136
+ *
137
+ * @see {@link https://undici.nodejs.org/#/?id=garbage-collection}
138
+ * @param [onCancellationError] - Callback to handle any async body cancelling
139
+ * error. Defaults to logging the error. Do not use `null` if the request is
140
+ * cloned.
141
+ */
142
+ export function cancelBodyOnError<T>(
143
+ transformer: Transformer<Response, T>,
144
+ onCancellationError: null | ((err: unknown) => void) = logCancellationError,
145
+ ): (response: Response) => Promise<T> {
146
+ return async (response) => {
147
+ try {
148
+ return await transformer(response)
149
+ } catch (err) {
150
+ await cancelBody(response, onCancellationError ?? undefined)
151
+ throw err
152
+ }
72
153
  }
73
154
  }
74
155
 
75
156
  export function fetchOkProcessor(
76
157
  customMessage?: string | ResponseMessageGetter,
77
158
  ): ResponseTranformer {
78
- return async (response) => {
79
- if (response.ok) return response
80
- throw await FetchResponseError.from(response, undefined, customMessage)
81
- }
159
+ return cancelBodyOnError((response) => {
160
+ return fetchOkTransformer(response, customMessage)
161
+ })
162
+ }
163
+
164
+ export async function fetchOkTransformer(
165
+ response: Response,
166
+ customMessage?: string | ResponseMessageGetter,
167
+ ) {
168
+ if (response.ok) return response
169
+ throw await FetchResponseError.from(response, customMessage)
82
170
  }
83
171
 
84
172
  export function fetchMaxSizeProcessor(maxBytes: number): ResponseTranformer {
85
173
  if (maxBytes === Infinity) return (response) => response
86
174
  if (!Number.isFinite(maxBytes) || maxBytes < 0) {
87
- throw new TypeError('maxBytes must be a non-negative number')
175
+ throw new TypeError('maxBytes must be a 0, Infinity or a positive number')
88
176
  }
89
- return async (response) => fetchResponseMaxSize(response, maxBytes)
177
+ return cancelBodyOnError((response) => {
178
+ return fetchResponseMaxSizeChecker(response, maxBytes)
179
+ })
90
180
  }
91
181
 
92
- export async function fetchResponseMaxSize(
182
+ export function fetchResponseMaxSizeChecker(
93
183
  response: Response,
94
184
  maxBytes: number,
95
- ): Promise<Response> {
185
+ ): Response {
96
186
  if (maxBytes === Infinity) return response
97
- if (!response.body) return response
187
+ checkLength(response, maxBytes)
98
188
 
99
- const contentLength = response.headers.get('content-length')
100
- if (contentLength) {
101
- const length = Number(contentLength)
102
- if (!(length < maxBytes)) {
103
- const err = new FetchResponseError(response, 502, 'Response too large')
104
- await response.body.cancel(err)
105
- throw err
106
- }
107
- }
108
-
109
- let bytesRead = 0
110
-
111
- const transform = new TransformStream<Uint8Array, Uint8Array>({
112
- transform: (
113
- chunk: Uint8Array,
114
- ctrl: TransformStreamDefaultController<Uint8Array>,
115
- ) => {
116
- if ((bytesRead += chunk.length) <= maxBytes) {
117
- ctrl.enqueue(chunk)
118
- } else {
119
- ctrl.error(new FetchResponseError(response, 502, 'Response too large'))
120
- }
121
- },
122
- })
189
+ // Some engines (react-native 👀) don't expose a body property. In that case,
190
+ // we will only rely on the Content-Length header.
191
+ if (!response.body) return response
123
192
 
193
+ const transform = new MaxBytesTransformStream(maxBytes)
124
194
  return new TransformedResponse(response, transform)
125
195
  }
126
196
 
127
- export type ContentTypeCheckFn = (contentType: string) => boolean
128
- export type ContentTypeCheck = string | RegExp | ContentTypeCheckFn
197
+ export type MimeTypeCheckFn = (mimeType: string) => boolean
198
+ export type MimeTypeCheck = string | RegExp | MimeTypeCheckFn
129
199
 
130
200
  export function fetchTypeProcessor(
131
- expectedType: ContentTypeCheck,
201
+ expectedMime: MimeTypeCheck,
132
202
  contentTypeRequired = true,
133
203
  ): ResponseTranformer {
134
- const isExpected: ContentTypeCheckFn =
135
- typeof expectedType === 'string'
136
- ? (ct) => ct === expectedType
137
- : expectedType instanceof RegExp
138
- ? (ct) => expectedType.test(ct)
139
- : expectedType
204
+ const isExpected: MimeTypeCheckFn =
205
+ typeof expectedMime === 'string'
206
+ ? (mimeType) => mimeType === expectedMime
207
+ : expectedMime instanceof RegExp
208
+ ? (mimeType) => expectedMime.test(mimeType)
209
+ : expectedMime
210
+
211
+ return cancelBodyOnError((response) => {
212
+ return fetchResponseTypeChecker(response, isExpected, contentTypeRequired)
213
+ })
214
+ }
140
215
 
141
- return async (response) => {
142
- const contentType = response.headers
143
- .get('content-type')
144
- ?.split(';')[0]!
145
- .trim()
146
-
147
- if (contentType) {
148
- if (!isExpected(contentType)) {
149
- throw await FetchResponseError.from(
150
- response,
151
- 502,
152
- `Unexpected response Content-Type (${contentType})`,
153
- )
154
- }
155
- } else if (contentTypeRequired) {
216
+ export async function fetchResponseTypeChecker(
217
+ response: Response,
218
+ isExpectedMime: MimeTypeCheckFn,
219
+ contentTypeRequired = true,
220
+ ): Promise<Response> {
221
+ const mimeType = extractMime(response)
222
+ if (mimeType) {
223
+ if (!isExpectedMime(mimeType)) {
156
224
  throw await FetchResponseError.from(
157
225
  response,
226
+ `Unexpected response Content-Type (${mimeType})`,
158
227
  502,
159
- 'Missing response Content-Type header',
160
228
  )
161
229
  }
162
-
163
- return response
230
+ } else if (contentTypeRequired) {
231
+ throw await FetchResponseError.from(
232
+ response,
233
+ 'Missing response Content-Type header',
234
+ 502,
235
+ )
164
236
  }
237
+
238
+ return response
165
239
  }
166
240
 
167
241
  export type ParsedJsonResponse<T = Json> = {
@@ -169,17 +243,9 @@ export type ParsedJsonResponse<T = Json> = {
169
243
  json: T
170
244
  }
171
245
 
172
- export async function jsonTranformer<T = Json>(
246
+ export async function fetchResponseJsonTranformer<T = Json>(
173
247
  response: Response,
174
248
  ): Promise<ParsedJsonResponse<T>> {
175
- if (response.body === null) {
176
- throw new FetchResponseError(response, 502, 'No response body')
177
- }
178
-
179
- if (response.bodyUsed) {
180
- throw new FetchResponseError(response, 500, 'Response body already used')
181
- }
182
-
183
249
  try {
184
250
  const json = (await response.json()) as T
185
251
  return { response, json }
@@ -194,19 +260,19 @@ export async function jsonTranformer<T = Json>(
194
260
  }
195
261
 
196
262
  export function fetchJsonProcessor<T = Json>(
197
- contentType: ContentTypeCheck = /^application\/(?:[^+]+\+)?json$/,
263
+ expectedMime: MimeTypeCheck = /^application\/(?:[^+]+\+)?json$/,
198
264
  contentTypeRequired = true,
199
265
  ): Transformer<Response, ParsedJsonResponse<T>> {
200
- return compose(
201
- fetchTypeProcessor(contentType, contentTypeRequired),
202
- jsonTranformer<T>,
266
+ return pipe(
267
+ fetchTypeProcessor(expectedMime, contentTypeRequired),
268
+ cancelBodyOnError(fetchResponseJsonTranformer<T>),
203
269
  )
204
270
  }
205
271
 
206
- export function fetchJsonZodProcessor<S extends z.ZodTypeAny>(
272
+ export function fetchJsonZodProcessor<S extends ZodTypeAny>(
207
273
  schema: S,
208
- params?: Partial<z.ParseParams>,
209
- ): Transformer<ParsedJsonResponse, z.infer<S>> {
210
- return async (jsonResponse: ParsedJsonResponse): Promise<z.infer<S>> =>
274
+ params?: Partial<ParseParams>,
275
+ ): Transformer<ParsedJsonResponse, TypeOf<S>> {
276
+ return async (jsonResponse: ParsedJsonResponse): Promise<TypeOf<S>> =>
211
277
  schema.parseAsync(jsonResponse.json, params)
212
278
  }
package/src/fetch-wrap.ts CHANGED
@@ -1,101 +1,150 @@
1
- import { Fetch } from './fetch.js'
1
+ import { FetchRequestError } from './fetch-request.js'
2
+ import { Fetch, FetchContext, toRequestTransformer } from './fetch.js'
2
3
  import { TransformedResponse } from './transformed-response.js'
3
-
4
- export const loggedFetchWrap = ({
5
- fetch = globalThis.fetch as Fetch,
6
- } = {}): Fetch => {
7
- return async function (request) {
8
- return fetchLog.call(this, request, fetch)
9
- }
10
- }
11
-
12
- async function fetchLog(
13
- this: ThisParameterType<Fetch>,
14
- request: Request,
15
- fetch: Fetch = globalThis.fetch,
16
- ) {
17
- console.info(
18
- `> ${request.method} ${request.url}\n` +
19
- stringifyPayload(request.headers, await request.clone().text()),
20
- )
21
-
22
- try {
23
- const response = await fetch(request)
24
-
25
- console.info(
26
- `< HTTP/1.1 ${response.status} ${response.statusText}\n` +
27
- stringifyPayload(response.headers, await response.clone().text()),
28
- )
29
-
30
- return response
31
- } catch (error) {
32
- console.error(`< Error:`, error)
33
-
34
- throw error
35
- }
4
+ import { padLines, stringifyMessage } from './util.js'
5
+
6
+ type LogFn<Args extends unknown[]> = (...args: Args) => void | PromiseLike<void>
7
+
8
+ export function loggedFetch<C = FetchContext>({
9
+ fetch = globalThis.fetch as Fetch<C>,
10
+ logRequest = true as boolean | LogFn<[request: Request]>,
11
+ logResponse = true as boolean | LogFn<[response: Response, request: Request]>,
12
+ logError = true as boolean | LogFn<[error: unknown, request: Request]>,
13
+ }) {
14
+ const onRequest =
15
+ logRequest === true
16
+ ? async (request) => {
17
+ const requestMessage = await stringifyMessage(request)
18
+ console.info(
19
+ `> ${request.method} ${request.url}\n${padLines(requestMessage, ' ')}`,
20
+ )
21
+ }
22
+ : logRequest || undefined
23
+
24
+ const onResponse =
25
+ logResponse === true
26
+ ? async (response) => {
27
+ const responseMessage = await stringifyMessage(response.clone())
28
+ console.info(
29
+ `< HTTP/1.1 ${response.status} ${response.statusText}\n${padLines(responseMessage, ' ')}`,
30
+ )
31
+ }
32
+ : logResponse || undefined
33
+
34
+ const onError =
35
+ logError === true
36
+ ? async (error) => {
37
+ console.error(`< Error:`, error)
38
+ }
39
+ : logError || undefined
40
+
41
+ if (!onRequest && !onResponse && !onError) return fetch
42
+
43
+ return toRequestTransformer(async function (
44
+ this: C,
45
+ request,
46
+ ): Promise<Response> {
47
+ if (onRequest) await onRequest(request)
48
+
49
+ try {
50
+ const response = await fetch.call(this, request)
51
+
52
+ if (onResponse) await onResponse(response, request)
53
+
54
+ return response
55
+ } catch (error) {
56
+ if (onError) await onError(error, request)
57
+
58
+ throw error
59
+ }
60
+ })
36
61
  }
37
62
 
38
- const stringifyPayload = (headers: Headers, body: string) =>
39
- [stringifyHeaders(headers), stringifyBody(body)]
40
- .filter(Boolean)
41
- .join('\n ') + '\n '
42
-
43
- const stringifyHeaders = (headers: Headers) =>
44
- Array.from(headers)
45
- .map(([name, value]) => ` ${name}: ${value}\n`)
46
- .join('')
47
-
48
- const stringifyBody = (body: string) =>
49
- body ? `\n ${body.replace(/\r?\n/g, '\\n')}` : ''
50
-
51
- export const timeoutFetchWrap = ({
52
- fetch = globalThis.fetch as Fetch,
63
+ export const timedFetch = <C = FetchContext>(
53
64
  timeout = 60e3,
54
- } = {}): Fetch => {
65
+ fetch: Fetch<C> = globalThis.fetch,
66
+ ): Fetch<C> => {
55
67
  if (timeout === Infinity) return fetch
56
68
  if (!Number.isFinite(timeout) || timeout <= 0) {
57
69
  throw new TypeError('Timeout must be positive')
58
70
  }
59
- return async function (request) {
60
- return fetchTimeout.call(this, request, timeout, fetch)
61
- }
71
+ return toRequestTransformer(async function (
72
+ this: C,
73
+ request,
74
+ ): Promise<Response> {
75
+ const controller = new AbortController()
76
+ const signal = controller.signal
77
+
78
+ const abort = () => {
79
+ controller.abort()
80
+ }
81
+ const cleanup = () => {
82
+ clearTimeout(timer)
83
+ request.signal?.removeEventListener('abort', abort)
84
+ }
85
+
86
+ const timer = setTimeout(abort, timeout)
87
+ if (typeof timer === 'object') timer.unref?.() // only on node
88
+ request.signal?.addEventListener('abort', abort)
89
+
90
+ signal.addEventListener('abort', cleanup)
91
+
92
+ const response = await fetch.call(this, request, { signal })
93
+
94
+ if (!response.body) {
95
+ cleanup()
96
+ return response
97
+ } else {
98
+ // Cleanup the timer & event listeners when the body stream is closed
99
+ const transform = new TransformStream({ flush: cleanup })
100
+ return new TransformedResponse(response, transform)
101
+ }
102
+ })
62
103
  }
63
104
 
64
- export async function fetchTimeout(
65
- this: ThisParameterType<Fetch>,
66
- request: Request,
67
- timeout = 30e3,
68
- fetch: Fetch = globalThis.fetch,
69
- ): Promise<Response> {
70
- if (timeout === Infinity) return fetch(request)
71
- if (!Number.isFinite(timeout) || timeout <= 0) {
72
- throw new TypeError('Timeout must be positive')
73
- }
74
-
75
- const controller = new AbortController()
76
- const signal = controller.signal
77
-
78
- const abort = () => {
79
- controller.abort()
80
- }
81
- const cleanup = () => {
82
- clearTimeout(timeoutId)
83
- request.signal?.removeEventListener('abort', abort)
84
- }
85
-
86
- const timeoutId = setTimeout(abort, timeout).unref()
87
- request.signal?.addEventListener('abort', abort)
88
-
89
- signal.addEventListener('abort', cleanup)
90
-
91
- const response = await fetch(new Request(request, { signal }))
92
-
93
- if (!response.body) {
94
- cleanup()
95
- return response
96
- } else {
97
- // Cleanup the timer & event listeners when the body stream is closed
98
- const transform = new TransformStream({ flush: cleanup })
99
- return new TransformedResponse(response, transform)
100
- }
105
+ /**
106
+ * Wraps a fetch function to bind it to a specific context, and wrap any thrown
107
+ * errors into a FetchRequestError.
108
+ *
109
+ * @example
110
+ *
111
+ * ```ts
112
+ * class MyClient {
113
+ * constructor(private fetch = globalThis.fetch) {}
114
+ *
115
+ * async get(url: string) {
116
+ * // This will generate an error, because the context used is not a
117
+ * // FetchContext (it's a MyClient instance).
118
+ * return this.fetch(url)
119
+ * }
120
+ * }
121
+ * ```
122
+ *
123
+ * @example
124
+ *
125
+ * ```ts
126
+ * class MyClient {
127
+ * private fetch: Fetch<unknown>
128
+ *
129
+ * constructor(fetch = globalThis.fetch) {
130
+ * this.fetch = bindFetch(fetch)
131
+ * }
132
+ *
133
+ * async get(url: string) {
134
+ * return this.fetch(url) // no more error
135
+ * }
136
+ * }
137
+ * ```
138
+ */
139
+ export function bindFetch<C = FetchContext>(
140
+ fetch: Fetch<C> = globalThis.fetch,
141
+ context: C = globalThis as C,
142
+ ) {
143
+ return toRequestTransformer(async (request) => {
144
+ try {
145
+ return await fetch.call(context, request)
146
+ } catch (err) {
147
+ throw FetchRequestError.from(request, err)
148
+ }
149
+ })
101
150
  }
package/src/fetch.ts CHANGED
@@ -1,4 +1,39 @@
1
- export type Fetch = (
2
- this: void | null | typeof globalThis,
3
- input: Request,
1
+ import { ThisParameterOverride } from './util.js'
2
+
3
+ export type FetchContext = void | null | typeof globalThis
4
+
5
+ export type FetchBound = (
6
+ input: string | URL | Request,
7
+ init?: RequestInit,
4
8
  ) => Promise<Response>
9
+
10
+ // NOT using "typeof globalThis.fetch" here because "globalThis.fetch" does not
11
+ // have a "this" parameter, while runtimes do ensure that "fetch" is called with
12
+ // the correct "this" parameter (either null, undefined, or window).
13
+
14
+ export type Fetch<C = FetchContext> = ThisParameterOverride<C, FetchBound>
15
+
16
+ export type SimpleFetchBound = (input: Request) => Promise<Response>
17
+ export type SimpleFetch<C = FetchContext> = ThisParameterOverride<
18
+ C,
19
+ SimpleFetchBound
20
+ >
21
+
22
+ export function toRequestTransformer<C, O>(
23
+ requestTransformer: (this: C, input: Request) => O,
24
+ ): ThisParameterOverride<
25
+ C,
26
+ (input: string | URL | Request, init?: RequestInit) => O
27
+ > {
28
+ return function (this: C, input, init) {
29
+ return requestTransformer.call(this, asRequest(input, init))
30
+ }
31
+ }
32
+
33
+ export function asRequest(
34
+ input: string | URL | Request,
35
+ init?: RequestInit,
36
+ ): Request {
37
+ if (!init && input instanceof Request) return input
38
+ return new Request(input, init)
39
+ }
@@ -2,11 +2,14 @@ export class TransformedResponse extends Response {
2
2
  #response: Response
3
3
 
4
4
  constructor(response: Response, transform: TransformStream) {
5
- if (response.body && response.bodyUsed) {
5
+ if (!response.body) {
6
+ throw new TypeError('Response body is not available')
7
+ }
8
+ if (response.bodyUsed) {
6
9
  throw new TypeError('Response body is already used')
7
10
  }
8
11
 
9
- super(response.body?.pipeThrough(transform) ?? null, {
12
+ super(response.body.pipeThrough(transform), {
10
13
  status: response.status,
11
14
  statusText: response.statusText,
12
15
  headers: response.headers,