@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/src/fetch-wrap.ts CHANGED
@@ -1,101 +1,122 @@
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'
4
+ import { padLines, stringifyMessage } from './util.js'
3
5
 
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,
6
+ export function loggedFetch<C = FetchContext>(
7
+ fetch: Fetch<C> = globalThis.fetch,
16
8
  ) {
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
-
9
+ return toRequestTransformer(async function (
10
+ this: C,
11
+ request,
12
+ ): Promise<Response> {
13
+ const requestMessage = await stringifyMessage(request)
25
14
  console.info(
26
- `< HTTP/1.1 ${response.status} ${response.statusText}\n` +
27
- stringifyPayload(response.headers, await response.clone().text()),
15
+ `> ${request.method} ${request.url}\n${padLines(requestMessage, ' ')}`,
28
16
  )
29
17
 
30
- return response
31
- } catch (error) {
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 stringifyPayload = (headers: Headers, body: string) =>
39
- [stringifyHeaders(headers), stringifyBody(body)]
40
- .filter(Boolean)
41
- .join('\n ') + '\n '
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
- const stringifyHeaders = (headers: Headers) =>
44
- Array.from(headers)
45
- .map(([name, value]) => ` ${name}: ${value}\n`)
46
- .join('')
26
+ return response
27
+ } catch (error) {
28
+ console.error(`< Error:`, error)
47
29
 
48
- const stringifyBody = (body: string) =>
49
- body ? `\n ${body.replace(/\r?\n/g, '\\n')}` : ''
30
+ throw error
31
+ }
32
+ })
33
+ }
50
34
 
51
- export const timeoutFetchWrap = ({
52
- fetch = globalThis.fetch as Fetch,
35
+ export const timedFetch = <C = FetchContext>(
53
36
  timeout = 60e3,
54
- } = {}): Fetch => {
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 (request) {
60
- return fetchTimeout.call(this, request, timeout, fetch)
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
- 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
- }
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
- 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,
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
- declare global {
9
- interface JSON {
10
- parse(text: string, reviver?: (key: any, value: any) => any): Json
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?: 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 as Json
37
- { [key: string]: unknown }
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 ifArray = <V>(v?: V) => (Array.isArray(v) ? v : undefined)
45
- export const ifScalar = <V>(v?: V) => {
46
- switch (typeof v) {
47
- case 'string':
48
- case 'number':
49
- case 'boolean':
50
- return v
51
- default:
52
- if (v === null) return null as V & null
53
- return undefined as V extends JsonScalar ? never : undefined
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)
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": ["../../../tsconfig/isomorphic.json"],
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
package/tsconfig.json CHANGED
@@ -1,8 +1,4 @@
1
1
  {
2
- "extends": "../../tsconfig/isomorphic.json",
3
- "compilerOptions": {
4
- "outDir": "dist",
5
- "rootDir": "src"
6
- },
7
- "include": ["src"]
2
+ "include": [],
3
+ "references": [{ "path": "./tsconfig.build.json" }]
8
4
  }