UNPKG

cloudworker-proxy

Version:
594 lines (528 loc) 14.2 kB
const {Readable} = require("stream") const {inspect} = require("util") const {basename} = require("path") const mimes = require("mime-types") const getStreamIterator = require("./util/getStreamIterator.js") const getLength = require("./util/getLength.js") const isObject = require("./util/isObject.js") const isStream = require("./util/isStream.js") const boundary = require("./util/boundary.js") const isBlob = require("./util/isBlob.js") const toFile = require("./util/toFile.js") const {isBuffer} = Buffer const {isArray} = Array const {freeze} = Object /** * @const DEFAULT_CONTENT_TYPE * * @type {"application/octet-stream"} */ const DEFAULT_CONTENT_TYPE = "application/octet-stream" /** * @const DASHES * * @type{"--"} */ const DASHES = "-".repeat(2) /** * @const CARRIAGE * * @type {"\r\n"} */ const CARRIAGE = "\r\n" /** * @typedef {Object} FieldOptions * * @property {number} size */ /** * FormData implementation for Node.js. * * @api public */ class FormData { /** * Refers to the internal Readable stream * * @type {Readable} * * @public * @property * @readonly */ get stream() { return Readable.from(this.__read()) } /** * Returns a string representation of the FormData * * @type {string} */ get [Symbol.toStringTag]() { return "FormData" } /** * @param {Array<{name: string, value: any, filename?: string, options?: FieldOptions}>} [fields] – an optional FormData initial fields. * Each field must be passed as a collection of the objects * with "name", "value" and "filename" props. * See the FormData#append() method for more information. */ constructor(fields = null) { /** * Generates a new boundary string once FormData instance constructed * * @type {string} * * @public * @property * @readonly */ this.boundary = `NodeJSFormDataStreamBoundary${boundary()}` /** * Returns headers for multipart/form-data * * @type {{"Content-Type": string}} * * @public * @property * @readonly */ this.headers = freeze({ "Content-Type": `multipart/form-data; boundary=${this.boundary}` }) /** * @type {Map<string, {append: boolean, values: Array<{value: any, filename: string}>}>} * * @private * @readonly * @property */ this.__content = new Map() /** * @type {string} * * @private * @readonly * @property */ this.__footer = `${DASHES}${this.boundary}${DASHES}` + `${CARRIAGE.repeat(2)}` /** * Alias of the FormData#[util.inspect.custom]() * * @return {string} * * @public * @method */ this.inspect = this[inspect.custom] if (isArray(fields)) { this.__appendFromInitialFields(fields) } } /** * Returns a mime type by field's filename * * @param {string} filename * * @return {string} * * @private * @method */ __getMime(filename) { return mimes.lookup(filename) || DEFAULT_CONTENT_TYPE } /** * Returns a headers for given field's data * * @param {string} name * @param {string} filename * * @return {string} * * @private * @method */ __getHeader(name, filename) { let header = "" header += `${DASHES}${this.boundary}${CARRIAGE}` header += `Content-Disposition: form-data; name="${name}"` if (filename) { header += `; filename="${filename}"${CARRIAGE}` header += `Content-Type: ${this.__getMime(filename)}` } return `${header}${CARRIAGE.repeat(2)}` } /** * Get each field from internal Map * * @yields {Buffer | string} * * @private * @method * @async * @generator */ async* __getField() { for (const [name, {values}] of this.__content) { for (const {value, filename} of values) { // Set field's header yield this.__getHeader(name, filename) if (isBlob(value)) { yield* getStreamIterator(value.stream()) } else if (isStream(value)) { // Read the stream content yield* getStreamIterator(value) } else { yield value } // Add trailing carriage yield CARRIAGE } } // Add a footer when all fields ended yield this.__footer } /** * Read values from internal storage and push it to the internal stream * * @return {void} * * @private * @method */ async* __read() { for await (const ch of this.__getField()) { yield isBuffer(ch) ? ch : Buffer.from(String(ch)) } } /** * Append initial fields * * @param {Array<{name: string, value: any, filename?: string, options?: FieldOptions}>} fields * * @return {void} * * @private * @method */ __appendFromInitialFields(fields) { for (const field of fields) { if (isObject(field) && !isArray(field)) { this.append(field.name, field.value, field.filename, field.options) } } } /** * Appends a new value onto an existing key inside a FormData object, * or adds the key if it does not already exist. * * @param {string} name The name of the field whose data * is contained in value * * @param {any} value The field value. You can pass any primitive type * (including null and undefined), Buffer or Readable stream. * Note that Arrays and Object will be converted to string * by using String function. * * @param {string} [filename = undefined] A filename of given field. * Can be added only for Buffer and Readable * * @param {FieldOptions} [options = {}] An object with additional paramerets. * @param {boolean} append * @param {number} argsLength * * @return {void} * * @private */ __setField(name, value, filename, options, append, argsLength) { const fieldName = String(name) const methodName = append ? "append" : "set" if (isObject(filename)) { [options, filename] = [filename, undefined] } // FormData required at least 2 arguments to be set. if (argsLength < 2) { throw new TypeError( `Failed to execute '${methodName}' on 'FormData': ` + `2 arguments required, but only ${argsLength} present.` ) } // FormData requires the second argument to be some kind of binary data // when a filename has been set. if (filename && !(isBlob(value) || isStream(value) || isBuffer(value))) { throw new TypeError( `Failed to execute '${methodName}' on 'FormData': ` + "parameter 2 is not one of the following types: ", "ReadableStream | ReadStream | Readable | Buffer | File | Blob" ) } // Get a filename for Buffer, Blob, File, ReadableStream and Readable values if (isBuffer(value) && filename) { filename = basename(filename) } else if (isBlob(value)) { filename = basename( value.name || filename || (value.constructor.name === "Blob" ? "blob" : String(value.name)) ) } else if (isStream(value) && (value.path || filename)) { // Readable stream which created from fs.createReadStream // have a "path" property. So, we can get a "filename" // from the stream itself. filename = basename(value.path || filename) } // Normalize field content if (isStream(value)) { if (options.size != null) { value = toFile(value, filename || name || options.filename, options) } } else if (isBlob(value) || isBuffer(value)) { value = toFile(value, filename || name || options.filename, options) } else { value = String(value) } const field = this.__content.get(fieldName) // Set a new field if given name is not exists if (!field) { return void this.__content.set(fieldName, { append, values: [{value, filename}] }) } // Replace a value of the existing field if "set" called if (!append) { return void this.__content.set(fieldName, { append, values: [{value, filename}] }) } // Do nothing if the field has been created from .set() if (!field.append) { return undefined } // Append a new value to the existing field field.values.push({value, filename}) this.__content.set(fieldName, field) } /** * Returns computed length of the FormData content. * If data contains stream.Readable field(s), * the method will always return undefined. * * @return {Promise<number | undefined>} * * @public * @method */ async getComputedLength() { let length = 0 const carriageLength = Buffer.from(CARRIAGE).length for (const [name, {values}] of this.__content) { for (const {value, filename} of values) { length += Buffer.from(this.__getHeader(name, filename)).length const valueLength = await getLength(value) // Return `undefined` if can't tell field's length // (it's probably a stream with unknown length) if (valueLength == null) { return undefined } length += Number(valueLength) + carriageLength } } return length + Buffer.from(this.__footer).length } /** * Appends a new value onto an existing key inside a FormData object, * or adds the key if it does not already exist. * * @param {string} name The name of the field whose data * is contained in value * * @param {any} value The field value. You can pass any primitive type * (including null and undefined), Buffer or Readable stream. * Note that Arrays and Object will be converted to string * by using String function. * * @param {string} [filename = undefined] A filename of given field. * Can be added only for Buffer and Readable * * @param {FieldOptions} [options = {}] * * @return {void} * * @public * @method */ append(name, value, filename = undefined, options = {}) { return this.__setField( name, value, filename, options, true, arguments.length ) } /** * Set a new value for an existing key inside FormData, * or add the new field if it does not already exist. * * @param {string} name The name of the field whose data * is contained in value * * @param {any} value The field value. You can pass any primitive type * (including null and undefined), Buffer or Readable stream. * Note that Arrays and Object will be converted to string * by using String function. * * @param {string} [filename = undefined] A filename of given field. * Can be added only for Buffer and Readable * * @param {FieldOptions} [options = {}] * * @return {void} * * @public * @method */ set(name, value, filename = undefined, options = {}) { return this.__setField( name, value, filename, options, false, arguments.length ) } /** * Check if a field with the given name exists inside FormData. * * @param {string} name A name of the field you want to test for. * * @return {boolean} * * @public * @method */ has(name) { return this.__content.has(String(name)) } /** * Returns the first value associated with the given name. * Buffer and Readable values will be returned as-is. * * @param {string} name A name of the value you want to retrieve. * * @public */ get(name) { name = String(name) if (!this.has(name)) { return null } const field = this.__content.get(name) return field.values[0].value } /** * Returns all the values associated with * a given key from within a FormData object. * * @param {string} name – A name of the value you want to retrieve. * * @public */ getAll(name) { const field = this.__content.get(String(name)) return field ? field.values.map(({value}) => value) : [] } /** * Deletes a key and its value(s) from a FormData object. * * @param {string} name – The name of the key you want to delete. * * @public */ delete(name) { this.__content.delete(String(name)) } /** * @return {IterableIterator<string>} */ * keys() { for (const key of this.__content.keys()) { yield key } } /** * @return {IterableIterator<[string, any]>} */ * entries() { for (const name of this.keys()) { const values = this.getAll(name) // Yield each value of a field, like browser-side FormData does. for (const value of values) { yield [name, value] } } } /** * @return {IterableIterator<any>} */ * values() { for (const [, values] of this) { yield values } } /** * @return {IterableIterator<[string, any]>} */ [Symbol.iterator]() { return this.entries() } /** * Executes a given callback for each field of the FormData instance * * @param {(value: any, name: string, fd: FormData) => void} fn Function to execute for each element, * taking three arguments: * + {any} value – A value(s) of the current field. * + {string} – Name of the current field. * + {FormData} fd – The FormData instance that forEach * is being applied to * * @param {any} [ctx = null] * * @public */ forEach(fn, ctx = null) { for (const [name, value] of this) { fn.call(ctx, value, name, this) } } /** * This method allows to read a content from internal stream * using async generators and for-await-of APIs. * An alias of FormData#stream[Symbol.asyncIterator]() * * @return {AsyncIterableIterator<Buffer>} * * @public * @method */ [Symbol.asyncIterator]() { return this.stream[Symbol.asyncIterator]() } /** * Returns a string representation of the FormData * * @return {string} * * @public * @method */ toString() { return "[object FormData]" } /** * @type {string} * * @public * @property */ [inspect.custom]() { return "FormData" } } module.exports = FormData module.exports.default = FormData