cloudflare/cloudflare-typescript
Publicmirrored from https://github.com/cloudflare/cloudflare-typescriptAvailable
src/uploads.ts
245lines · modeblame
2d51afdcstainless-app[bot]2 years ago | 1 | import { type RequestOptions } from './core'; |
| 2 | import { | |
| 3 | FormData, | |
| 4 | File, | |
| 5 | type Blob, | |
| 6 | type FilePropertyBag, | |
| 7 | getMultipartRequestOptions, | |
| 8 | type FsReadStream, | |
| 9 | isFsReadStream, | |
| 10 | } from './_shims/index'; | |
| 11 | import { MultipartBody } from './_shims/MultipartBody'; | |
| 12 | export { fileFromPath } from './_shims/index'; | |
| 13 | | |
| 14 | type BlobLikePart = string | ArrayBuffer | ArrayBufferView | BlobLike | Uint8Array | DataView; | |
| 15 | export type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob | Uint8Array | DataView; | |
| 16 | | |
| 17 | /** | |
| 18 | * Typically, this is a native "File" class. | |
| 19 | * | |
| 20 | * We provide the {@link toFile} utility to convert a variety of objects | |
| 21 | * into the File class. | |
| 22 | * | |
| 23 | * For convenience, you can also pass a fetch Response, or in Node, | |
| 24 | * the result of fs.createReadStream(). | |
| 25 | */ | |
| 26 | export type Uploadable = FileLike | ResponseLike | FsReadStream; | |
| 27 | | |
| 28 | /** | |
| 29 | * Intended to match web.Blob, node.Blob, node-fetch.Blob, etc. | |
| 30 | */ | |
| 31 | export interface BlobLike { | |
| 32 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */ | |
| 33 | readonly size: number; | |
| 34 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */ | |
| 35 | readonly type: string; | |
| 36 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */ | |
| 37 | text(): Promise<string>; | |
| 38 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */ | |
| 39 | slice(start?: number, end?: number): BlobLike; | |
| 40 | // unfortunately @types/node-fetch@^2.6.4 doesn't type the arrayBuffer method | |
| 41 | } | |
| 42 | | |
| 43 | /** | |
| 44 | * Intended to match web.File, node.File, node-fetch.File, etc. | |
| 45 | */ | |
| 46 | export interface FileLike extends BlobLike { | |
| 47 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */ | |
| 48 | readonly lastModified: number; | |
| 49 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */ | |
| 50 | readonly name: string; | |
| 51 | } | |
| 52 | | |
| 53 | /** | |
| 54 | * Intended to match web.Response, node.Response, node-fetch.Response, etc. | |
| 55 | */ | |
| 56 | export interface ResponseLike { | |
| 57 | url: string; | |
| 58 | blob(): Promise<BlobLike>; | |
| 59 | } | |
| 60 | | |
| 61 | export const isResponseLike = (value: any): value is ResponseLike => | |
| 62 | value != null && | |
| 63 | typeof value === 'object' && | |
| 64 | typeof value.url === 'string' && | |
| 65 | typeof value.blob === 'function'; | |
| 66 | | |
| 67 | export const isFileLike = (value: any): value is FileLike => | |
| 68 | value != null && | |
| 69 | typeof value === 'object' && | |
| 70 | typeof value.name === 'string' && | |
| 71 | typeof value.lastModified === 'number' && | |
| 72 | isBlobLike(value); | |
| 73 | | |
| 74 | /** | |
| 75 | * The BlobLike type omits arrayBuffer() because @types/node-fetch@^2.6.4 lacks it; but this check | |
| 76 | * adds the arrayBuffer() method type because it is available and used at runtime | |
| 77 | */ | |
| 78 | export const isBlobLike = (value: any): value is BlobLike & { arrayBuffer(): Promise<ArrayBuffer> } => | |
| 79 | value != null && | |
| 80 | typeof value === 'object' && | |
| 81 | typeof value.size === 'number' && | |
| 82 | typeof value.type === 'string' && | |
| 83 | typeof value.text === 'function' && | |
| 84 | typeof value.slice === 'function' && | |
| 85 | typeof value.arrayBuffer === 'function'; | |
| 86 | | |
| 87 | export const isUploadable = (value: any): value is Uploadable => { | |
| 88 | return isFileLike(value) || isResponseLike(value) || isFsReadStream(value); | |
| 89 | }; | |
| 90 | | |
| 91 | export type ToFileInput = Uploadable | Exclude<BlobLikePart, string> | AsyncIterable<BlobLikePart>; | |
| 92 | | |
| 93 | /** | |
| 94 | * Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats | |
| 95 | * @param value the raw content of the file. Can be an {@link Uploadable}, {@link BlobLikePart}, or {@link AsyncIterable} of {@link BlobLikePart}s | |
| 96 | * @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible | |
| 97 | * @param {Object=} options additional properties | |
| 98 | * @param {string=} options.type the MIME type of the content | |
| 99 | * @param {number=} options.lastModified the last modified timestamp | |
| 100 | * @returns a {@link File} with the given properties | |
| 101 | */ | |
| 102 | export async function toFile( | |
| 103 | value: ToFileInput | PromiseLike<ToFileInput>, | |
| 104 | name?: string | null | undefined, | |
| 105 | options: FilePropertyBag | undefined = {}, | |
| 106 | ): Promise<FileLike> { | |
| 107 | // If it's a promise, resolve it. | |
| 108 | value = await value; | |
| 109 | | |
| 110 | if (isResponseLike(value)) { | |
| 111 | const blob = await value.blob(); | |
| 112 | name ||= new URL(value.url).pathname.split(/[\\/]/).pop() ?? 'unknown_file'; | |
| 113 | | |
| 114 | return new File([blob as any], name, options); | |
| 115 | } | |
| 116 | | |
| 117 | const bits = await getBytes(value); | |
| 118 | | |
| 119 | name ||= getName(value) ?? 'unknown_file'; | |
| 120 | | |
| 121 | if (!options.type) { | |
| 122 | const type = (bits[0] as any)?.type; | |
| 123 | if (typeof type === 'string') { | |
| 124 | options = { ...options, type }; | |
| 125 | } | |
| 126 | } | |
| 127 | | |
| 128 | return new File(bits, name, options); | |
| 129 | } | |
| 130 | | |
| 131 | async function getBytes(value: ToFileInput): Promise<Array<BlobPart>> { | |
| 132 | let parts: Array<BlobPart> = []; | |
| 133 | if ( | |
| 134 | typeof value === 'string' || | |
| 135 | ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc. | |
| 136 | value instanceof ArrayBuffer | |
| 137 | ) { | |
| 138 | parts.push(value); | |
| 139 | } else if (isBlobLike(value)) { | |
| 140 | parts.push(await value.arrayBuffer()); | |
| 141 | } else if ( | |
| 142 | isAsyncIterableIterator(value) // includes Readable, ReadableStream, etc. | |
| 143 | ) { | |
| 144 | for await (const chunk of value) { | |
| 145 | parts.push(chunk as BlobPart); // TODO, consider validating? | |
| 146 | } | |
| 147 | } else { | |
| 148 | throw new Error( | |
| 149 | `Unexpected data type: ${typeof value}; constructor: ${value?.constructor | |
| 150 | ?.name}; props: ${propsForError(value)}`, | |
| 151 | ); | |
| 152 | } | |
| 153 | | |
| 154 | return parts; | |
| 155 | } | |
| 156 | | |
| 157 | function propsForError(value: any): string { | |
| 158 | const props = Object.getOwnPropertyNames(value); | |
| 159 | return `[${props.map((p) => `"${p}"`).join(', ')}]`; | |
| 160 | } | |
| 161 | | |
| 162 | function getName(value: any): string | undefined { | |
| 163 | return ( | |
| 164 | getStringFromMaybeBuffer(value.name) || | |
| 165 | getStringFromMaybeBuffer(value.filename) || | |
| 166 | // For fs.ReadStream | |
| 167 | getStringFromMaybeBuffer(value.path)?.split(/[\\/]/).pop() | |
| 168 | ); | |
| 169 | } | |
| 170 | | |
| 171 | const getStringFromMaybeBuffer = (x: string | Buffer | unknown): string | undefined => { | |
| 172 | if (typeof x === 'string') return x; | |
| 173 | if (typeof Buffer !== 'undefined' && x instanceof Buffer) return String(x); | |
| 174 | return undefined; | |
| 175 | }; | |
| 176 | | |
| 177 | const isAsyncIterableIterator = (value: any): value is AsyncIterableIterator<unknown> => | |
| 178 | value != null && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function'; | |
| 179 | | |
| 180 | export const isMultipartBody = (body: any): body is MultipartBody => | |
| 181 | body && typeof body === 'object' && body.body && body[Symbol.toStringTag] === 'MultipartBody'; | |
| 182 | | |
| 183 | /** | |
| 184 | * Returns a multipart/form-data request if any part of the given request body contains a File / Blob value. | |
| 185 | * Otherwise returns the request as is. | |
| 186 | */ | |
| 187 | export const maybeMultipartFormRequestOptions = async <T = Record<string, unknown>>( | |
| 188 | opts: RequestOptions<T>, | |
| 189 | ): Promise<RequestOptions<T | MultipartBody>> => { | |
| 190 | if (!hasUploadableValue(opts.body)) return opts; | |
| 191 | | |
| 192 | const form = await createForm(opts.body); | |
| 193 | return getMultipartRequestOptions(form, opts); | |
| 194 | }; | |
| 195 | | |
| 196 | export const multipartFormRequestOptions = async <T = Record<string, unknown>>( | |
| 197 | opts: RequestOptions<T>, | |
| 198 | ): Promise<RequestOptions<T | MultipartBody>> => { | |
| 199 | const form = await createForm(opts.body); | |
| 200 | return getMultipartRequestOptions(form, opts); | |
| 201 | }; | |
| 202 | | |
| 203 | export const createForm = async <T = Record<string, unknown>>(body: T | undefined): Promise<FormData> => { | |
| 204 | const form = new FormData(); | |
| 205 | await Promise.all(Object.entries(body || {}).map(([key, value]) => addFormValue(form, key, value))); | |
| 206 | return form; | |
| 207 | }; | |
| 208 | | |
| 209 | const hasUploadableValue = (value: unknown): boolean => { | |
| 210 | if (isUploadable(value)) return true; | |
| 211 | if (Array.isArray(value)) return value.some(hasUploadableValue); | |
| 212 | if (value && typeof value === 'object') { | |
| 213 | for (const k in value) { | |
| 214 | if (hasUploadableValue((value as any)[k])) return true; | |
| 215 | } | |
| 216 | } | |
| 217 | return false; | |
| 218 | }; | |
| 219 | | |
| 220 | const addFormValue = async (form: FormData, key: string, value: unknown): Promise<void> => { | |
| 221 | if (value === undefined) return; | |
| 222 | if (value == null) { | |
| 223 | throw new TypeError( | |
| 224 | `Received null for "${key}"; to pass null in FormData, you must use the string 'null'`, | |
| 225 | ); | |
| 226 | } | |
| 227 | | |
| 228 | // TODO: make nested formats configurable | |
| 229 | if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { | |
| 230 | form.append(key, String(value)); | |
| 231 | } else if (isUploadable(value)) { | |
| 232 | const file = await toFile(value); | |
| 233 | form.append(key, file as File); | |
| 234 | } else if (Array.isArray(value)) { | |
| 235 | await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry))); | |
| 236 | } else if (typeof value === 'object') { | |
| 237 | await Promise.all( | |
| 238 | Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop)), | |
| 239 | ); | |
| 240 | } else { | |
| 241 | throw new TypeError( | |
| 242 | `Invalid value given to form, expected a string, number, boolean, object, Array, File or Blob but got ${value} instead`, | |
| 243 | ); | |
| 244 | } | |
| 245 | }; |