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