@atproto-labs/fetch 0.0.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE.txt +7 -0
  3. package/dist/fetch-error.d.ts +16 -0
  4. package/dist/fetch-error.d.ts.map +1 -0
  5. package/dist/fetch-error.js +73 -0
  6. package/dist/fetch-error.js.map +1 -0
  7. package/dist/fetch-request.d.ts +7 -0
  8. package/dist/fetch-request.d.ts.map +1 -0
  9. package/dist/fetch-request.js +69 -0
  10. package/dist/fetch-request.js.map +1 -0
  11. package/dist/fetch-response.d.ts +24 -0
  12. package/dist/fetch-response.d.ts.map +1 -0
  13. package/dist/fetch-response.js +151 -0
  14. package/dist/fetch-response.js.map +1 -0
  15. package/dist/fetch-wrap.d.ts +10 -0
  16. package/dist/fetch-wrap.d.ts.map +1 -0
  17. package/dist/fetch-wrap.js +73 -0
  18. package/dist/fetch-wrap.js.map +1 -0
  19. package/dist/fetch.d.ts +2 -0
  20. package/dist/fetch.d.ts.map +1 -0
  21. package/dist/fetch.js +3 -0
  22. package/dist/fetch.js.map +1 -0
  23. package/dist/index.d.ts +7 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +23 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/transformed-response.d.ts +12 -0
  28. package/dist/transformed-response.d.ts.map +1 -0
  29. package/dist/transformed-response.js +47 -0
  30. package/dist/transformed-response.js.map +1 -0
  31. package/dist/util.d.ts +24 -0
  32. package/dist/util.d.ts.map +1 -0
  33. package/dist/util.js +51 -0
  34. package/dist/util.js.map +1 -0
  35. package/package.json +36 -0
  36. package/src/fetch-error.ts +77 -0
  37. package/src/fetch-request.ts +86 -0
  38. package/src/fetch-response.ts +212 -0
  39. package/src/fetch-wrap.ts +101 -0
  40. package/src/fetch.ts +4 -0
  41. package/src/index.ts +6 -0
  42. package/src/transformed-response.ts +33 -0
  43. package/src/util.ts +59 -0
  44. package/tsconfig.json +8 -0
package/dist/util.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ export type JsonScalar = string | number | boolean | null;
2
+ export type Json = JsonScalar | Json[] | {
3
+ [key: string]: undefined | Json;
4
+ };
5
+ export type JsonObject = {
6
+ [key: string]: Json;
7
+ };
8
+ export type JsonArray = Json[];
9
+ declare global {
10
+ interface JSON {
11
+ parse(text: string, reviver?: (key: any, value: any) => any): Json;
12
+ }
13
+ }
14
+ export declare function isIp(hostname: string): boolean;
15
+ export declare const ifObject: <V>(v?: V) => (V extends symbol | Function | JsonScalar | JsonArray ? never : V extends Json ? V : {
16
+ [key: string]: unknown;
17
+ }) | undefined;
18
+ export declare const ifArray: <V>(v?: V) => (V & any[]) | undefined;
19
+ export declare const ifScalar: <V>(v?: V) => (V & string) | (V & number) | (V & boolean) | (V & null) | (V extends JsonScalar ? never : undefined);
20
+ export declare const ifBoolean: <V>(v?: V) => (V & boolean) | undefined;
21
+ export declare const ifString: <V>(v?: V) => (V & string) | undefined;
22
+ export declare const ifNumber: <V>(v?: V) => (V & number) | undefined;
23
+ export declare const ifNull: <V>(v?: V) => V | undefined;
24
+ //# sourceMappingURL=util.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,CAAA;AACzD,MAAM,MAAM,IAAI,GAAG,UAAU,GAAG,IAAI,EAAE,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAA;CAAE,CAAA;AAC5E,MAAM,MAAM,UAAU,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAA;AAChD,MAAM,MAAM,SAAS,GAAG,IAAI,EAAE,CAAA;AAE9B,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,IAAI;QACZ,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,KAAK,GAAG,GAAG,IAAI,CAAA;KACnE;CACF;AAED,wBAAgB,IAAI,CAAC,QAAQ,EAAE,MAAM,WAQpC;AAKD,eAAO,MAAM,QAAQ,UAAW,CAAC;;cAehC,CAAA;AAED,eAAO,MAAM,OAAO,UAAW,CAAC,4BAAuC,CAAA;AACvE,eAAO,MAAM,QAAQ,UAAW,CAAC,0GAUhC,CAAA;AACD,eAAO,MAAM,SAAS,UAAW,CAAC,8BAA6C,CAAA;AAC/E,eAAO,MAAM,QAAQ,UAAW,CAAC,6BAA4C,CAAA;AAC7E,eAAO,MAAM,QAAQ,UAAW,CAAC,6BAA4C,CAAA;AAC7E,eAAO,MAAM,MAAM,UAAW,CAAC,kBAAiC,CAAA"}
package/dist/util.js ADDED
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ // TODO: Move to a shared package ?
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.ifNull = exports.ifNumber = exports.ifString = exports.ifBoolean = exports.ifScalar = exports.ifArray = exports.ifObject = exports.isIp = void 0;
5
+ function isIp(hostname) {
6
+ // IPv4
7
+ if (hostname.match(/^\d+\.\d+\.\d+\.\d+$/))
8
+ return true;
9
+ // IPv6
10
+ if (hostname.startsWith('[') && hostname.endsWith(']'))
11
+ return true;
12
+ return false;
13
+ }
14
+ exports.isIp = isIp;
15
+ // TODO: Move to a shared package ?
16
+ const plainObjectProto = Object.prototype;
17
+ const ifObject = (v) => {
18
+ if (typeof v === 'object' && v != null && !Array.isArray(v)) {
19
+ const proto = Object.getPrototypeOf(v);
20
+ if (proto === null || proto === plainObjectProto) {
21
+ // eslint-disable-next-line @typescript-eslint/ban-types
22
+ return v;
23
+ }
24
+ }
25
+ return undefined;
26
+ };
27
+ exports.ifObject = ifObject;
28
+ const ifArray = (v) => (Array.isArray(v) ? v : undefined);
29
+ exports.ifArray = ifArray;
30
+ const ifScalar = (v) => {
31
+ switch (typeof v) {
32
+ case 'string':
33
+ case 'number':
34
+ case 'boolean':
35
+ return v;
36
+ default:
37
+ if (v === null)
38
+ return null;
39
+ return undefined;
40
+ }
41
+ };
42
+ exports.ifScalar = ifScalar;
43
+ const ifBoolean = (v) => (typeof v === 'boolean' ? v : undefined);
44
+ exports.ifBoolean = ifBoolean;
45
+ const ifString = (v) => (typeof v === 'string' ? v : undefined);
46
+ exports.ifString = ifString;
47
+ const ifNumber = (v) => (typeof v === 'number' ? v : undefined);
48
+ exports.ifNumber = ifNumber;
49
+ const ifNull = (v) => (v === null ? v : undefined);
50
+ exports.ifNull = ifNull;
51
+ //# sourceMappingURL=util.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":";AAAA,mCAAmC;;;AAanC,SAAgB,IAAI,CAAC,QAAgB;IACnC,OAAO;IACP,IAAI,QAAQ,CAAC,KAAK,CAAC,sBAAsB,CAAC;QAAE,OAAO,IAAI,CAAA;IAEvD,OAAO;IACP,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IAEnE,OAAO,KAAK,CAAA;AACd,CAAC;AARD,oBAQC;AAED,mCAAmC;AAEnC,MAAM,gBAAgB,GAAG,MAAM,CAAC,SAAS,CAAA;AAClC,MAAM,QAAQ,GAAG,CAAI,CAAK,EAAE,EAAE;IACnC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5D,MAAM,KAAK,GAAG,MAAM,CAAC,cAAc,CAAC,CAAC,CAAC,CAAA;QACtC,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,gBAAgB,EAAE,CAAC;YACjD,wDAAwD;YACxD,OAAO,CAKyB,CAAA;QAClC,CAAC;IACH,CAAC;IAED,OAAO,SAAS,CAAA;AAClB,CAAC,CAAA;AAfY,QAAA,QAAQ,YAepB;AAEM,MAAM,OAAO,GAAG,CAAI,CAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAA;AAA1D,QAAA,OAAO,WAAmD;AAChE,MAAM,QAAQ,GAAG,CAAI,CAAK,EAAE,EAAE;IACnC,QAAQ,OAAO,CAAC,EAAE,CAAC;QACjB,KAAK,QAAQ,CAAC;QACd,KAAK,QAAQ,CAAC;QACd,KAAK,SAAS;YACZ,OAAO,CAAC,CAAA;QACV;YACE,IAAI,CAAC,KAAK,IAAI;gBAAE,OAAO,IAAgB,CAAA;YACvC,OAAO,SAAqD,CAAA;IAChE,CAAC;AACH,CAAC,CAAA;AAVY,QAAA,QAAQ,YAUpB;AACM,MAAM,SAAS,GAAG,CAAI,CAAK,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAA;AAAlE,QAAA,SAAS,aAAyD;AACxE,MAAM,QAAQ,GAAG,CAAI,CAAK,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAA;AAAhE,QAAA,QAAQ,YAAwD;AACtE,MAAM,QAAQ,GAAG,CAAI,CAAK,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAA;AAAhE,QAAA,QAAQ,YAAwD;AACtE,MAAM,MAAM,GAAG,CAAI,CAAK,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAA;AAAnD,QAAA,MAAM,UAA6C"}
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@atproto-labs/fetch",
3
+ "version": "0.0.1",
4
+ "license": "MIT",
5
+ "description": "Isomorphic wrapper utilities for fetch API",
6
+ "keywords": [
7
+ "atproto",
8
+ "fetch"
9
+ ],
10
+ "homepage": "https://atproto.com",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/bluesky-social/atproto",
14
+ "directory": "packages/fetch"
15
+ },
16
+ "type": "commonjs",
17
+ "main": "dist/index.js",
18
+ "types": "dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "dependencies": {
26
+ "tslib": "^2.6.2",
27
+ "zod": "^3.22.4",
28
+ "@atproto-labs/transformer": "0.0.1"
29
+ },
30
+ "devDependencies": {
31
+ "typescript": "^5.3.3"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc --build tsconfig.json"
35
+ }
36
+ }
@@ -0,0 +1,77 @@
1
+ import { Transformer } from '@atproto-labs/transformer'
2
+
3
+ export type FetchErrorOptions = {
4
+ cause?: unknown
5
+ request?: Request
6
+ response?: Response
7
+ }
8
+
9
+ export class FetchError extends Error {
10
+ public readonly request?: Request
11
+ public readonly response?: Response
12
+
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
+ }
22
+
23
+ static async from(err: unknown) {
24
+ const cause = extractCause(err)
25
+ return new FetchError(...extractInfo(cause), { cause })
26
+ }
27
+ }
28
+
29
+ export const fetchFailureHandler: Transformer<unknown, never> = async (
30
+ err: unknown,
31
+ ) => {
32
+ throw await FetchError.from(err)
33
+ }
34
+
35
+ function extractCause(err: unknown): unknown {
36
+ // Unwrap the Network error from undici (i.e. Node's internal fetch() implementation)
37
+ // https://github.com/nodejs/undici/blob/3274c975947ce11a08508743df026f73598bfead/lib/web/fetch/index.js#L223-L228
38
+ if (
39
+ err instanceof TypeError &&
40
+ err.message === 'fetch failed' &&
41
+ err.cause instanceof Error
42
+ ) {
43
+ return err.cause
44
+ }
45
+
46
+ return err
47
+ }
48
+
49
+ export function extractInfo(
50
+ err: unknown,
51
+ ): [statusCode: number, message: string] {
52
+ if (typeof err === 'string' && err.length > 0) {
53
+ return [502, err]
54
+ }
55
+
56
+ if (!(err instanceof Error)) {
57
+ return [502, 'Unable to fetch']
58
+ }
59
+
60
+ if ('code' in err && typeof err.code === 'string') {
61
+ switch (true) {
62
+ case err.code === 'ENOTFOUND':
63
+ return [404, 'Invalid hostname']
64
+ case err.code === 'ECONNREFUSED':
65
+ return [502, 'Connection refused']
66
+ case err.code === 'DEPTH_ZERO_SELF_SIGNED_CERT':
67
+ return [502, 'Self-signed certificate']
68
+ case err.code.startsWith('ERR_TLS'):
69
+ return [502, 'TLS error']
70
+ case err.code.startsWith('ECONN'):
71
+ return [502, 'Connection error']
72
+ }
73
+ }
74
+
75
+ // Let's assume that other errors are "bad gateway" errors
76
+ return [502, err.message]
77
+ }
@@ -0,0 +1,86 @@
1
+ import { Transformer } from '@atproto-labs/transformer'
2
+
3
+ import { FetchError } from './fetch-error.js'
4
+ import { isIp } from './util.js'
5
+
6
+ export type RequestTranformer = Transformer<Request>
7
+
8
+ export function protocolCheckRequestTransform(
9
+ protocols: Iterable<string>,
10
+ ): RequestTranformer {
11
+ const allowedProtocols = new Set<string>(protocols)
12
+
13
+ return async (request) => {
14
+ const { protocol } = new URL(request.url)
15
+
16
+ if (!allowedProtocols.has(protocol)) {
17
+ throw new FetchError(400, `${protocol} is not allowed`, { request })
18
+ }
19
+
20
+ return request
21
+ }
22
+ }
23
+
24
+ export function requireHostHeaderTranform(): RequestTranformer {
25
+ return async (request) => {
26
+ // Note that fetch() will automatically add the Host header from the URL and
27
+ // discard any Host header manually set in the request.
28
+
29
+ const { protocol, hostname } = new URL(request.url)
30
+
31
+ // "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 })
34
+ }
35
+
36
+ if (!hostname || isIp(hostname)) {
37
+ throw new FetchError(400, 'Invalid hostname', { request })
38
+ }
39
+
40
+ return request
41
+ }
42
+ }
43
+
44
+ export const DEFAULT_FORBIDDEN_DOMAIN_NAMES = [
45
+ 'example.com',
46
+ '*.example.com',
47
+ 'example.org',
48
+ '*.example.org',
49
+ 'example.net',
50
+ '*.example.net',
51
+ 'googleusercontent.com',
52
+ '*.googleusercontent.com',
53
+ ]
54
+
55
+ export function forbiddenDomainNameRequestTransform(
56
+ denyList: Iterable<string> = DEFAULT_FORBIDDEN_DOMAIN_NAMES,
57
+ ): RequestTranformer {
58
+ const denySet = new Set<string>(denyList)
59
+
60
+ // Optimization: if no forbidden domain names are provided, we can skip the
61
+ // check entirely.
62
+ if (denySet.size === 0) {
63
+ return async (request) => request
64
+ }
65
+
66
+ return async (request) => {
67
+ const { hostname } = new URL(request.url)
68
+
69
+ // Full domain name check
70
+ if (denySet.has(hostname)) {
71
+ throw new FetchError(403, 'Forbidden hostname', { request })
72
+ }
73
+
74
+ // Sub domain name check
75
+ let curDot = hostname.indexOf('.')
76
+ while (curDot !== -1) {
77
+ const subdomain = hostname.slice(curDot + 1)
78
+ if (denySet.has(`*.${subdomain}`)) {
79
+ throw new FetchError(403, 'Forbidden hostname', { request })
80
+ }
81
+ curDot = hostname.indexOf('.', curDot + 1)
82
+ }
83
+
84
+ return request
85
+ }
86
+ }
@@ -0,0 +1,212 @@
1
+ import { Transformer, compose } from '@atproto-labs/transformer'
2
+ import { z } from 'zod'
3
+
4
+ import { FetchError, FetchErrorOptions } from './fetch-error.js'
5
+ import { Json, ifObject, ifString } from './util.js'
6
+ import { TransformedResponse } from './transformed-response.js'
7
+
8
+ export type ResponseTranformer = Transformer<Response>
9
+ export type ResponseMessageGetter = Transformer<Response, string | undefined>
10
+
11
+ const extractResponseMessage: ResponseMessageGetter = async (response) => {
12
+ if (!response.body) return undefined
13
+
14
+ const contentType = response.headers.get('content-type')
15
+ if (!contentType) return undefined
16
+
17
+ const mimeType = contentType.split(';')[0].trim()
18
+ if (!mimeType) return undefined
19
+
20
+ try {
21
+ if (mimeType === 'text/plain') {
22
+ return await response.text()
23
+ } else if (/^application\/(?:[^+]+\+)?json$/i.test(mimeType)) {
24
+ const json = await response.json()
25
+
26
+ if (typeof json === 'string') return json
27
+
28
+ const errorDescription = ifString(ifObject(json)?.['error_description'])
29
+ if (errorDescription) return errorDescription
30
+
31
+ const error = ifString(ifObject(json)?.['error'])
32
+ if (error) return error
33
+
34
+ const message = ifString(ifObject(json)?.['message'])
35
+ if (message) return message
36
+ }
37
+ } catch {
38
+ // noop
39
+ }
40
+
41
+ return undefined
42
+ }
43
+
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 })
52
+ }
53
+
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
66
+
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()
70
+
71
+ return new FetchResponseError(response, statusCode, message, options)
72
+ }
73
+ }
74
+
75
+ export function fetchOkProcessor(
76
+ customMessage?: string | ResponseMessageGetter,
77
+ ): ResponseTranformer {
78
+ return async (response) => {
79
+ if (response.ok) return response
80
+ throw await FetchResponseError.from(response, undefined, customMessage)
81
+ }
82
+ }
83
+
84
+ export function fetchMaxSizeProcessor(maxBytes: number): ResponseTranformer {
85
+ if (maxBytes === Infinity) return (response) => response
86
+ if (!Number.isFinite(maxBytes) || maxBytes < 0) {
87
+ throw new TypeError('maxBytes must be a non-negative number')
88
+ }
89
+ return async (response) => fetchResponseMaxSize(response, maxBytes)
90
+ }
91
+
92
+ export async function fetchResponseMaxSize(
93
+ response: Response,
94
+ maxBytes: number,
95
+ ): Promise<Response> {
96
+ if (maxBytes === Infinity) return response
97
+ if (!response.body) return response
98
+
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
+ })
123
+
124
+ return new TransformedResponse(response, transform)
125
+ }
126
+
127
+ export type ContentTypeCheckFn = (contentType: string) => boolean
128
+ export type ContentTypeCheck = string | RegExp | ContentTypeCheckFn
129
+
130
+ export function fetchTypeProcessor(
131
+ expectedType: ContentTypeCheck,
132
+ contentTypeRequired = true,
133
+ ): ResponseTranformer {
134
+ const isExpected: ContentTypeCheckFn =
135
+ typeof expectedType === 'string'
136
+ ? (ct) => ct === expectedType
137
+ : expectedType instanceof RegExp
138
+ ? (ct) => expectedType.test(ct)
139
+ : expectedType
140
+
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) {
156
+ throw await FetchResponseError.from(
157
+ response,
158
+ 502,
159
+ 'Missing response Content-Type header',
160
+ )
161
+ }
162
+
163
+ return response
164
+ }
165
+ }
166
+
167
+ export type ParsedJsonResponse<T = Json> = {
168
+ response: Response
169
+ json: T
170
+ }
171
+
172
+ export async function jsonTranformer<T = Json>(
173
+ response: Response,
174
+ ): 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
+ try {
184
+ const json = (await response.json()) as T
185
+ return { response, json }
186
+ } catch (cause) {
187
+ throw new FetchResponseError(
188
+ response,
189
+ 502,
190
+ 'Unable to parse response as JSON',
191
+ { cause },
192
+ )
193
+ }
194
+ }
195
+
196
+ export function fetchJsonProcessor<T = Json>(
197
+ contentType: ContentTypeCheck = /^application\/(?:[^+]+\+)?json$/,
198
+ contentTypeRequired = true,
199
+ ): Transformer<Response, ParsedJsonResponse<T>> {
200
+ return compose(
201
+ fetchTypeProcessor(contentType, contentTypeRequired),
202
+ jsonTranformer<T>,
203
+ )
204
+ }
205
+
206
+ export function fetchJsonZodProcessor<S extends z.ZodTypeAny>(
207
+ schema: S,
208
+ params?: Partial<z.ParseParams>,
209
+ ): Transformer<ParsedJsonResponse, z.infer<S>> {
210
+ return async (jsonResponse: ParsedJsonResponse): Promise<z.infer<S>> =>
211
+ schema.parseAsync(jsonResponse.json, params)
212
+ }
@@ -0,0 +1,101 @@
1
+ import { Fetch } from './fetch.js'
2
+ import { TransformedResponse } from './transformed-response.js'
3
+
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,
16
+ ) {
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
+
25
+ console.info(
26
+ `< HTTP/1.1 ${response.status} ${response.statusText}\n` +
27
+ stringifyPayload(response.headers, await response.clone().text()),
28
+ )
29
+
30
+ return response
31
+ } catch (error) {
32
+ console.error(`< Error:`, error)
33
+
34
+ throw error
35
+ }
36
+ }
37
+
38
+ const stringifyPayload = (headers: Headers, body: string) =>
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,
53
+ timeout = 60e3,
54
+ } = {}): Fetch => {
55
+ if (timeout === Infinity) return fetch
56
+ if (!Number.isFinite(timeout) || timeout <= 0) {
57
+ throw new TypeError('Timeout must be positive')
58
+ }
59
+ return async function (request) {
60
+ return fetchTimeout.call(this, request, timeout, fetch)
61
+ }
62
+ }
63
+
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
+ }
101
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type Fetch = (
2
+ this: void | null | typeof globalThis,
3
+ input: Request,
4
+ ) => Promise<Response>
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './fetch-error.js'
2
+ export * from './fetch-request.js'
3
+ export * from './fetch-response.js'
4
+ export * from './fetch-wrap.js'
5
+ export * from './fetch.js'
6
+ export * from './util.js'
@@ -0,0 +1,33 @@
1
+ export class TransformedResponse extends Response {
2
+ #response: Response
3
+
4
+ constructor(response: Response, transform: TransformStream) {
5
+ if (response.body && response.bodyUsed) {
6
+ throw new TypeError('Response body is already used')
7
+ }
8
+
9
+ super(response.body?.pipeThrough(transform) ?? null, {
10
+ status: response.status,
11
+ statusText: response.statusText,
12
+ headers: response.headers,
13
+ })
14
+
15
+ this.#response = response
16
+ }
17
+
18
+ /**
19
+ * Some props can't be set through ResponseInit, so we need to proxy them
20
+ */
21
+ get url() {
22
+ return this.#response.url
23
+ }
24
+ get redirected() {
25
+ return this.#response.redirected
26
+ }
27
+ get type() {
28
+ return this.#response.type
29
+ }
30
+ get statusText() {
31
+ return this.#response.statusText
32
+ }
33
+ }