formdata-polyfill
Version:
HTML5 `FormData` polyfill for Browsers.
422 lines (365 loc) • 11.2 kB
JavaScript
/* global FormData self Blob File */
/* eslint-disable no-inner-declarations */
if (typeof Blob === 'function' && (typeof FormData === 'undefined' || !FormData.prototype.keys)) {
const global = typeof window === 'object'
? window
: typeof self === 'object' ? self : this
// keep a reference to native implementation
const _FormData = global.FormData
// To be monkey patched
const _send = global.XMLHttpRequest && global.XMLHttpRequest.prototype.send
const _fetch = global.Request && global.fetch
const _sendBeacon = global.navigator && global.navigator.sendBeacon
// Unable to patch Request constructor correctly
// const _Request = global.Request
// only way is to use ES6 class extend
// https://github.com/babel/babel/issues/1966
const stringTag = global.Symbol && Symbol.toStringTag
// Add missing stringTags to blob and files
if (stringTag) {
if (!Blob.prototype[stringTag]) {
Blob.prototype[stringTag] = 'Blob'
}
if ('File' in global && !File.prototype[stringTag]) {
File.prototype[stringTag] = 'File'
}
}
// Fix so you can construct your own File
try {
new File([], '') // eslint-disable-line
} catch (a) {
global.File = function File (b, d, c) {
const blob = new Blob(b, c)
const t = c && void 0 !== c.lastModified ? new Date(c.lastModified) : new Date()
Object.defineProperties(blob, {
name: {
value: d
},
lastModifiedDate: {
value: t
},
lastModified: {
value: +t
},
toString: {
value () {
return '[object File]'
}
}
})
if (stringTag) {
Object.defineProperty(blob, stringTag, {
value: 'File'
})
}
return blob
}
}
function normalizeValue ([value, filename]) {
if (value instanceof Blob) {
// Should always returns a new File instance
// console.assert(fd.get(x) !== fd.get(x))
value = new File([value], filename, {
type: value.type,
lastModified: value.lastModified
})
}
return value
}
function ensureArgs (args, expected) {
if (args.length < expected) {
throw new TypeError(`${expected} argument required, but only ${args.length} present.`)
}
}
function normalizeArgs (name, value, filename) {
return value instanceof Blob
// normalize name and filename if adding an attachment
? [String(name), value, filename !== undefined
? filename + '' // Cast filename to string if 3th arg isn't undefined
: typeof value.name === 'string' // if name prop exist
? value.name // Use File.name
: 'blob'] // otherwise fallback to Blob
// If no attachment, just cast the args to strings
: [String(name), String(value)]
}
// normalize linefeeds for textareas
// https://html.spec.whatwg.org/multipage/form-elements.html#textarea-line-break-normalisation-transformation
function normalizeLinefeeds (value) {
return value.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n')
}
function each (arr, cb) {
for (let i = 0; i < arr.length; i++) {
cb(arr[i])
}
}
/**
* @implements {Iterable}
*/
class FormDataPolyfill {
/**
* FormData class
*
* @param {HTMLElement=} form
*/
constructor (form) {
this._data = Object.create(null)
if (!form) return this
const self = this
each(form.elements, elm => {
if (!elm.name || elm.disabled || elm.type === 'submit' || elm.type === 'button') return
if (elm.type === 'file') {
const files = elm.files && elm.files.length
? elm.files
: [new File([], '', { type: 'application/octet-stream' })] // #78
each(files, file => {
self.append(elm.name, file)
})
} else if (elm.type === 'select-multiple' || elm.type === 'select-one') {
each(elm.options, opt => {
!opt.disabled && opt.selected && self.append(elm.name, opt.value)
})
} else if (elm.type === 'checkbox' || elm.type === 'radio') {
if (elm.checked) self.append(elm.name, elm.value)
} else {
const value = elm.type === 'textarea' ? normalizeLinefeeds(elm.value) : elm.value
self.append(elm.name, value)
}
})
}
/**
* Append a field
*
* @param {string} name field name
* @param {string|Blob|File} value string / blob / file
* @param {string=} filename filename to use with blob
* @return {undefined}
*/
append (name, value, filename) {
ensureArgs(arguments, 2)
;[name, value, filename] = normalizeArgs.apply(null, arguments)
const map = this._data
if (!map[name]) map[name] = []
map[name].push([value, filename])
}
/**
* Delete all fields values given name
*
* @param {string} name Field name
* @return {undefined}
*/
delete (name) {
ensureArgs(arguments, 1)
delete this._data[String(name)]
}
/**
* Iterate over all fields as [name, value]
*
* @return {Iterator}
*/
* entries () {
const map = this._data
for (let name in map) {
for (let value of map[name]) {
yield [name, normalizeValue(value)]
}
}
}
/**
* Iterate over all fields
*
* @param {Function} callback Executed for each item with parameters (value, name, thisArg)
* @param {Object=} thisArg `this` context for callback function
* @return {undefined}
*/
forEach (callback, thisArg) {
ensureArgs(arguments, 1)
for (let [name, value] of this) {
callback.call(thisArg, value, name, this)
}
}
/**
* Return first field value given name
* or null if non existen
*
* @param {string} name Field name
* @return {string|File|null} value Fields value
*/
get (name) {
ensureArgs(arguments, 1)
const map = this._data
name = String(name)
return map[name] ? normalizeValue(map[name][0]) : null
}
/**
* Return all fields values given name
*
* @param {string} name Fields name
* @return {Array} [{String|File}]
*/
getAll (name) {
ensureArgs(arguments, 1)
return (this._data[String(name)] || []).map(normalizeValue)
}
/**
* Check for field name existence
*
* @param {string} name Field name
* @return {boolean}
*/
has (name) {
ensureArgs(arguments, 1)
return String(name) in this._data
}
/**
* Iterate over all fields name
*
* @return {Iterator}
*/
* keys () {
for (let [name] of this) {
yield name
}
}
/**
* Overwrite all values given name
*
* @param {string} name Filed name
* @param {string} value Field value
* @param {string=} filename Filename (optional)
* @return {undefined}
*/
set (name, value, filename) {
ensureArgs(arguments, 2)
const args = normalizeArgs.apply(null, arguments)
this._data[args[0]] = [[args[1], args[2]]]
}
/**
* Iterate over all fields
*
* @return {Iterator}
*/
* values () {
for (let [, value] of this) {
yield value
}
}
/**
* Return a native (perhaps degraded) FormData with only a `append` method
* Can throw if it's not supported
*
* @return {FormData}
*/
['_asNative'] () {
const fd = new _FormData()
for (let [name, value] of this) {
fd.append(name, value)
}
return fd
}
/**
* [_blob description]
*
* @return {Blob} [description]
*/
['_blob'] () {
const boundary = '----formdata-polyfill-' + Math.random()
const chunks = []
for (let [name, value] of this) {
chunks.push(`--${boundary}\r\n`)
if (value instanceof Blob) {
chunks.push(
`Content-Disposition: form-data; name="${name}"; filename="${value.name}"\r\n`,
`Content-Type: ${value.type || 'application/octet-stream'}\r\n\r\n`,
value,
'\r\n'
)
} else {
chunks.push(
`Content-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`
)
}
}
chunks.push(`--${boundary}--`)
return new Blob(chunks, {
type: 'multipart/form-data; boundary=' + boundary
})
}
/**
* The class itself is iterable
* alias for formdata.entries()
*
* @return {Iterator}
*/
[Symbol.iterator] () {
return this.entries()
}
/**
* Create the default string description.
*
* @return {string} [object FormData]
*/
toString () {
return '[object FormData]'
}
}
if (stringTag) {
/**
* Create the default string description.
* It is accessed internally by the Object.prototype.toString().
*/
FormDataPolyfill.prototype[stringTag] = 'FormData'
}
// Patch xhr's send method to call _blob transparently
if (_send) {
const setRequestHeader = global.XMLHttpRequest.prototype.setRequestHeader
/**
* @param {string} name
* @param {string} value
* @returns {undefined}
* @see https://xhr.spec.whatwg.org/#dom-xmlhttprequest-setrequestheader
*/
global.XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
if (name.toLowerCase() === 'content-type') this._hasContentType = true
return setRequestHeader.call(this, name, value)
}
/**
* @param {ArrayBuffer|ArrayBufferView|Blob|Document|FormData|string=} data
* @return {undefined}
* @see https://xhr.spec.whatwg.org/#the-send()-method
*/
global.XMLHttpRequest.prototype.send = function (data) {
// I would check if Content-Type isn't already set
// But xhr lacks getRequestHeaders functionallity
// https://github.com/jimmywarting/FormData/issues/44
if (data instanceof FormDataPolyfill) {
const blob = data['_blob']()
// Check if Content-Type is already set
// https://github.com/jimmywarting/FormData/issues/86
if (!this._hasContentType) this.setRequestHeader('Content-Type', blob.type)
_send.call(this, blob)
} else {
_send.call(this, data)
}
}
}
// Patch fetch's function to call _blob transparently
if (_fetch) {
const _fetch = global.fetch
global.fetch = function (input, init) {
if (init && init.body && init.body instanceof FormDataPolyfill) {
init.body = init.body['_blob']()
}
return _fetch.call(this, input, init)
}
}
// Patch navigator.sendBeacon to use native FormData
if (_sendBeacon) {
global.navigator.sendBeacon = function (url, data) {
if (data instanceof FormDataPolyfill) {
data = data['_asNative']()
}
return _sendBeacon.call(this, url, data)
}
}
global['FormData'] = FormDataPolyfill
}