@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.
- package/CHANGELOG.md +16 -4
- package/dist/fetch-error.d.ts +1 -12
- package/dist/fetch-error.d.ts.map +1 -1
- package/dist/fetch-error.js +24 -39
- package/dist/fetch-error.js.map +1 -1
- package/dist/fetch-request.d.ts +22 -5
- package/dist/fetch-request.d.ts.map +1 -1
- package/dist/fetch-request.js +55 -18
- package/dist/fetch-request.js.map +1 -1
- package/dist/fetch-response.d.ts +30 -12
- package/dist/fetch-response.d.ts.map +1 -1
- package/dist/fetch-response.js +134 -81
- package/dist/fetch-response.js.map +1 -1
- package/dist/fetch-wrap.d.ts +47 -9
- package/dist/fetch-wrap.d.ts.map +1 -1
- package/dist/fetch-wrap.js +112 -61
- package/dist/fetch-wrap.js.map +1 -1
- package/dist/fetch.d.ts +8 -1
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +13 -0
- package/dist/fetch.js.map +1 -1
- package/dist/transformed-response.d.ts.map +1 -1
- package/dist/transformed-response.js +5 -2
- package/dist/transformed-response.js.map +1 -1
- package/dist/util.d.ts +46 -14
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +121 -24
- package/dist/util.js.map +1 -1
- package/package.json +6 -5
- package/src/fetch-error.ts +26 -44
- package/src/fetch-request.ts +81 -24
- package/src/fetch-response.ts +177 -111
- package/src/fetch-wrap.ts +139 -90
- package/src/fetch.ts +38 -3
- package/src/transformed-response.ts +5 -2
- package/src/util.ts +142 -25
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +2 -6
package/src/fetch-response.ts
CHANGED
|
@@ -1,27 +1,58 @@
|
|
|
1
|
-
import { Transformer,
|
|
2
|
-
import { z } from 'zod'
|
|
1
|
+
import { Transformer, pipe } from '@atproto-labs/pipe'
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
import {
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
)
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
125
|
+
export function extractMime(response: Response) {
|
|
126
|
+
const contentType = response.headers.get('Content-Type')
|
|
127
|
+
if (contentType == null) return undefined
|
|
70
128
|
|
|
71
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
175
|
+
throw new TypeError('maxBytes must be a 0, Infinity or a positive number')
|
|
88
176
|
}
|
|
89
|
-
return
|
|
177
|
+
return cancelBodyOnError((response) => {
|
|
178
|
+
return fetchResponseMaxSizeChecker(response, maxBytes)
|
|
179
|
+
})
|
|
90
180
|
}
|
|
91
181
|
|
|
92
|
-
export
|
|
182
|
+
export function fetchResponseMaxSizeChecker(
|
|
93
183
|
response: Response,
|
|
94
184
|
maxBytes: number,
|
|
95
|
-
):
|
|
185
|
+
): Response {
|
|
96
186
|
if (maxBytes === Infinity) return response
|
|
97
|
-
|
|
187
|
+
checkLength(response, maxBytes)
|
|
98
188
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
128
|
-
export type
|
|
197
|
+
export type MimeTypeCheckFn = (mimeType: string) => boolean
|
|
198
|
+
export type MimeTypeCheck = string | RegExp | MimeTypeCheckFn
|
|
129
199
|
|
|
130
200
|
export function fetchTypeProcessor(
|
|
131
|
-
|
|
201
|
+
expectedMime: MimeTypeCheck,
|
|
132
202
|
contentTypeRequired = true,
|
|
133
203
|
): ResponseTranformer {
|
|
134
|
-
const isExpected:
|
|
135
|
-
typeof
|
|
136
|
-
? (
|
|
137
|
-
:
|
|
138
|
-
? (
|
|
139
|
-
:
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
263
|
+
expectedMime: MimeTypeCheck = /^application\/(?:[^+]+\+)?json$/,
|
|
198
264
|
contentTypeRequired = true,
|
|
199
265
|
): Transformer<Response, ParsedJsonResponse<T>> {
|
|
200
|
-
return
|
|
201
|
-
fetchTypeProcessor(
|
|
202
|
-
|
|
266
|
+
return pipe(
|
|
267
|
+
fetchTypeProcessor(expectedMime, contentTypeRequired),
|
|
268
|
+
cancelBodyOnError(fetchResponseJsonTranformer<T>),
|
|
203
269
|
)
|
|
204
270
|
}
|
|
205
271
|
|
|
206
|
-
export function fetchJsonZodProcessor<S extends
|
|
272
|
+
export function fetchJsonZodProcessor<S extends ZodTypeAny>(
|
|
207
273
|
schema: S,
|
|
208
|
-
params?: Partial<
|
|
209
|
-
): Transformer<ParsedJsonResponse,
|
|
210
|
-
return async (jsonResponse: ParsedJsonResponse): Promise<
|
|
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 {
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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
|
|
12
|
+
super(response.body.pipeThrough(transform), {
|
|
10
13
|
status: response.status,
|
|
11
14
|
statusText: response.statusText,
|
|
12
15
|
headers: response.headers,
|