@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/src/fetch-wrap.ts
CHANGED
|
@@ -1,101 +1,122 @@
|
|
|
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'
|
|
4
|
+
import { padLines, stringifyMessage } from './util.js'
|
|
3
5
|
|
|
4
|
-
export
|
|
5
|
-
fetch = globalThis.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,
|
|
6
|
+
export function loggedFetch<C = FetchContext>(
|
|
7
|
+
fetch: Fetch<C> = globalThis.fetch,
|
|
16
8
|
) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
const response = await fetch(request)
|
|
24
|
-
|
|
9
|
+
return toRequestTransformer(async function (
|
|
10
|
+
this: C,
|
|
11
|
+
request,
|
|
12
|
+
): Promise<Response> {
|
|
13
|
+
const requestMessage = await stringifyMessage(request)
|
|
25
14
|
console.info(
|
|
26
|
-
|
|
27
|
-
stringifyPayload(response.headers, await response.clone().text()),
|
|
15
|
+
`> ${request.method} ${request.url}\n${padLines(requestMessage, ' ')}`,
|
|
28
16
|
)
|
|
29
17
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
console.error(`< Error:`, error)
|
|
33
|
-
|
|
34
|
-
throw error
|
|
35
|
-
}
|
|
36
|
-
}
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetch.call(this, request)
|
|
37
20
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
21
|
+
const responseMessage = await stringifyMessage(response.clone())
|
|
22
|
+
console.info(
|
|
23
|
+
`< HTTP/1.1 ${response.status} ${response.statusText}\n${padLines(responseMessage, ' ')}`,
|
|
24
|
+
)
|
|
42
25
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.join('')
|
|
26
|
+
return response
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error(`< Error:`, error)
|
|
47
29
|
|
|
48
|
-
|
|
49
|
-
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
|
50
34
|
|
|
51
|
-
export const
|
|
52
|
-
fetch = globalThis.fetch as Fetch,
|
|
35
|
+
export const timedFetch = <C = FetchContext>(
|
|
53
36
|
timeout = 60e3,
|
|
54
|
-
|
|
37
|
+
fetch: Fetch<C> = globalThis.fetch,
|
|
38
|
+
): Fetch<C> => {
|
|
55
39
|
if (timeout === Infinity) return fetch
|
|
56
40
|
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
57
41
|
throw new TypeError('Timeout must be positive')
|
|
58
42
|
}
|
|
59
|
-
return async function (
|
|
60
|
-
|
|
61
|
-
|
|
43
|
+
return toRequestTransformer(async function (
|
|
44
|
+
this: C,
|
|
45
|
+
request,
|
|
46
|
+
): Promise<Response> {
|
|
47
|
+
const controller = new AbortController()
|
|
48
|
+
const signal = controller.signal
|
|
49
|
+
|
|
50
|
+
const abort = () => {
|
|
51
|
+
controller.abort()
|
|
52
|
+
}
|
|
53
|
+
const cleanup = () => {
|
|
54
|
+
clearTimeout(timer)
|
|
55
|
+
request.signal?.removeEventListener('abort', abort)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const timer = setTimeout(abort, timeout)
|
|
59
|
+
if (typeof timer === 'object') timer.unref?.() // only on node
|
|
60
|
+
request.signal?.addEventListener('abort', abort)
|
|
61
|
+
|
|
62
|
+
signal.addEventListener('abort', cleanup)
|
|
63
|
+
|
|
64
|
+
const response = await fetch.call(this, request, { signal })
|
|
65
|
+
|
|
66
|
+
if (!response.body) {
|
|
67
|
+
cleanup()
|
|
68
|
+
return response
|
|
69
|
+
} else {
|
|
70
|
+
// Cleanup the timer & event listeners when the body stream is closed
|
|
71
|
+
const transform = new TransformStream({ flush: cleanup })
|
|
72
|
+
return new TransformedResponse(response, transform)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
62
75
|
}
|
|
63
76
|
|
|
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
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Wraps a fetch function to bind it to a specific context, and wrap any thrown
|
|
79
|
+
* errors into a FetchRequestError.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
*
|
|
83
|
+
* ```ts
|
|
84
|
+
* class MyClient {
|
|
85
|
+
* constructor(private fetch = globalThis.fetch) {}
|
|
86
|
+
*
|
|
87
|
+
* async get(url: string) {
|
|
88
|
+
* // This will generate an error, because the context used is not a
|
|
89
|
+
* // FetchContext (it's a MyClient instance).
|
|
90
|
+
* return this.fetch(url)
|
|
91
|
+
* }
|
|
92
|
+
* }
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
*
|
|
97
|
+
* ```ts
|
|
98
|
+
* class MyClient {
|
|
99
|
+
* private fetch: Fetch<unknown>
|
|
100
|
+
*
|
|
101
|
+
* constructor(fetch = globalThis.fetch) {
|
|
102
|
+
* this.fetch = bindFetch(fetch)
|
|
103
|
+
* }
|
|
104
|
+
*
|
|
105
|
+
* async get(url: string) {
|
|
106
|
+
* return this.fetch(url) // no more error
|
|
107
|
+
* }
|
|
108
|
+
* }
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function bindFetch<C = FetchContext>(
|
|
112
|
+
fetch: Fetch<C> = globalThis.fetch,
|
|
113
|
+
context: C = globalThis as C,
|
|
114
|
+
) {
|
|
115
|
+
return toRequestTransformer(async (request) => {
|
|
116
|
+
try {
|
|
117
|
+
return await fetch.call(context, request)
|
|
118
|
+
} catch (err) {
|
|
119
|
+
throw FetchRequestError.from(request, err)
|
|
120
|
+
}
|
|
121
|
+
})
|
|
101
122
|
}
|
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,
|
package/src/util.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
// TODO: Move to a shared package ?
|
|
1
|
+
// @TODO: Move some of these to a shared package ?
|
|
2
2
|
|
|
3
3
|
export type JsonScalar = string | number | boolean | null
|
|
4
4
|
export type Json = JsonScalar | Json[] | { [key: string]: undefined | Json }
|
|
5
5
|
export type JsonObject = { [key: string]: Json }
|
|
6
6
|
export type JsonArray = Json[]
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
export type ThisParameterOverride<
|
|
9
|
+
C,
|
|
10
|
+
Fn extends (...a: any) => any,
|
|
11
|
+
> = Fn extends (...args: infer P) => infer R
|
|
12
|
+
? ((this: C, ...args: P) => R) & {
|
|
13
|
+
bind(context: C): (...args: P) => R
|
|
14
|
+
}
|
|
15
|
+
: never
|
|
13
16
|
|
|
14
17
|
export function isIp(hostname: string) {
|
|
15
18
|
// IPv4
|
|
@@ -21,10 +24,8 @@ export function isIp(hostname: string) {
|
|
|
21
24
|
return false
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
// TODO: Move to a shared package ?
|
|
25
|
-
|
|
26
27
|
const plainObjectProto = Object.prototype
|
|
27
|
-
export const ifObject = <V>(v
|
|
28
|
+
export const ifObject = <V>(v: V) => {
|
|
28
29
|
if (typeof v === 'object' && v != null && !Array.isArray(v)) {
|
|
29
30
|
const proto = Object.getPrototypeOf(v)
|
|
30
31
|
if (proto === null || proto === plainObjectProto) {
|
|
@@ -33,27 +34,136 @@ export const ifObject = <V>(v?: V) => {
|
|
|
33
34
|
? never
|
|
34
35
|
: V extends Json
|
|
35
36
|
? V
|
|
36
|
-
: // Plain object are (mostly) safe to access
|
|
37
|
-
|
|
37
|
+
: // Plain object are (mostly) safe to access using a string index
|
|
38
|
+
Record<string, unknown>
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
return undefined
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
export const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
export const ifString = <V>(v: V) => (typeof v === 'string' ? v : undefined)
|
|
46
|
+
|
|
47
|
+
export class MaxBytesTransformStream extends TransformStream<
|
|
48
|
+
Uint8Array,
|
|
49
|
+
Uint8Array
|
|
50
|
+
> {
|
|
51
|
+
constructor(maxBytes: number) {
|
|
52
|
+
// Note: negation accounts for invalid value types (NaN, non numbers)
|
|
53
|
+
if (!(maxBytes >= 0)) {
|
|
54
|
+
throw new TypeError('maxBytes must be a non-negative number')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let bytesRead = 0
|
|
58
|
+
|
|
59
|
+
super({
|
|
60
|
+
transform: (
|
|
61
|
+
chunk: Uint8Array,
|
|
62
|
+
ctrl: TransformStreamDefaultController<Uint8Array>,
|
|
63
|
+
) => {
|
|
64
|
+
if ((bytesRead += chunk.length) <= maxBytes) {
|
|
65
|
+
ctrl.enqueue(chunk)
|
|
66
|
+
} else {
|
|
67
|
+
ctrl.error(new Error('Response too large'))
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const LINE_BREAK = /\r?\n/g
|
|
75
|
+
export function padLines(input: string, pad: string) {
|
|
76
|
+
if (!input) return input
|
|
77
|
+
return pad + input.replace(LINE_BREAK, `$&${pad}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param [onCancellationError] - Callback that will trigger to asynchronously
|
|
82
|
+
* handle any error that occurs while cancelling the response body. Providing
|
|
83
|
+
* this will speed up the process and avoid potential deadlocks. Defaults to
|
|
84
|
+
* awaiting the cancellation operation. use `"log"` to log the error.
|
|
85
|
+
* @see {@link https://undici.nodejs.org/#/?id=garbage-collection}
|
|
86
|
+
* @note awaiting this function's result, when no `onCancellationError` is
|
|
87
|
+
* provided, might result in a dead lock. Indeed, if the response was cloned(),
|
|
88
|
+
* the response.body.cancel() method will not resolve until the other response's
|
|
89
|
+
* body is consumed/cancelled.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* // Make sure response was not cloned, or that every cloned response was
|
|
94
|
+
* // consumed/cancelled before awaiting this function's result.
|
|
95
|
+
* await cancelBody(response)
|
|
96
|
+
* ```
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* await cancelBody(response, (err) => {
|
|
100
|
+
* // No biggie, let's just log the error
|
|
101
|
+
* console.warn('Failed to cancel response body', err)
|
|
102
|
+
* })
|
|
103
|
+
* ```
|
|
104
|
+
* @example
|
|
105
|
+
* ```ts
|
|
106
|
+
* // Will generate an "unhandledRejection" if an error occurs while cancelling
|
|
107
|
+
* // the response body. This will likely crash the process.
|
|
108
|
+
* await cancelBody(response, (err) => { throw err })
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export async function cancelBody(
|
|
112
|
+
body: Body,
|
|
113
|
+
onCancellationError?: 'log' | ((err: unknown) => void),
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
if (
|
|
116
|
+
body.body &&
|
|
117
|
+
!body.bodyUsed &&
|
|
118
|
+
!body.body.locked &&
|
|
119
|
+
// Support for alternative fetch implementations
|
|
120
|
+
typeof body.body.cancel === 'function'
|
|
121
|
+
) {
|
|
122
|
+
if (typeof onCancellationError === 'function') {
|
|
123
|
+
void body.body.cancel().catch(onCancellationError)
|
|
124
|
+
} else if (onCancellationError === 'log') {
|
|
125
|
+
void body.body.cancel().catch(logCancellationError)
|
|
126
|
+
} else {
|
|
127
|
+
await body.body.cancel()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function logCancellationError(err: unknown): void {
|
|
133
|
+
console.warn('Failed to cancel response body', err)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function stringifyMessage(input: Body & { headers: Headers }) {
|
|
137
|
+
try {
|
|
138
|
+
const headers = stringifyHeaders(input.headers)
|
|
139
|
+
const payload = await stringifyBody(input)
|
|
140
|
+
return headers && payload ? `${headers}\n${payload}` : headers || payload
|
|
141
|
+
} finally {
|
|
142
|
+
void cancelBody(input, 'log')
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stringifyHeaders(headers: Headers) {
|
|
147
|
+
return Array.from(headers)
|
|
148
|
+
.map(([name, value]) => `${name}: ${value}`)
|
|
149
|
+
.join('\n')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function stringifyBody(body: Body) {
|
|
153
|
+
try {
|
|
154
|
+
const blob = await body.blob()
|
|
155
|
+
if (blob.type?.startsWith('text/')) {
|
|
156
|
+
const text = await blob.text()
|
|
157
|
+
return JSON.stringify(text)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (/application\/(?:\w+\+)?json/.test(blob.type)) {
|
|
161
|
+
const text = await blob.text()
|
|
162
|
+
return text.includes('\n') ? JSON.stringify(JSON.parse(text)) : text
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return `[Body size: ${blob.size}, type: ${JSON.stringify(blob.type)} ]`
|
|
166
|
+
} catch {
|
|
167
|
+
return '[Body could not be read]'
|
|
54
168
|
}
|
|
55
169
|
}
|
|
56
|
-
export const ifBoolean = <V>(v?: V) => (typeof v === 'boolean' ? v : undefined)
|
|
57
|
-
export const ifString = <V>(v?: V) => (typeof v === 'string' ? v : undefined)
|
|
58
|
-
export const ifNumber = <V>(v?: V) => (typeof v === 'number' ? v : undefined)
|
|
59
|
-
export const ifNull = <V>(v?: V) => (v === null ? v : undefined)
|