@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto-labs/fetch",
3
- "version": "0.0.1",
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
- "tslib": "^2.6.2",
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
  }
@@ -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 request?: Request
11
- public readonly response?: Response
2
+ public readonly statusCode: number
12
3
 
13
- constructor(
14
- public readonly statusCode: number,
15
- message?: string,
16
- { cause, request, response }: FetchErrorOptions = {},
17
- ) {
18
- super(message, { cause })
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
- static async from(err: unknown) {
24
- const cause = extractCause(err)
25
- return new FetchError(...extractInfo(cause), { cause })
26
- }
27
- }
11
+ super(message, options)
28
12
 
29
- export const fetchFailureHandler: Transformer<unknown, never> = async (
30
- err: unknown,
31
- ) => {
32
- throw await FetchError.from(err)
13
+ this.statusCode = statusCode
14
+ }
33
15
  }
34
16
 
35
- function extractCause(err: unknown): unknown {
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 instanceof Error
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
- export function extractInfo(
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 [502, err]
33
+ return [500, err]
54
34
  }
55
35
 
56
36
  if (!(err instanceof Error)) {
57
- return [502, 'Unable to fetch']
37
+ return [500, 'Failed to fetch']
58
38
  }
59
39
 
60
- if ('code' in err && typeof err.code === 'string') {
40
+ const code = err['code']
41
+ if (typeof code === 'string') {
61
42
  switch (true) {
62
- case err.code === 'ENOTFOUND':
63
- return [404, 'Invalid hostname']
64
- case err.code === 'ECONNREFUSED':
43
+ case code === 'ENOTFOUND':
44
+ return [400, 'Invalid hostname']
45
+ case code === 'ECONNREFUSED':
65
46
  return [502, 'Connection refused']
66
- case err.code === 'DEPTH_ZERO_SELF_SIGNED_CERT':
47
+ case code === 'DEPTH_ZERO_SELF_SIGNED_CERT':
67
48
  return [502, 'Self-signed certificate']
68
- case err.code.startsWith('ERR_TLS'):
49
+ case code.startsWith('ERR_TLS'):
69
50
  return [502, 'TLS error']
70
- case err.code.startsWith('ECONN'):
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
- // Let's assume that other errors are "bad gateway" errors
76
- return [502, err.message]
58
+ return [500, err.message]
77
59
  }
@@ -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 type RequestTranformer = Transformer<Request>
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
- export function protocolCheckRequestTransform(
9
- protocols: Iterable<string>,
10
- ): RequestTranformer {
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 async (request) => {
14
- const { protocol } = new URL(request.url)
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 FetchError(400, `${protocol} is not allowed`, { request })
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(): RequestTranformer {
25
- return async (request) => {
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 } = new URL(request.url)
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 FetchError(400, `Forbidden protocol ${protocol}`, { request })
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 FetchError(400, 'Invalid hostname', { request })
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
- ): RequestTranformer {
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 (request) => {
67
- const { hostname } = new URL(request.url)
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 FetchError(403, 'Forbidden hostname', { request })
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 FetchError(403, 'Forbidden hostname', { request })
111
+ throw new FetchRequestError(request, 403, 'Forbidden hostname')
80
112
  }
81
113
  curDot = hostname.indexOf('.', curDot + 1)
82
114
  }
@@ -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
  }