255 lines
7.4 KiB
JavaScript
255 lines
7.4 KiB
JavaScript
/*! fetch-blob. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
|
|
|
// TODO (jimmywarting): in the feature use conditional loading with top level await (requires 14.x)
|
|
// Node has recently added whatwg stream into core
|
|
|
|
import './streams.cjs'
|
|
|
|
// 64 KiB (same size chrome slice theirs blob into Uint8array's)
|
|
const POOL_SIZE = 65536
|
|
|
|
/** @param {(Blob | Uint8Array)[]} parts */
|
|
async function * toIterator (parts, clone = true) {
|
|
for (const part of parts) {
|
|
if ('stream' in part) {
|
|
yield * (/** @type {AsyncIterableIterator<Uint8Array>} */ (part.stream()))
|
|
} else if (ArrayBuffer.isView(part)) {
|
|
if (clone) {
|
|
let position = part.byteOffset
|
|
const end = part.byteOffset + part.byteLength
|
|
while (position !== end) {
|
|
const size = Math.min(end - position, POOL_SIZE)
|
|
const chunk = part.buffer.slice(position, position + size)
|
|
position += chunk.byteLength
|
|
yield new Uint8Array(chunk)
|
|
}
|
|
} else {
|
|
yield part
|
|
}
|
|
/* c8 ignore next 10 */
|
|
} else {
|
|
// For blobs that have arrayBuffer but no stream method (nodes buffer.Blob)
|
|
let position = 0, b = (/** @type {Blob} */ (part))
|
|
while (position !== b.size) {
|
|
const chunk = b.slice(position, Math.min(b.size, position + POOL_SIZE))
|
|
const buffer = await chunk.arrayBuffer()
|
|
position += buffer.byteLength
|
|
yield new Uint8Array(buffer)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const _Blob = class Blob {
|
|
/** @type {Array.<(Blob|Uint8Array)>} */
|
|
#parts = []
|
|
#type = ''
|
|
#size = 0
|
|
#endings = 'transparent'
|
|
|
|
/**
|
|
* The Blob() constructor returns a new Blob object. The content
|
|
* of the blob consists of the concatenation of the values given
|
|
* in the parameter array.
|
|
*
|
|
* @param {*} blobParts
|
|
* @param {{ type?: string, endings?: string }} [options]
|
|
*/
|
|
constructor (blobParts = [], options = {}) {
|
|
if (typeof blobParts !== 'object' || blobParts === null) {
|
|
throw new TypeError('Failed to construct \'Blob\': The provided value cannot be converted to a sequence.')
|
|
}
|
|
|
|
if (typeof blobParts[Symbol.iterator] !== 'function') {
|
|
throw new TypeError('Failed to construct \'Blob\': The object must have a callable @@iterator property.')
|
|
}
|
|
|
|
if (typeof options !== 'object' && typeof options !== 'function') {
|
|
throw new TypeError('Failed to construct \'Blob\': parameter 2 cannot convert to dictionary.')
|
|
}
|
|
|
|
if (options === null) options = {}
|
|
|
|
const encoder = new TextEncoder()
|
|
for (const element of blobParts) {
|
|
let part
|
|
if (ArrayBuffer.isView(element)) {
|
|
part = new Uint8Array(element.buffer.slice(element.byteOffset, element.byteOffset + element.byteLength))
|
|
} else if (element instanceof ArrayBuffer) {
|
|
part = new Uint8Array(element.slice(0))
|
|
} else if (element instanceof Blob) {
|
|
part = element
|
|
} else {
|
|
part = encoder.encode(`${element}`)
|
|
}
|
|
|
|
const size = ArrayBuffer.isView(part) ? part.byteLength : part.size
|
|
// Avoid pushing empty parts into the array to better GC them
|
|
if (size) {
|
|
this.#size += size
|
|
this.#parts.push(part)
|
|
}
|
|
}
|
|
|
|
this.#endings = `${options.endings === undefined ? 'transparent' : options.endings}`
|
|
const type = options.type === undefined ? '' : String(options.type)
|
|
this.#type = /^[\x20-\x7E]*$/.test(type) ? type : ''
|
|
}
|
|
|
|
/**
|
|
* The Blob interface's size property returns the
|
|
* size of the Blob in bytes.
|
|
*/
|
|
get size () {
|
|
return this.#size
|
|
}
|
|
|
|
/**
|
|
* The type property of a Blob object returns the MIME type of the file.
|
|
*/
|
|
get type () {
|
|
return this.#type
|
|
}
|
|
|
|
/**
|
|
* The text() method in the Blob interface returns a Promise
|
|
* that resolves with a string containing the contents of
|
|
* the blob, interpreted as UTF-8.
|
|
*
|
|
* @return {Promise<string>}
|
|
*/
|
|
async text () {
|
|
// More optimized than using this.arrayBuffer()
|
|
// that requires twice as much ram
|
|
const decoder = new TextDecoder()
|
|
let str = ''
|
|
for await (const part of toIterator(this.#parts, false)) {
|
|
str += decoder.decode(part, { stream: true })
|
|
}
|
|
// Remaining
|
|
str += decoder.decode()
|
|
return str
|
|
}
|
|
|
|
/**
|
|
* The arrayBuffer() method in the Blob interface returns a
|
|
* Promise that resolves with the contents of the blob as
|
|
* binary data contained in an ArrayBuffer.
|
|
*
|
|
* @return {Promise<ArrayBuffer>}
|
|
*/
|
|
async arrayBuffer () {
|
|
// Easier way... Just a unnecessary overhead
|
|
// const view = new Uint8Array(this.size);
|
|
// await this.stream().getReader({mode: 'byob'}).read(view);
|
|
// return view.buffer;
|
|
|
|
const data = new Uint8Array(this.size)
|
|
let offset = 0
|
|
for await (const chunk of toIterator(this.#parts, false)) {
|
|
data.set(chunk, offset)
|
|
offset += chunk.length
|
|
}
|
|
|
|
return data.buffer
|
|
}
|
|
|
|
stream () {
|
|
const it = toIterator(this.#parts, true)
|
|
|
|
return new globalThis.ReadableStream({
|
|
// @ts-ignore
|
|
type: 'bytes',
|
|
async pull (ctrl) {
|
|
const chunk = await it.next()
|
|
chunk.done ? ctrl.close() : ctrl.enqueue(chunk.value)
|
|
},
|
|
|
|
async cancel () {
|
|
await it.return()
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The Blob interface's slice() method creates and returns a
|
|
* new Blob object which contains data from a subset of the
|
|
* blob on which it's called.
|
|
*
|
|
* @param {number} [start]
|
|
* @param {number} [end]
|
|
* @param {string} [type]
|
|
*/
|
|
slice (start = 0, end = this.size, type = '') {
|
|
const { size } = this
|
|
|
|
let relativeStart = start < 0 ? Math.max(size + start, 0) : Math.min(start, size)
|
|
let relativeEnd = end < 0 ? Math.max(size + end, 0) : Math.min(end, size)
|
|
|
|
const span = Math.max(relativeEnd - relativeStart, 0)
|
|
const parts = this.#parts
|
|
const blobParts = []
|
|
let added = 0
|
|
|
|
for (const part of parts) {
|
|
// don't add the overflow to new blobParts
|
|
if (added >= span) {
|
|
break
|
|
}
|
|
|
|
const size = ArrayBuffer.isView(part) ? part.byteLength : part.size
|
|
if (relativeStart && size <= relativeStart) {
|
|
// Skip the beginning and change the relative
|
|
// start & end position as we skip the unwanted parts
|
|
relativeStart -= size
|
|
relativeEnd -= size
|
|
} else {
|
|
let chunk
|
|
if (ArrayBuffer.isView(part)) {
|
|
chunk = part.subarray(relativeStart, Math.min(size, relativeEnd))
|
|
added += chunk.byteLength
|
|
} else {
|
|
chunk = part.slice(relativeStart, Math.min(size, relativeEnd))
|
|
added += chunk.size
|
|
}
|
|
relativeEnd -= size
|
|
blobParts.push(chunk)
|
|
relativeStart = 0 // All next sequential parts should start at 0
|
|
}
|
|
}
|
|
|
|
const blob = new Blob([], { type: String(type).toLowerCase() })
|
|
blob.#size = span
|
|
blob.#parts = blobParts
|
|
|
|
return blob
|
|
}
|
|
|
|
get [Symbol.toStringTag] () {
|
|
return 'Blob'
|
|
}
|
|
|
|
static [Symbol.hasInstance] (object) {
|
|
return (
|
|
object &&
|
|
typeof object === 'object' &&
|
|
typeof object.constructor === 'function' &&
|
|
(
|
|
typeof object.stream === 'function' ||
|
|
typeof object.arrayBuffer === 'function'
|
|
) &&
|
|
/^(Blob|File)$/.test(object[Symbol.toStringTag])
|
|
)
|
|
}
|
|
}
|
|
|
|
Object.defineProperties(_Blob.prototype, {
|
|
size: { enumerable: true },
|
|
type: { enumerable: true },
|
|
slice: { enumerable: true }
|
|
})
|
|
|
|
/** @type {typeof globalThis.Blob} */
|
|
export const Blob = _Blob
|
|
export default Blob
|