node/lib/internal/blob.js
Khafra 3d3c7eae0f
buffer: implement blob.textStream()
Signed-off-by: Matthew Aitken <maitken033380023@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/64036
Refs: cd1d1da9a5
Refs: 0d6ecc5710
Reviewed-By: René <contact.9a5d6388@renegade334.me.uk>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Luigi Pinca <luigipinca@gmail.com>
Reviewed-By: Mattias Buelens <mattias@buelens.com>
2026-06-22 23:40:44 +00:00

638 lines
16 KiB
JavaScript

'use strict';
const {
ArrayPrototypePush,
MathMax,
MathMin,
ObjectDefineProperties,
ObjectDefineProperty,
ObjectSetPrototypeOf,
PromisePrototypeThen,
PromiseReject,
PromiseWithResolvers,
RegExpPrototypeExec,
RegExpPrototypeSymbolReplace,
StringPrototypeSplit,
StringPrototypeToLowerCase,
Symbol,
SymbolToStringTag,
Uint8Array,
} = primordials;
const {
createBlob: _createBlob,
createBlobFromFilePath: _createBlobFromFilePath,
concat,
getDataObject,
} = internalBinding('blob');
const {
kMaxLength,
} = internalBinding('buffer');
const {
TextDecoder,
TextEncoder,
} = require('internal/encoding');
const { URL } = require('internal/url');
const {
markTransferMode,
kClone,
kDeserialize,
} = require('internal/worker/js_transferable');
const {
isAnyArrayBuffer,
isArrayBufferView,
} = require('internal/util/types');
const {
customInspectSymbol: kInspect,
kEmptyObject,
kEnumerableProperty,
lazyDOMException,
} = require('internal/util');
const { inspect } = require('internal/util/inspect');
const {
converters,
createSequenceConverter,
} = require('internal/webidl');
const {
codes: {
ERR_BUFFER_TOO_LARGE,
ERR_INVALID_ARG_VALUE,
ERR_INVALID_STATE,
ERR_INVALID_THIS,
},
} = require('internal/errors');
const {
validateDictionary,
} = require('internal/validators');
const {
setImmediate,
} = require('timers');
const { queueMicrotask } = require('internal/process/task_queues');
const kHandle = Symbol('kHandle');
const kType = Symbol('kType');
const kLength = Symbol('kLength');
const kNotCloneable = Symbol('kNotCloneable');
const disallowedTypeCharacters = /[^\u{0020}-\u{007E}]/u;
let ReadableStream;
let TextDecoderStream;
const enc = new TextEncoder();
let dec;
// Yes, lazy loading is annoying but because of circular
// references between the url, internal/blob, and buffer
// modules, lazy loading here makes sure that things work.
function lazyReadableStream(options) {
// eslint-disable-next-line no-global-assign
ReadableStream ??=
require('internal/webstreams/readablestream').ReadableStream;
return new ReadableStream(options);
}
function lazyTextDecoderStream() {
// eslint-disable-next-line no-global-assign
TextDecoderStream ??=
require('internal/webstreams/encoding').TextDecoderStream;
return new TextDecoderStream();
}
const { EOL } = require('internal/constants');
function isBlob(object) {
return object?.[kHandle] !== undefined;
}
function getSource(source, endings) {
if (isBlob(source))
return [source.size, source[kHandle]];
if (isAnyArrayBuffer(source)) {
source = new Uint8Array(source);
} else if (!isArrayBufferView(source)) {
if (endings === 'native')
source = RegExpPrototypeSymbolReplace(/\n|\r\n/g, source, EOL);
source = enc.encode(source);
}
// We copy into a new Uint8Array because the underlying
// BackingStores are going to be detached and owned by
// the Blob.
const { buffer, byteOffset, byteLength } = source;
const slice = buffer.slice(byteOffset, byteOffset + byteLength);
return [byteLength, new Uint8Array(slice)];
}
const sourcesConverter = createSequenceConverter((source, opts = kEmptyObject) => {
if (isBlob(source)) {
return source;
}
if (isAnyArrayBuffer(source) || isArrayBufferView(source)) {
return converters.BufferSource(source, opts);
}
return converters.DOMString(source, opts);
});
class Blob {
/**
* @typedef {string|ArrayBuffer|ArrayBufferView|Blob} SourcePart
*/
/**
* @param {SourcePart[]} [sources]
* @param {{
* endings? : string,
* type? : string,
* }} [options]
* @constructs Blob
*/
constructor(sources = [], options) {
markTransferMode(this, true, false);
const sources_ = sourcesConverter(sources, {
__proto__: null,
context: 'sources',
});
validateDictionary(options, 'options');
let {
endings = 'transparent',
type = '',
} = options ?? kEmptyObject;
endings = `${endings}`;
if (endings !== 'transparent' && endings !== 'native')
throw new ERR_INVALID_ARG_VALUE('options.endings', endings);
let length = 0;
for (let i = 0; i < sources_.length; ++i) {
const { 0: len, 1: src } = getSource(sources_[i], endings);
length += len;
sources_[i] = src;
}
if (length > kMaxLength)
throw new ERR_BUFFER_TOO_LARGE(kMaxLength);
this[kHandle] = _createBlob(sources_, length);
this[kLength] = length;
type = `${type}`;
this[kType] = RegExpPrototypeExec(disallowedTypeCharacters, type) !== null ?
'' : StringPrototypeToLowerCase(type);
}
[kInspect](depth, options) {
if (depth < 0)
return this;
const opts = {
...options,
depth: options.depth == null ? null : options.depth - 1,
};
return `Blob ${inspect({
size: this.size,
type: this.type,
}, opts)}`;
}
[kClone]() {
if (this[kNotCloneable]) {
// We do not currently allow file-backed Blobs to be cloned or passed across
// worker threads.
throw new ERR_INVALID_STATE.TypeError('File-backed Blobs are not cloneable');
}
const handle = this[kHandle];
const type = this[kType];
const length = this[kLength];
return {
data: { handle, type, length },
deserializeInfo: 'internal/blob:Blob',
};
}
[kDeserialize]({ handle, type, length }) {
this[kHandle] = handle;
this[kType] = type;
this[kLength] = length;
}
/**
* @readonly
* @type {string}
*/
get type() {
if (!isBlob(this))
throw new ERR_INVALID_THIS('Blob');
return this[kType];
}
/**
* @readonly
* @type {number}
*/
get size() {
if (!isBlob(this))
throw new ERR_INVALID_THIS('Blob');
return this[kLength];
}
/**
* @param {number} [start]
* @param {number} [end]
* @param {string} [contentType]
* @returns {Blob}
*/
slice(start = 0, end = this[kLength], contentType = '') {
if (!isBlob(this))
throw new ERR_INVALID_THIS('Blob');
start = converters['long long'](
start,
{ __proto__: null, context: 'start' },
);
end = converters['long long'](
end,
{ __proto__: null, context: 'end' },
);
if (start < 0) {
start = MathMax(this[kLength] + start, 0);
} else {
start = MathMin(start, this[kLength]);
}
if (end < 0) {
end = MathMax(this[kLength] + end, 0);
} else {
end = MathMin(end, this[kLength]);
}
contentType = `${contentType}`;
if (RegExpPrototypeExec(disallowedTypeCharacters, contentType) !== null) {
contentType = '';
} else {
contentType = StringPrototypeToLowerCase(contentType);
}
const span = MathMax(end - start, 0);
return createBlob(
this[kHandle].slice(start, start + span),
span,
contentType);
}
/**
* @returns {Promise<ArrayBuffer>}
*/
arrayBuffer() {
if (!isBlob(this))
return PromiseReject(new ERR_INVALID_THIS('Blob'));
return arrayBuffer(this);
}
/**
* @returns {Promise<string>}
*/
text() {
if (!isBlob(this))
return PromiseReject(new ERR_INVALID_THIS('Blob'));
dec ??= new TextDecoder();
return PromisePrototypeThen(
arrayBuffer(this),
(buffer) => dec.decode(buffer));
}
/**
* @returns {Promise<Uint8Array>}
*/
bytes() {
if (!isBlob(this))
return PromiseReject(new ERR_INVALID_THIS('Blob'));
return PromisePrototypeThen(
arrayBuffer(this),
(buffer) => new Uint8Array(buffer));
}
/**
* @returns {ReadableStream}
*/
stream() {
if (!isBlob(this))
throw new ERR_INVALID_THIS('Blob');
return createBlobReaderStream(this[kHandle].getReader());
}
/**
* @returns {ReadableStream}
*/
textStream() {
if (!isBlob(this))
throw new ERR_INVALID_THIS('Blob');
const stream = createBlobReaderStream(this[kHandle].getReader());
const decoder = lazyTextDecoderStream();
return stream.pipeThrough(decoder);
}
}
function TransferableBlob(handle, length, type = '') {
ObjectSetPrototypeOf(this, Blob.prototype);
markTransferMode(this, true, false);
this[kHandle] = handle;
this[kType] = type;
this[kLength] = length;
}
ObjectSetPrototypeOf(TransferableBlob.prototype, Blob.prototype);
ObjectSetPrototypeOf(TransferableBlob, Blob);
function createBlob(handle, length, type = '') {
const transferredBlob = new TransferableBlob(handle, length, type);
// Fix issues like: https://github.com/nodejs/node/pull/49730#discussion_r1331720053
transferredBlob.constructor = Blob;
return transferredBlob;
}
ObjectDefineProperty(Blob.prototype, SymbolToStringTag, {
__proto__: null,
configurable: true,
value: 'Blob',
});
ObjectDefineProperties(Blob.prototype, {
size: kEnumerableProperty,
type: kEnumerableProperty,
slice: kEnumerableProperty,
stream: kEnumerableProperty,
textStream: kEnumerableProperty,
text: kEnumerableProperty,
arrayBuffer: kEnumerableProperty,
bytes: kEnumerableProperty,
});
function resolveObjectURL(url) {
url = `${url}`;
try {
const parsed = new URL(url);
const split = StringPrototypeSplit(parsed.pathname, ':', 3);
if (split.length !== 2)
return;
const {
0: base,
1: id,
} = split;
if (base !== 'nodedata')
return;
const ret = getDataObject(id);
if (ret === undefined)
return;
const {
0: handle,
1: length,
2: type,
} = ret;
if (handle !== undefined)
return createBlob(handle, length, type);
} catch {
// If there's an error, it's ignored and nothing is returned
}
}
// TODO(@jasnell): Now that the File class exists, we might consider having
// this return a `File` instead of a `Blob`.
function createBlobFromFilePath(path, options) {
const maybeBlob = _createBlobFromFilePath(path);
if (maybeBlob === undefined) {
return lazyDOMException('The blob could not be read', 'NotReadableError');
}
const { 0: blob, 1: length } = maybeBlob;
const res = createBlob(blob, length, options?.type);
res[kNotCloneable] = true;
return res;
}
function arrayBuffer(blob) {
const { promise, resolve, reject } = PromiseWithResolvers();
const reader = blob[kHandle].getReader();
const buffers = [];
const readNext = () => {
reader.pull((status, buffer) => {
if (status === 0) {
// EOS, concat & resolve
// buffer should be undefined here
resolve(concat(buffers));
return;
} else if (status < 0) {
// The read could fail for many different reasons when reading
// from a non-memory resident blob part (e.g. file-backed blob).
// The error details the system error code.
const error = lazyDOMException('The blob could not be read', 'NotReadableError');
reject(error);
return;
}
if (buffer !== undefined)
buffers.push(buffer);
queueMicrotask(() => readNext());
});
};
readNext();
return promise;
}
function createBlobReaderStream(reader) {
return new lazyReadableStream({
type: 'bytes',
start(c) {
// There really should only be one read at a time so using an
// array here is purely defensive.
this.pendingPulls = [];
// Register a wakeup callback that the C++ side can invoke
// when new data is available after a STATUS_BLOCK.
reader.setWakeup(() => {
if (this.pendingPulls.length > 0) {
this.readNext(c);
}
});
},
pull(c) {
const { promise, resolve, reject } = PromiseWithResolvers();
this.pendingPulls.push({ resolve, reject });
this.readNext(c);
return promise;
},
readNext(c) {
reader.pull((status, buffer) => {
// If pendingPulls is empty here, the stream had to have
// been canceled, and we don't really care about the result.
// We can simply exit.
if (this.pendingPulls.length === 0) {
return;
}
if (status === 0) {
// EOS
c.close();
// This is to signal the end for byob readers
// see https://streams.spec.whatwg.org/#example-rbs-pull
c.byobRequest?.respond(0);
const pending = this.pendingPulls.shift();
pending.resolve();
return;
} else if (status < 0) {
// The read could fail for many different reasons when reading
// from a non-memory resident blob part (e.g. file-backed blob).
// The error details the system error code.
const error =
lazyDOMException('The blob could not be read',
'NotReadableError');
const pending = this.pendingPulls.shift();
c.error(error);
pending.reject(error);
return;
} else if (status === 2) {
// STATUS_BLOCK: No data available yet. The wakeup callback
// registered in start() will re-invoke readNext when data
// arrives.
return;
}
// ReadableByteStreamController.enqueue errors if we submit a
// 0-length buffer. We need to check for that here.
if (buffer !== undefined && buffer.byteLength !== 0) {
c.enqueue(new Uint8Array(buffer));
}
// We keep reading until we either reach EOS, some error, or
// we hit the flow rate of the stream (c.desiredSize).
// We use setImmediate here because we have to allow the event
// loop to turn in order to process any pending i/o. Using
// queueMicrotask won't allow the event loop to turn.
setImmediate(() => {
if (c.desiredSize < 0) {
// A manual backpressure check.
if (this.pendingPulls.length !== 0) {
const pending = this.pendingPulls.shift();
pending.resolve();
}
return;
}
this.readNext(c);
});
});
},
cancel(reason) {
// Reject any currently pending pulls here.
for (const pending of this.pendingPulls) {
pending.reject(reason);
}
this.pendingPulls = [];
},
// We set the highWaterMark to 0 because we do not want the stream to
// start reading immediately on creation. We want it to wait until read
// is called.
}, { highWaterMark: 0 });
}
// Maximum number of chunks to collect in a single batch to prevent
// unbounded memory growth when the DataQueue has a large burst of data.
const kMaxBatchChunks = 16;
async function* createBlobReaderIterable(reader, options = kEmptyObject) {
const { getReadError } = options;
let wakeup = PromiseWithResolvers();
reader.setWakeup(wakeup.resolve);
try {
while (true) {
const batch = [];
let blocked = false;
let eos = false;
let error = null;
// Pull as many chunks as available synchronously.
// reader.pull(callback) calls the callback synchronously via
// MakeCallback, so we can collect multiple chunks per iteration
// step without any async overhead.
while (true) {
let pullResult;
reader.pull((status, buffer) => {
pullResult = { status, buffer };
});
if (pullResult.status === 0) {
eos = true;
break;
}
if (pullResult.status < 0) {
error = typeof getReadError === 'function' ?
getReadError(pullResult.status) :
new ERR_INVALID_STATE('The reader is not readable');
break;
}
if (pullResult.status === 2) {
blocked = true;
break;
}
ArrayPrototypePush(batch, new Uint8Array(pullResult.buffer));
if (batch.length >= kMaxBatchChunks) break;
}
if (batch.length > 0) {
yield batch;
}
if (eos) return;
if (error) throw error;
if (blocked) {
const fin = await wakeup.promise;
wakeup = PromiseWithResolvers();
reader.setWakeup(wakeup.resolve);
// If the wakeup was triggered by FIN (EndReadable), the DataQueue
// is capped. Continue the loop to pull again -- the next pull will
// return EOS. Without this, a race between the data notification
// and the FIN notification can leave the iterator waiting for a
// wakeup that will never come.
if (fin) continue;
}
}
} finally {
reader.setWakeup(undefined);
}
}
module.exports = {
Blob,
createBlob,
createBlobFromFilePath,
createBlobReaderIterable,
createBlobReaderStream,
isBlob,
kHandle,
resolveObjectURL,
TransferableBlob,
};