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};