@atproto-labs/fetch 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -5
- 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 +9 -5
- package/dist/fetch-request.d.ts.map +1 -1
- package/dist/fetch-request.js +39 -13
- 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 +42 -9
- package/dist/fetch-wrap.d.ts.map +1 -1
- package/dist/fetch-wrap.js +92 -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 +45 -14
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +115 -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 +52 -20
- package/src/fetch-response.ts +177 -111
- package/src/fetch-wrap.ts +104 -83
- package/src/fetch.ts +38 -3
- package/src/transformed-response.ts +5 -2
- package/src/util.ts +135 -25
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +2 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atproto-labs/fetch",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Isomorphic wrapper utilities for fetch API",
|
|
6
6
|
"keywords": [
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
13
13
|
"url": "https://github.com/bluesky-social/atproto",
|
|
14
|
-
"directory": "packages/fetch"
|
|
14
|
+
"directory": "packages/internal/fetch"
|
|
15
15
|
},
|
|
16
16
|
"type": "commonjs",
|
|
17
17
|
"main": "dist/index.js",
|
|
@@ -23,13 +23,14 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"
|
|
27
|
-
"zod": "^3.22.4",
|
|
28
|
-
"@atproto-labs/transformer": "0.0.1"
|
|
26
|
+
"@atproto-labs/pipe": "0.1.0"
|
|
29
27
|
},
|
|
30
28
|
"devDependencies": {
|
|
31
29
|
"typescript": "^5.3.3"
|
|
32
30
|
},
|
|
31
|
+
"optionalDependencies": {
|
|
32
|
+
"zod": "^3.23.8"
|
|
33
|
+
},
|
|
33
34
|
"scripts": {
|
|
34
35
|
"build": "tsc --build tsconfig.json"
|
|
35
36
|
}
|
package/src/fetch-error.ts
CHANGED
|
@@ -1,44 +1,26 @@
|
|
|
1
|
-
import { Transformer } from '@atproto-labs/transformer'
|
|
2
|
-
|
|
3
|
-
export type FetchErrorOptions = {
|
|
4
|
-
cause?: unknown
|
|
5
|
-
request?: Request
|
|
6
|
-
response?: Response
|
|
7
|
-
}
|
|
8
|
-
|
|
9
1
|
export class FetchError extends Error {
|
|
10
|
-
public readonly
|
|
11
|
-
public readonly response?: Response
|
|
2
|
+
public readonly statusCode: number
|
|
12
3
|
|
|
13
|
-
constructor(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
this.request = request
|
|
20
|
-
this.response = response
|
|
21
|
-
}
|
|
4
|
+
constructor(statusCode?: number, message?: string, options?: ErrorOptions) {
|
|
5
|
+
if (statusCode == null || !message) {
|
|
6
|
+
const info = extractInfo(extractRootCause(options?.cause))
|
|
7
|
+
statusCode = statusCode ?? info[0]
|
|
8
|
+
message = message || info[1]
|
|
9
|
+
}
|
|
22
10
|
|
|
23
|
-
|
|
24
|
-
const cause = extractCause(err)
|
|
25
|
-
return new FetchError(...extractInfo(cause), { cause })
|
|
26
|
-
}
|
|
27
|
-
}
|
|
11
|
+
super(message, options)
|
|
28
12
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
) => {
|
|
32
|
-
throw await FetchError.from(err)
|
|
13
|
+
this.statusCode = statusCode
|
|
14
|
+
}
|
|
33
15
|
}
|
|
34
16
|
|
|
35
|
-
function
|
|
17
|
+
function extractRootCause(err: unknown): unknown {
|
|
36
18
|
// Unwrap the Network error from undici (i.e. Node's internal fetch() implementation)
|
|
37
19
|
// https://github.com/nodejs/undici/blob/3274c975947ce11a08508743df026f73598bfead/lib/web/fetch/index.js#L223-L228
|
|
38
20
|
if (
|
|
39
21
|
err instanceof TypeError &&
|
|
40
22
|
err.message === 'fetch failed' &&
|
|
41
|
-
err.cause
|
|
23
|
+
err.cause !== undefined
|
|
42
24
|
) {
|
|
43
25
|
return err.cause
|
|
44
26
|
}
|
|
@@ -46,32 +28,32 @@ function extractCause(err: unknown): unknown {
|
|
|
46
28
|
return err
|
|
47
29
|
}
|
|
48
30
|
|
|
49
|
-
|
|
50
|
-
err: unknown,
|
|
51
|
-
): [statusCode: number, message: string] {
|
|
31
|
+
function extractInfo(err: unknown): [statusCode: number, message: string] {
|
|
52
32
|
if (typeof err === 'string' && err.length > 0) {
|
|
53
|
-
return [
|
|
33
|
+
return [500, err]
|
|
54
34
|
}
|
|
55
35
|
|
|
56
36
|
if (!(err instanceof Error)) {
|
|
57
|
-
return [
|
|
37
|
+
return [500, 'Failed to fetch']
|
|
58
38
|
}
|
|
59
39
|
|
|
60
|
-
|
|
40
|
+
const code = err['code']
|
|
41
|
+
if (typeof code === 'string') {
|
|
61
42
|
switch (true) {
|
|
62
|
-
case
|
|
63
|
-
return [
|
|
64
|
-
case
|
|
43
|
+
case code === 'ENOTFOUND':
|
|
44
|
+
return [400, 'Invalid hostname']
|
|
45
|
+
case code === 'ECONNREFUSED':
|
|
65
46
|
return [502, 'Connection refused']
|
|
66
|
-
case
|
|
47
|
+
case code === 'DEPTH_ZERO_SELF_SIGNED_CERT':
|
|
67
48
|
return [502, 'Self-signed certificate']
|
|
68
|
-
case
|
|
49
|
+
case code.startsWith('ERR_TLS'):
|
|
69
50
|
return [502, 'TLS error']
|
|
70
|
-
case
|
|
51
|
+
case code.startsWith('ECONN'):
|
|
71
52
|
return [502, 'Connection error']
|
|
53
|
+
default:
|
|
54
|
+
return [500, `${code} error`]
|
|
72
55
|
}
|
|
73
56
|
}
|
|
74
57
|
|
|
75
|
-
|
|
76
|
-
return [502, err.message]
|
|
58
|
+
return [500, err.message]
|
|
77
59
|
}
|
package/src/fetch-request.ts
CHANGED
|
@@ -1,40 +1,70 @@
|
|
|
1
|
-
import { Transformer } from '@atproto-labs/transformer'
|
|
2
|
-
|
|
3
1
|
import { FetchError } from './fetch-error.js'
|
|
2
|
+
import { asRequest } from './fetch.js'
|
|
4
3
|
import { isIp } from './util.js'
|
|
5
4
|
|
|
6
|
-
export
|
|
5
|
+
export class FetchRequestError extends FetchError {
|
|
6
|
+
constructor(
|
|
7
|
+
public readonly request: Request,
|
|
8
|
+
statusCode?: number,
|
|
9
|
+
message?: string,
|
|
10
|
+
options?: ErrorOptions,
|
|
11
|
+
) {
|
|
12
|
+
super(statusCode, message, options)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static from(request: Request, cause: unknown): FetchRequestError {
|
|
16
|
+
if (cause instanceof FetchRequestError) return cause
|
|
17
|
+
return new FetchRequestError(request, undefined, undefined, { cause })
|
|
18
|
+
}
|
|
19
|
+
}
|
|
7
20
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
21
|
+
const extractUrl = (input: Request | string | URL) =>
|
|
22
|
+
typeof input === 'string'
|
|
23
|
+
? new URL(input)
|
|
24
|
+
: input instanceof URL
|
|
25
|
+
? input
|
|
26
|
+
: new URL(input.url)
|
|
27
|
+
|
|
28
|
+
export function protocolCheckRequestTransform(protocols: Iterable<string>) {
|
|
11
29
|
const allowedProtocols = new Set<string>(protocols)
|
|
12
30
|
|
|
13
|
-
return
|
|
14
|
-
const { protocol } =
|
|
31
|
+
return (input: Request | string | URL, init?: RequestInit) => {
|
|
32
|
+
const { protocol } = extractUrl(input)
|
|
33
|
+
|
|
34
|
+
const request = asRequest(input, init)
|
|
15
35
|
|
|
16
36
|
if (!allowedProtocols.has(protocol)) {
|
|
17
|
-
throw new
|
|
37
|
+
throw new FetchRequestError(
|
|
38
|
+
request,
|
|
39
|
+
400,
|
|
40
|
+
`"${protocol}" protocol is not allowed`,
|
|
41
|
+
)
|
|
18
42
|
}
|
|
19
43
|
|
|
20
44
|
return request
|
|
21
45
|
}
|
|
22
46
|
}
|
|
23
47
|
|
|
24
|
-
export function requireHostHeaderTranform()
|
|
25
|
-
return
|
|
48
|
+
export function requireHostHeaderTranform() {
|
|
49
|
+
return (input: Request | string | URL, init?: RequestInit) => {
|
|
26
50
|
// Note that fetch() will automatically add the Host header from the URL and
|
|
27
51
|
// discard any Host header manually set in the request.
|
|
28
52
|
|
|
29
|
-
const { protocol, hostname } =
|
|
53
|
+
const { protocol, hostname } = extractUrl(input)
|
|
54
|
+
|
|
55
|
+
const request = asRequest(input, init)
|
|
30
56
|
|
|
31
57
|
// "Host" header only makes sense in the context of an HTTP request
|
|
32
|
-
if (protocol !== 'http:' && protocol !== 'https') {
|
|
33
|
-
throw new
|
|
58
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
59
|
+
throw new FetchRequestError(
|
|
60
|
+
request,
|
|
61
|
+
400,
|
|
62
|
+
`"${protocol}" requests are not allowed`,
|
|
63
|
+
)
|
|
34
64
|
}
|
|
35
65
|
|
|
36
66
|
if (!hostname || isIp(hostname)) {
|
|
37
|
-
throw new
|
|
67
|
+
throw new FetchRequestError(request, 400, 'Invalid hostname')
|
|
38
68
|
}
|
|
39
69
|
|
|
40
70
|
return request
|
|
@@ -54,7 +84,7 @@ export const DEFAULT_FORBIDDEN_DOMAIN_NAMES = [
|
|
|
54
84
|
|
|
55
85
|
export function forbiddenDomainNameRequestTransform(
|
|
56
86
|
denyList: Iterable<string> = DEFAULT_FORBIDDEN_DOMAIN_NAMES,
|
|
57
|
-
)
|
|
87
|
+
) {
|
|
58
88
|
const denySet = new Set<string>(denyList)
|
|
59
89
|
|
|
60
90
|
// Optimization: if no forbidden domain names are provided, we can skip the
|
|
@@ -63,12 +93,14 @@ export function forbiddenDomainNameRequestTransform(
|
|
|
63
93
|
return async (request) => request
|
|
64
94
|
}
|
|
65
95
|
|
|
66
|
-
return async (
|
|
67
|
-
const { hostname } =
|
|
96
|
+
return async (input: Request | string | URL, init?: RequestInit) => {
|
|
97
|
+
const { hostname } = extractUrl(input)
|
|
98
|
+
|
|
99
|
+
const request = asRequest(input, init)
|
|
68
100
|
|
|
69
101
|
// Full domain name check
|
|
70
102
|
if (denySet.has(hostname)) {
|
|
71
|
-
throw new
|
|
103
|
+
throw new FetchRequestError(request, 403, 'Forbidden hostname')
|
|
72
104
|
}
|
|
73
105
|
|
|
74
106
|
// Sub domain name check
|
|
@@ -76,7 +108,7 @@ export function forbiddenDomainNameRequestTransform(
|
|
|
76
108
|
while (curDot !== -1) {
|
|
77
109
|
const subdomain = hostname.slice(curDot + 1)
|
|
78
110
|
if (denySet.has(`*.${subdomain}`)) {
|
|
79
|
-
throw new
|
|
111
|
+
throw new FetchRequestError(request, 403, 'Forbidden hostname')
|
|
80
112
|
}
|
|
81
113
|
curDot = hostname.indexOf('.', curDot + 1)
|
|
82
114
|
}
|
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
|
}
|