@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.
- package/CHANGELOG.md +10 -0
- package/LICENSE.txt +7 -0
- package/dist/fetch-error.d.ts +16 -0
- package/dist/fetch-error.d.ts.map +1 -0
- package/dist/fetch-error.js +73 -0
- package/dist/fetch-error.js.map +1 -0
- package/dist/fetch-request.d.ts +7 -0
- package/dist/fetch-request.d.ts.map +1 -0
- package/dist/fetch-request.js +69 -0
- package/dist/fetch-request.js.map +1 -0
- package/dist/fetch-response.d.ts +24 -0
- package/dist/fetch-response.d.ts.map +1 -0
- package/dist/fetch-response.js +151 -0
- package/dist/fetch-response.js.map +1 -0
- package/dist/fetch-wrap.d.ts +10 -0
- package/dist/fetch-wrap.d.ts.map +1 -0
- package/dist/fetch-wrap.js +73 -0
- package/dist/fetch-wrap.js.map +1 -0
- package/dist/fetch.d.ts +2 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +3 -0
- package/dist/fetch.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/transformed-response.d.ts +12 -0
- package/dist/transformed-response.d.ts.map +1 -0
- package/dist/transformed-response.js +47 -0
- package/dist/transformed-response.js.map +1 -0
- package/dist/util.d.ts +24 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +51 -0
- package/dist/util.js.map +1 -0
- package/package.json +36 -0
- package/src/fetch-error.ts +77 -0
- package/src/fetch-request.ts +86 -0
- package/src/fetch-response.ts +212 -0
- package/src/fetch-wrap.ts +101 -0
- package/src/fetch.ts +4 -0
- package/src/index.ts +6 -0
- package/src/transformed-response.ts +33 -0
- package/src/util.ts +59 -0
- 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
|
package/dist/util.js.map
ADDED
|
@@ -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
package/src/index.ts
ADDED
|
@@ -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
|
+
}
|