cloudflare/cloudflare-typescript

Public

mirrored from https://github.com/cloudflare/cloudflare-typescriptAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
bc468e5c848ce2706bc971cf2621ba36a7dbb34a

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

src/uploads.ts

255lines · modecode

1import { type RequestOptions } from './core';
2import {
3 FormData,
4 File,
5 type Blob,
6 type FilePropertyBag,
7 getMultipartRequestOptions,
8 type FsReadStream,
9 isFsReadStream,
10} from './_shims/index';
11import { MultipartBody } from './_shims/MultipartBody';
12export { fileFromPath } from './_shims/index';
13
14type BlobLikePart = string | ArrayBuffer | ArrayBufferView | BlobLike | Uint8Array | DataView;
15export 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 */
26export type Uploadable = FileLike | ResponseLike | FsReadStream;
27
28/**
29 * Intended to match web.Blob, node.Blob, node-fetch.Blob, etc.
30 */
31export 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 */
46export 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 */
56export interface ResponseLike {
57 url: string;
58 blob(): Promise<BlobLike>;
59}
60
61export 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
67export 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 */
78export 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
87export const isUploadable = (value: any): value is Uploadable => {
88 return isFileLike(value) || isResponseLike(value) || isFsReadStream(value);
89};
90
91export 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 */
102export 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 we've been given a `File` we don't need to do anything
111 if (isFileLike(value)) {
112 return value;
113 }
114
115 if (isResponseLike(value)) {
116 const blob = await value.blob();
117 name ||= new URL(value.url).pathname.split(/[\\/]/).pop() ?? 'unknown_file';
118
119 // we need to convert the `Blob` into an array buffer because the `Blob` class
120 // that `node-fetch` defines is incompatible with the web standard which results
121 // in `new File` interpreting it as a string instead of binary data.
122 const data = isBlobLike(blob) ? [(await blob.arrayBuffer()) as any] : [blob];
123
124 return new File(data, name, options);
125 }
126
127 const bits = await getBytes(value);
128
129 name ||= getName(value) ?? 'unknown_file';
130
131 if (!options?.type) {
132 const type = (bits[0] as any)?.type;
133 if (typeof type === 'string') {
134 options = { ...options, type };
135 }
136 }
137
138 return new File(bits, name, options);
139}
140
141async function getBytes(value: ToFileInput): Promise<Array<BlobPart>> {
142 let parts: Array<BlobPart> = [];
143 if (
144 typeof value === 'string' ||
145 ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc.
146 value instanceof ArrayBuffer
147 ) {
148 parts.push(value);
149 } else if (isBlobLike(value)) {
150 parts.push(await value.arrayBuffer());
151 } else if (
152 isAsyncIterableIterator(value) // includes Readable, ReadableStream, etc.
153 ) {
154 for await (const chunk of value) {
155 parts.push(chunk as BlobPart); // TODO, consider validating?
156 }
157 } else {
158 throw new Error(
159 `Unexpected data type: ${typeof value}; constructor: ${value?.constructor
160 ?.name}; props: ${propsForError(value)}`,
161 );
162 }
163
164 return parts;
165}
166
167function propsForError(value: any): string {
168 const props = Object.getOwnPropertyNames(value);
169 return `[${props.map((p) => `"${p}"`).join(', ')}]`;
170}
171
172function getName(value: any): string | undefined {
173 return (
174 getStringFromMaybeBuffer(value.name) ||
175 getStringFromMaybeBuffer(value.filename) ||
176 // For fs.ReadStream
177 getStringFromMaybeBuffer(value.path)?.split(/[\\/]/).pop()
178 );
179}
180
181const getStringFromMaybeBuffer = (x: string | Buffer | unknown): string | undefined => {
182 if (typeof x === 'string') return x;
183 if (typeof Buffer !== 'undefined' && x instanceof Buffer) return String(x);
184 return undefined;
185};
186
187const isAsyncIterableIterator = (value: any): value is AsyncIterableIterator<unknown> =>
188 value != null && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function';
189
190export const isMultipartBody = (body: any): body is MultipartBody =>
191 body && typeof body === 'object' && body.body && body[Symbol.toStringTag] === 'MultipartBody';
192
193/**
194 * Returns a multipart/form-data request if any part of the given request body contains a File / Blob value.
195 * Otherwise returns the request as is.
196 */
197export const maybeMultipartFormRequestOptions = async <T = Record<string, unknown>>(
198 opts: RequestOptions<T>,
199): Promise<RequestOptions<T | MultipartBody>> => {
200 if (!hasUploadableValue(opts.body)) return opts;
201
202 const form = await createForm(opts.body);
203 return getMultipartRequestOptions(form, opts);
204};
205
206export const multipartFormRequestOptions = async <T = Record<string, unknown>>(
207 opts: RequestOptions<T>,
208): Promise<RequestOptions<T | MultipartBody>> => {
209 const form = await createForm(opts.body);
210 return getMultipartRequestOptions(form, opts);
211};
212
213export const createForm = async <T = Record<string, unknown>>(body: T | undefined): Promise<FormData> => {
214 const form = new FormData();
215 await Promise.all(Object.entries(body || {}).map(([key, value]) => addFormValue(form, key, value)));
216 return form;
217};
218
219const hasUploadableValue = (value: unknown): boolean => {
220 if (isUploadable(value)) return true;
221 if (Array.isArray(value)) return value.some(hasUploadableValue);
222 if (value && typeof value === 'object') {
223 for (const k in value) {
224 if (hasUploadableValue((value as any)[k])) return true;
225 }
226 }
227 return false;
228};
229
230const addFormValue = async (form: FormData, key: string, value: unknown): Promise<void> => {
231 if (value === undefined) return;
232 if (value == null) {
233 throw new TypeError(
234 `Received null for "${key}"; to pass null in FormData, you must use the string 'null'`,
235 );
236 }
237
238 // TODO: make nested formats configurable
239 if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
240 form.append(key, String(value));
241 } else if (isUploadable(value)) {
242 const file = await toFile(value);
243 form.append(key, file as File);
244 } else if (Array.isArray(value)) {
245 await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry)));
246 } else if (typeof value === 'object') {
247 await Promise.all(
248 Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop)),
249 );
250 } else {
251 throw new TypeError(
252 `Invalid value given to form, expected a string, number, boolean, object, Array, File or Blob but got ${value} instead`,
253 );
254 }
255};