uploads.ts (224 lines of code) (raw):

import { type RequestOptions } from "./core.ts"; import { type Blob, File, type FilePropertyBag, FormData, type FsReadStream, getMultipartRequestOptions, isFsReadStream, } from "./_shims/mod.ts"; import { MultipartBody } from "./_shims/MultipartBody.ts"; export { fileFromPath } from "./_shims/mod.ts"; type BlobLikePart = | string | ArrayBuffer | ArrayBufferView | BlobLike | Uint8Array | DataView; export type BlobPart = | string | ArrayBuffer | ArrayBufferView | Blob | Uint8Array | DataView; /** * Typically, this is a native "File" class. * * We provide the {@link toFile} utility to convert a variety of objects * into the File class. * * For convenience, you can also pass a fetch Response, or in Node, * the result of fs.createReadStream(). */ export type Uploadable = FileLike | ResponseLike | FsReadStream; /** * Intended to match web.Blob, node.Blob, node-fetch.Blob, etc. */ export interface BlobLike { /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */ readonly size: number; /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */ readonly type: string; /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */ text(): Promise<string>; /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */ slice(start?: number, end?: number): BlobLike; // unfortunately @types/node-fetch@^2.6.4 doesn't type the arrayBuffer method } /** * Intended to match web.File, node.File, node-fetch.File, etc. */ export interface FileLike extends BlobLike { /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */ readonly lastModified: number; /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */ readonly name: string; } /** * Intended to match web.Response, node.Response, node-fetch.Response, etc. */ export interface ResponseLike { url: string; blob(): Promise<BlobLike>; } export const isResponseLike = (value: any): value is ResponseLike => value != null && typeof value === "object" && typeof value.url === "string" && typeof value.blob === "function"; export const isFileLike = (value: any): value is FileLike => value != null && typeof value === "object" && typeof value.name === "string" && typeof value.lastModified === "number" && isBlobLike(value); /** * The BlobLike type omits arrayBuffer() because @types/node-fetch@^2.6.4 lacks it; but this check * adds the arrayBuffer() method type because it is available and used at runtime */ export const isBlobLike = ( value: any, ): value is BlobLike & { arrayBuffer(): Promise<ArrayBuffer> } => value != null && typeof value === "object" && typeof value.size === "number" && typeof value.type === "string" && typeof value.text === "function" && typeof value.slice === "function" && typeof value.arrayBuffer === "function"; export const isUploadable = (value: any): value is Uploadable => { return isFileLike(value) || isResponseLike(value) || isFsReadStream(value); }; export type ToFileInput = | Uploadable | Exclude<BlobLikePart, string> | AsyncIterable<BlobLikePart>; /** * Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats * @param value the raw content of the file. Can be an {@link Uploadable}, {@link BlobLikePart}, or {@link AsyncIterable} of {@link BlobLikePart}s * @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible * @param {Object=} options additional properties * @param {string=} options.type the MIME type of the content * @param {number=} options.lastModified the last modified timestamp * @returns a {@link File} with the given properties */ export async function toFile( value: ToFileInput | PromiseLike<ToFileInput>, name?: string | null | undefined, options?: FilePropertyBag | undefined, ): Promise<FileLike> { // If it's a promise, resolve it. value = await value; // If we've been given a `File` we don't need to do anything if (isFileLike(value)) { return value; } if (isResponseLike(value)) { const blob = await value.blob(); name ||= new URL(value.url).pathname.split(/[\\/]/).pop() ?? "unknown_file"; // we need to convert the `Blob` into an array buffer because the `Blob` class // that `node-fetch` defines is incompatible with the web standard which results // in `new File` interpreting it as a string instead of binary data. const data = isBlobLike(blob) ? [(await blob.arrayBuffer()) as any] : [blob]; return new File(data, name, options); } const bits = await getBytes(value); name ||= getName(value) ?? "unknown_file"; if (!options?.type) { const type = (bits[0] as any)?.type; if (typeof type === "string") { options = { ...options, type }; } } return new File(bits, name, options); } async function getBytes(value: ToFileInput): Promise<Array<BlobPart>> { let parts: Array<BlobPart> = []; if ( typeof value === "string" || ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc. value instanceof ArrayBuffer ) { parts.push(value); } else if (isBlobLike(value)) { parts.push(await value.arrayBuffer()); } else if ( isAsyncIterableIterator(value) // includes Readable, ReadableStream, etc. ) { for await (const chunk of value) { parts.push(chunk as BlobPart); // TODO, consider validating? } } else { throw new Error( `Unexpected data type: ${typeof value}; constructor: ${value?.constructor?.name}; props: ${ propsForError(value) }`, ); } return parts; } function propsForError(value: any): string { const props = Object.getOwnPropertyNames(value); return `[${props.map((p) => `"${p}"`).join(", ")}]`; } function getName(value: any): string | undefined { return ( getStringFromMaybeBuffer(value.name) || getStringFromMaybeBuffer(value.filename) || // For fs.ReadStream getStringFromMaybeBuffer(value.path)?.split(/[\\/]/).pop() ); } const getStringFromMaybeBuffer = ( x: string | Buffer | unknown, ): string | undefined => { if (typeof x === "string") return x; if (typeof Buffer !== "undefined" && x instanceof Buffer) return String(x); return undefined; }; const isAsyncIterableIterator = ( value: any, ): value is AsyncIterableIterator<unknown> => value != null && typeof value === "object" && typeof value[Symbol.asyncIterator] === "function"; export const isMultipartBody = (body: any): body is MultipartBody => body && typeof body === "object" && body.body && body[Symbol.toStringTag] === "MultipartBody"; /** * Returns a multipart/form-data request if any part of the given request body contains a File / Blob value. * Otherwise returns the request as is. */ export const maybeMultipartFormRequestOptions = async < T = Record<string, unknown>, >( opts: RequestOptions<T>, ): Promise<RequestOptions<T | MultipartBody>> => { if (!hasUploadableValue(opts.body)) return opts; const form = await createForm(opts.body); return getMultipartRequestOptions(form, opts); }; export const multipartFormRequestOptions = async <T = Record<string, unknown>>( opts: RequestOptions<T>, ): Promise<RequestOptions<T | MultipartBody>> => { const form = await createForm(opts.body); return getMultipartRequestOptions(form, opts); }; export const createForm = async <T = Record<string, unknown>>( body: T | undefined, ): Promise<FormData> => { const form = new FormData(); await Promise.all( Object.entries(body || {}).map(([key, value]) => addFormValue(form, key, value) ), ); return form; }; const hasUploadableValue = (value: unknown): boolean => { if (isUploadable(value)) return true; if (Array.isArray(value)) return value.some(hasUploadableValue); if (value && typeof value === "object") { for (const k in value) { if (hasUploadableValue((value as any)[k])) return true; } } return false; }; const addFormValue = async ( form: FormData, key: string, value: unknown, ): Promise<void> => { if (value === undefined) return; if (value == null) { throw new TypeError( `Received null for "${key}"; to pass null in FormData, you must use the string 'null'`, ); } // TODO: make nested formats configurable if ( typeof value === "string" || typeof value === "number" || typeof value === "boolean" ) { form.append(key, String(value)); } else if (isUploadable(value)) { const file = await toFile(value); form.append(key, file as File); } else if (Array.isArray(value)) { await Promise.all( value.map((entry) => addFormValue(form, key + "[]", entry)), ); } else if (typeof value === "object") { await Promise.all( Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop) ), ); } else { throw new TypeError( `Invalid value given to form, expected a string, number, boolean, object, Array, File or Blob but got ${value} instead`, ); } }; declare let Buffer: any; type Buffer = any;