cloudflare/cloudflare-typescript
Publicmirrored fromhttps://github.com/cloudflare/cloudflare-typescriptAvailable
src/uploads.ts
248lines · modecode
| 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 | // Use the file's options if there isn't one provided |
| 111 | options ??= isFileLike(value) ? { lastModified: value.lastModified, type: value.type } : {}; |
| 112 | |
| 113 | if (isResponseLike(value)) { |
| 114 | const blob = await value.blob(); |
| 115 | name ||= new URL(value.url).pathname.split(/[\\/]/).pop() ?? 'unknown_file'; |
| 116 | |
| 117 | return new File([blob as any], name, options); |
| 118 | } |
| 119 | |
| 120 | const bits = await getBytes(value); |
| 121 | |
| 122 | name ||= getName(value) ?? 'unknown_file'; |
| 123 | |
| 124 | if (!options.type) { |
| 125 | const type = (bits[0] as any)?.type; |
| 126 | if (typeof type === 'string') { |
| 127 | options = { ...options, type }; |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | return new File(bits, name, options); |
| 132 | } |
| 133 | |
| 134 | async function getBytes(value: ToFileInput): Promise<Array<BlobPart>> { |
| 135 | let parts: Array<BlobPart> = []; |
| 136 | if ( |
| 137 | typeof value === 'string' || |
| 138 | ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc. |
| 139 | value instanceof ArrayBuffer |
| 140 | ) { |
| 141 | parts.push(value); |
| 142 | } else if (isBlobLike(value)) { |
| 143 | parts.push(await value.arrayBuffer()); |
| 144 | } else if ( |
| 145 | isAsyncIterableIterator(value) // includes Readable, ReadableStream, etc. |
| 146 | ) { |
| 147 | for await (const chunk of value) { |
| 148 | parts.push(chunk as BlobPart); // TODO, consider validating? |
| 149 | } |
| 150 | } else { |
| 151 | throw new Error( |
| 152 | `Unexpected data type: ${typeof value}; constructor: ${value?.constructor |
| 153 | ?.name}; props: ${propsForError(value)}`, |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | return parts; |
| 158 | } |
| 159 | |
| 160 | function propsForError(value: any): string { |
| 161 | const props = Object.getOwnPropertyNames(value); |
| 162 | return `[${props.map((p) => `"${p}"`).join(', ')}]`; |
| 163 | } |
| 164 | |
| 165 | function getName(value: any): string | undefined { |
| 166 | return ( |
| 167 | getStringFromMaybeBuffer(value.name) || |
| 168 | getStringFromMaybeBuffer(value.filename) || |
| 169 | // For fs.ReadStream |
| 170 | getStringFromMaybeBuffer(value.path)?.split(/[\\/]/).pop() |
| 171 | ); |
| 172 | } |
| 173 | |
| 174 | const getStringFromMaybeBuffer = (x: string | Buffer | unknown): string | undefined => { |
| 175 | if (typeof x === 'string') return x; |
| 176 | if (typeof Buffer !== 'undefined' && x instanceof Buffer) return String(x); |
| 177 | return undefined; |
| 178 | }; |
| 179 | |
| 180 | const isAsyncIterableIterator = (value: any): value is AsyncIterableIterator<unknown> => |
| 181 | value != null && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function'; |
| 182 | |
| 183 | export const isMultipartBody = (body: any): body is MultipartBody => |
| 184 | body && typeof body === 'object' && body.body && body[Symbol.toStringTag] === 'MultipartBody'; |
| 185 | |
| 186 | /** |
| 187 | * Returns a multipart/form-data request if any part of the given request body contains a File / Blob value. |
| 188 | * Otherwise returns the request as is. |
| 189 | */ |
| 190 | export const maybeMultipartFormRequestOptions = async <T = Record<string, unknown>>( |
| 191 | opts: RequestOptions<T>, |
| 192 | ): Promise<RequestOptions<T | MultipartBody>> => { |
| 193 | if (!hasUploadableValue(opts.body)) return opts; |
| 194 | |
| 195 | const form = await createForm(opts.body); |
| 196 | return getMultipartRequestOptions(form, opts); |
| 197 | }; |
| 198 | |
| 199 | export const multipartFormRequestOptions = async <T = Record<string, unknown>>( |
| 200 | opts: RequestOptions<T>, |
| 201 | ): Promise<RequestOptions<T | MultipartBody>> => { |
| 202 | const form = await createForm(opts.body); |
| 203 | return getMultipartRequestOptions(form, opts); |
| 204 | }; |
| 205 | |
| 206 | export const createForm = async <T = Record<string, unknown>>(body: T | undefined): Promise<FormData> => { |
| 207 | const form = new FormData(); |
| 208 | await Promise.all(Object.entries(body || {}).map(([key, value]) => addFormValue(form, key, value))); |
| 209 | return form; |
| 210 | }; |
| 211 | |
| 212 | const hasUploadableValue = (value: unknown): boolean => { |
| 213 | if (isUploadable(value)) return true; |
| 214 | if (Array.isArray(value)) return value.some(hasUploadableValue); |
| 215 | if (value && typeof value === 'object') { |
| 216 | for (const k in value) { |
| 217 | if (hasUploadableValue((value as any)[k])) return true; |
| 218 | } |
| 219 | } |
| 220 | return false; |
| 221 | }; |
| 222 | |
| 223 | const addFormValue = async (form: FormData, key: string, value: unknown): Promise<void> => { |
| 224 | if (value === undefined) return; |
| 225 | if (value == null) { |
| 226 | throw new TypeError( |
| 227 | `Received null for "${key}"; to pass null in FormData, you must use the string 'null'`, |
| 228 | ); |
| 229 | } |
| 230 | |
| 231 | // TODO: make nested formats configurable |
| 232 | if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { |
| 233 | form.append(key, String(value)); |
| 234 | } else if (isUploadable(value)) { |
| 235 | const file = await toFile(value); |
| 236 | form.append(key, file as File); |
| 237 | } else if (Array.isArray(value)) { |
| 238 | await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry))); |
| 239 | } else if (typeof value === 'object') { |
| 240 | await Promise.all( |
| 241 | Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop)), |
| 242 | ); |
| 243 | } else { |
| 244 | throw new TypeError( |
| 245 | `Invalid value given to form, expected a string, number, boolean, object, Array, File or Blob but got ${value} instead`, |
| 246 | ); |
| 247 | } |
| 248 | }; |
| 249 | |