Mirror: A Node.js fetch shim using built-in Request, Response, and Headers (but without native fetch)
1import { Readable } from 'node:stream';
2import { isAnyArrayBuffer } from 'node:util/types';
3import { randomBytes } from 'node:crypto';
4
5export type BodyInit =
6 | Exclude<RequestInit['body'], undefined | null>
7 | FormDataPolyfill
8 | Readable;
9
10export interface BodyState {
11 contentLength: number | null;
12 contentType: string | null;
13 body: Readable | ReadableStream | Uint8Array | null;
14}
15
16interface FormDataPolyfill extends Readable {
17 getBoundary(): string;
18 getLengthSync(): number;
19 hasKnownLength(): number;
20}
21
22const CRLF = '\r\n';
23const CRLF_LENGTH = 2;
24const BOUNDARY = '-'.repeat(2);
25
26const isReadable = (object: any): object is Readable =>
27 Readable.isReadable(object);
28
29const isIterable = (
30 object: any
31): object is AsyncIterable<any> | Iterable<any> =>
32 typeof object[Symbol.asyncIterator] === 'function' ||
33 typeof object[Symbol.iterator] === 'function';
34
35const isMultipartFormDataStream = (object: any): object is FormDataPolyfill =>
36 typeof object.getBoundary === 'function' &&
37 typeof object.hasKnownLength === 'function' &&
38 typeof object.getLengthSync === 'function' &&
39 Readable.isReadable(object);
40
41const isFormData = (object: any): object is FormData =>
42 typeof object === 'object' &&
43 typeof object.append === 'function' &&
44 typeof object.set === 'function' &&
45 typeof object.get === 'function' &&
46 typeof object.getAll === 'function' &&
47 typeof object.delete === 'function' &&
48 typeof object.keys === 'function' &&
49 typeof object.values === 'function' &&
50 typeof object.entries === 'function' &&
51 typeof object.constructor === 'function' &&
52 object[Symbol.toStringTag] === 'FormData';
53
54const isURLSearchParameters = (object: any): object is URLSearchParams =>
55 typeof object === 'object' &&
56 typeof object.append === 'function' &&
57 typeof object.delete === 'function' &&
58 typeof object.get === 'function' &&
59 typeof object.getAll === 'function' &&
60 typeof object.has === 'function' &&
61 typeof object.set === 'function' &&
62 typeof object.sort === 'function' &&
63 object[Symbol.toStringTag] === 'URLSearchParams';
64
65const isReadableStream = (object: any): object is ReadableStream =>
66 typeof object === 'object' &&
67 typeof object.getReader === 'function' &&
68 typeof object.cancel === 'function' &&
69 typeof object.tee === 'function';
70
71const isBlob = (object: any): object is Blob => {
72 if (
73 typeof object === 'object' &&
74 typeof object.arrayBuffer === 'function' &&
75 typeof object.type === 'string' &&
76 typeof object.stream === 'function' &&
77 typeof object.constructor === 'function'
78 ) {
79 const tag = object[Symbol.toStringTag];
80 return tag.startsWith('Blob') || tag.startsWith('File');
81 } else {
82 return false;
83 }
84};
85
86const makeFormBoundary = (): string =>
87 `formdata-${randomBytes(8).toString('hex')}`;
88
89const getFormHeader = (
90 boundary: string,
91 name: string,
92 field: File | Blob | string
93): string => {
94 let header = `${BOUNDARY}${boundary}${CRLF}`;
95 header += `Content-Disposition: form-data; name="${name}"`;
96 if (isBlob(field)) {
97 header += `; filename="${(field as File).name ?? 'blob'}"${CRLF}`;
98 header += `Content-Type: ${field.type || 'application/octet-stream'}`;
99 }
100 return `${header}${CRLF}${CRLF}`;
101};
102
103const getFormFooter = (boundary: string) =>
104 `${BOUNDARY}${boundary}${BOUNDARY}${CRLF}${CRLF}`;
105
106export const getFormDataLength = (form: FormData, boundary: string) => {
107 let length = Buffer.byteLength(getFormFooter(boundary));
108 for (const [name, value] of form)
109 length +=
110 Buffer.byteLength(getFormHeader(boundary, name, value)) +
111 (isBlob(value) ? value.size : Buffer.byteLength(`${value}`)) +
112 CRLF_LENGTH;
113 return length;
114};
115
116async function* generatorOfFormData(
117 form: FormData,
118 boundary: string
119): AsyncGenerator<ArrayBufferLike> {
120 const encoder = new TextEncoder();
121 for (const [name, value] of form) {
122 if (isBlob(value)) {
123 yield encoder.encode(getFormHeader(boundary, name, value));
124 yield* value.stream();
125 yield encoder.encode(CRLF);
126 } else {
127 yield encoder.encode(getFormHeader(boundary, name, value) + value + CRLF);
128 }
129 }
130 yield encoder.encode(getFormFooter(boundary));
131}
132
133const encoder = new TextEncoder();
134
135export const extractBody = (object: BodyInit | null): BodyState => {
136 let type: string | null = null;
137 let body: Readable | ReadableStream | Uint8Array | null;
138 let size: number | null = null;
139 if (object == null) {
140 body = null;
141 size = 0;
142 } else if (typeof object === 'string') {
143 const bytes = encoder.encode(`${object}`);
144 type = 'text/plain;charset=UTF-8';
145 size = bytes.byteLength;
146 body = bytes;
147 } else if (isURLSearchParameters(object)) {
148 const bytes = encoder.encode(object.toString());
149 body = bytes;
150 size = bytes.byteLength;
151 type = 'application/x-www-form-urlencoded;charset=UTF-8';
152 } else if (isBlob(object)) {
153 size = object.size;
154 type = object.type || null;
155 body = object.stream();
156 } else if (object instanceof Uint8Array) {
157 body = object;
158 size = object.byteLength;
159 } else if (isAnyArrayBuffer(object)) {
160 const bytes = new Uint8Array(object);
161 body = bytes;
162 size = bytes.byteLength;
163 } else if (ArrayBuffer.isView(object)) {
164 const bytes = new Uint8Array(
165 object.buffer,
166 object.byteOffset,
167 object.byteLength
168 );
169 body = bytes;
170 size = bytes.byteLength;
171 } else if (isReadableStream(object)) {
172 body = object;
173 } else if (isFormData(object)) {
174 const boundary = makeFormBoundary();
175 type = `multipart/form-data; boundary=${boundary}`;
176 size = getFormDataLength(object, boundary);
177 body = Readable.from(generatorOfFormData(object, boundary));
178 } else if (isMultipartFormDataStream(object)) {
179 type = `multipart/form-data; boundary=${object.getBoundary()}`;
180 size = object.hasKnownLength() ? object.getLengthSync() : null;
181 body = object as Readable;
182 } else if (isReadable(object)) {
183 body = object as Readable;
184 } else if (isIterable(object)) {
185 body = Readable.from(object);
186 } else {
187 const bytes = encoder.encode(`${object}`);
188 type = 'text/plain;charset=UTF-8';
189 body = bytes;
190 size = bytes.byteLength;
191 }
192 return {
193 contentLength: size,
194 contentType: type,
195 body,
196 };
197};