Skip to content

Commit e486ed5

Browse files
committed
feat(util-fetch): initial commit
1 parent 47f14d3 commit e486ed5

11 files changed

+280
-0
lines changed

packages/misc/util-fetch/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# @atcute/util-fetch
2+
3+
random fetch utilities.
+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export class FetchResponseError extends Error {
2+
override name = 'FetchResponseError';
3+
}
4+
5+
export class FailedResponseError extends FetchResponseError {
6+
override name = 'FailedResponseError';
7+
8+
constructor(
9+
public status: number,
10+
reason: string,
11+
) {
12+
super(reason);
13+
}
14+
}
15+
16+
export class ImproperContentTypeError extends FetchResponseError {
17+
override name = 'ImproperContentTypeError';
18+
19+
constructor(
20+
public contentType: string | null,
21+
reason: string,
22+
) {
23+
super(reason);
24+
}
25+
}
26+
27+
export class ImproperContentLengthError extends FetchResponseError {
28+
override name = 'ImproperContentLengthError';
29+
30+
constructor(
31+
public expectedSize: number,
32+
public actualSize: number | null,
33+
reason: string,
34+
) {
35+
super(reason);
36+
}
37+
}
38+
39+
export class ImproperJsonResponseError extends FetchResponseError {
40+
override name = 'ImproperJsonResponse';
41+
42+
constructor(reason: string, options?: ErrorOptions) {
43+
super(reason, options);
44+
}
45+
}

packages/misc/util-fetch/lib/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './errors.js';
2+
export * from './pipeline.js';
3+
export * from './transformers.js';
+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
type Transformer<I, O = I> = (input: I) => Promise<O>;
2+
3+
type PipelineInput<T extends readonly Transformer<any>[]> = T extends [Transformer<infer I, any>, ...any[]]
4+
? I
5+
: T extends Transformer<infer I, any>[]
6+
? I
7+
: never;
8+
9+
type PipelineOutput<T extends readonly Transformer<any>[]> = T extends [...any[], Transformer<any, infer O>]
10+
? O
11+
: T extends Transformer<any, infer O>[]
12+
? O
13+
: never;
14+
15+
type Pipeline<
16+
F extends readonly Transformer<any>[],
17+
Acc extends readonly Transformer<any>[] = [],
18+
> = F extends [Transformer<infer I, infer O>]
19+
? [...Acc, Transformer<I, O>]
20+
: F extends [Transformer<infer A, any>, ...infer Tail]
21+
? Tail extends [Transformer<infer B, any>, ...any[]]
22+
? Pipeline<Tail, [...Acc, Transformer<A, B>]>
23+
: Acc
24+
: Acc;
25+
26+
export function pipe(): never;
27+
export function pipe<T extends readonly Transformer<any>[]>(
28+
...pipeline: Pipeline<T> extends T ? T : Pipeline<T>
29+
): (input: PipelineInput<T>) => Promise<PipelineOutput<T>>;
30+
export function pipe<T extends readonly Transformer<any>[]>(
31+
...pipeline: Pipeline<T> extends T ? T : Pipeline<T>
32+
): (input: PipelineInput<T>) => Promise<PipelineOutput<T>> {
33+
return pipeline.reduce(pipeTwo);
34+
}
35+
36+
const pipeTwo = <I, O, X = unknown>(
37+
first: Transformer<I, X>,
38+
second: Transformer<X, O>,
39+
): ((input: I) => Promise<O>) => {
40+
return (input) => first(input).then(second);
41+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as err from '../errors.js';
2+
3+
export class SizeLimitStream extends TransformStream<Uint8Array, Uint8Array> {
4+
constructor(maxSize: number) {
5+
let bytesRead = 0;
6+
7+
super({
8+
transform(chunk, controller) {
9+
bytesRead += chunk.length;
10+
11+
if (bytesRead > maxSize) {
12+
controller.error(
13+
new err.ImproperContentLengthError(maxSize, bytesRead, `response content-length too large`),
14+
);
15+
16+
return;
17+
}
18+
19+
controller.enqueue(chunk);
20+
},
21+
});
22+
}
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as v from '@badrap/valita';
2+
3+
import * as err from './errors.js';
4+
import { SizeLimitStream } from './streams/size-limit.js';
5+
6+
export type ParsedJsonResponse<T = unknown> = {
7+
response: Response;
8+
json: T;
9+
};
10+
11+
export const isResponseOk = async (response: Response): Promise<Response> => {
12+
if (response.ok) {
13+
return response;
14+
}
15+
16+
if (response.body) {
17+
await response.body.cancel();
18+
}
19+
20+
throw new err.FailedResponseError(response.status, `got http ${response.status}`);
21+
};
22+
23+
export const parseResponseAsJson =
24+
(typeRegex: RegExp, maxSize: number) =>
25+
async (response: Response): Promise<ParsedJsonResponse> => {
26+
assertContentType(response, typeRegex);
27+
28+
const text = await readResponseAsString(response, maxSize);
29+
30+
try {
31+
return JSON.parse(text);
32+
} catch (error) {
33+
throw new err.ImproperJsonResponseError(`response json invalid`, { cause: error });
34+
}
35+
};
36+
37+
export const validateJsonWith =
38+
<T>(schema: v.Type<T>) =>
39+
async (parsed: ParsedJsonResponse): Promise<ParsedJsonResponse<T>> => {
40+
const json = schema.parse(parsed.json);
41+
return { response: parsed.response, json };
42+
};
43+
44+
const assertContentType = async (response: Response, typeRegex: RegExp): Promise<void> => {
45+
const type = response.headers.get('content-type')?.split(';', 1)[0].trim();
46+
47+
if (type === undefined) {
48+
if (response.body) {
49+
await response.body.cancel();
50+
}
51+
52+
throw new err.ImproperContentTypeError(null, `missing response content-type`);
53+
}
54+
55+
if (!typeRegex.test(type)) {
56+
if (response.body) {
57+
await response.body.cancel();
58+
}
59+
60+
throw new err.ImproperContentTypeError(type, `unexpected response content-type`);
61+
}
62+
};
63+
64+
const readResponseAsString = async (response: Response, maxSize: number): Promise<string> => {
65+
const rawSize = response.headers.get('content-length');
66+
if (rawSize !== null) {
67+
const size = Number(rawSize);
68+
69+
if (!Number.isSafeInteger(size) || size <= 0) {
70+
response.body?.cancel();
71+
throw new err.ImproperContentLengthError(maxSize, null, `invalid response content-length`);
72+
}
73+
74+
if (size > maxSize) {
75+
response.body?.cancel();
76+
throw new err.ImproperContentLengthError(maxSize, size, `response content-length too large`);
77+
}
78+
}
79+
80+
const stream = response
81+
.body!.pipeThrough(new SizeLimitStream(maxSize))
82+
.pipeThrough(new TextDecoderStream());
83+
84+
let text = '';
85+
for await (const chunk of stream) {
86+
text += chunk;
87+
}
88+
89+
return text;
90+
};

packages/misc/util-fetch/package.json

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"type": "module",
3+
"name": "@atcute/util-fetch",
4+
"version": "1.0.0",
5+
"description": "atproto DID to DID document resolution",
6+
"keywords": [
7+
"atproto",
8+
"did"
9+
],
10+
"license": "MIT",
11+
"repository": {
12+
"url": "https://github.com/mary-ext/atcute",
13+
"directory": "packages/misc/util-fetch"
14+
},
15+
"files": [
16+
"dist/",
17+
"lib/",
18+
"!lib/**/*.bench.ts",
19+
"!lib/**/*.test.ts"
20+
],
21+
"exports": {
22+
".": "./dist/index.js"
23+
},
24+
"sideEffects": false,
25+
"scripts": {
26+
"build": "tsc --project tsconfig.build.json",
27+
"test": "bun test --coverage",
28+
"prepublish": "rm -rf dist; pnpm run build"
29+
},
30+
"devDependencies": {
31+
"@types/bun": "^1.2.1"
32+
},
33+
"dependencies": {
34+
"@badrap/valita": "^0.4.2"
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"exclude": ["**/*.test.ts"]
4+
}
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"compilerOptions": {
3+
"types": ["bun"],
4+
"outDir": "dist/",
5+
"esModuleInterop": true,
6+
"skipLibCheck": true,
7+
"target": "ESNext",
8+
"allowJs": true,
9+
"resolveJsonModule": true,
10+
"moduleDetection": "force",
11+
"isolatedModules": true,
12+
"verbatimModuleSyntax": true,
13+
"strict": true,
14+
"noImplicitOverride": true,
15+
"noUnusedLocals": true,
16+
"noUnusedParameters": true,
17+
"noFallthroughCasesInSwitch": true,
18+
"module": "NodeNext",
19+
"sourceMap": true,
20+
"declaration": true,
21+
"stripInternal": true,
22+
},
23+
"include": ["lib"],
24+
}

pnpm-lock.yaml

+10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ packages:
44
- packages/definitions/*
55
- packages/identity/*
66
- packages/internal/*
7+
- packages/misc/*
78
- packages/oauth/*
89
- packages/services/*
910
- packages/utilities/*

0 commit comments

Comments
 (0)