diff --git a/src/codegen/generate-jssink.ts b/src/codegen/generate-jssink.ts index a46bf98c0a58..5555d32e7e59 100644 --- a/src/codegen/generate-jssink.ts +++ b/src/codegen/generate-jssink.ts @@ -169,7 +169,7 @@ function header() { static size_t memoryCost(void* sinkPtr); ${controller}(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr, uintptr_t onDestroy) - : Base(vm, structure, sinkPtr, onDestroy) + : Base(vm, structure, sinkPtr, SinkID::${name}, onDestroy) { } @@ -208,19 +208,24 @@ class JSReadableSinkControllerBase : public JSC::JSDestructibleObject { public: using Base = JSC::JSDestructibleObject; + DECLARE_INFO; + void* wrapped() const { return m_sinkPtr; } + SinkID sinkId() const { return m_sinkId; } void* m_sinkPtr; + SinkID m_sinkId; mutable WriteBarrier m_onPull; mutable WriteBarrier m_onClose; mutable JSC::Weak m_weakReadableStream; uintptr_t m_onDestroy { 0 }; protected: - JSReadableSinkControllerBase(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr, uintptr_t onDestroy) + JSReadableSinkControllerBase(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr, SinkID sinkId, uintptr_t onDestroy) : Base(vm, structure) { m_sinkPtr = sinkPtr; + m_sinkId = sinkId; m_onDestroy = onDestroy; } }; @@ -301,6 +306,8 @@ using namespace JSC; ${classes.map(name => `extern "C" size_t ${name}__memoryCost(void* sinkPtr);`).join("\n")} ${classes.map(name => `extern "C" void ${name}__controllerDetached(void* sinkPtr, JSC::EncodedJSValue controllerValue);`).join("\n")} + +const ClassInfo JSReadableSinkControllerBase::s_info = { "ReadableSinkController"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSReadableSinkControllerBase) }; `; var templ = head; diff --git a/src/event_loop/ConcurrentTask.rs b/src/event_loop/ConcurrentTask.rs index e5ebdce23c6f..e15d4a72aad8 100644 --- a/src/event_loop/ConcurrentTask.rs +++ b/src/event_loop/ConcurrentTask.rs @@ -115,6 +115,7 @@ pub mod task_tag { NativeBrotli, NativeZlib, NativeZstd, + CompressionStreamCoderTask, Open, PasswordHashResult, PasswordVerifyResult, diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 40d69928146c..2ef61951e30f 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -410,8 +410,6 @@ declare function $streamErrored(): TODO; declare function $streamReadable(): TODO; declare function $streamWritable(): TODO; declare function $syscall(): TODO; -declare function $textDecoderStreamDecoder(): TODO; -declare function $textEncoderStreamEncoder(): TODO; declare function $toNamespacedPath(): TODO; declare function $url(): TODO; declare function $view(): TODO; @@ -627,7 +625,6 @@ declare function $ERR_STREAM_CANNOT_PIPE(): Error; declare function $ERR_STREAM_WRITE_AFTER_END(): Error; declare function $ERR_STREAM_UNSHIFT_AFTER_END_EVENT(): Error; declare function $ERR_STREAM_PUSH_AFTER_EOF(): Error; -declare function $ERR_TRAILING_JUNK_AFTER_STREAM_END(): TypeError; declare function $ERR_STREAM_UNABLE_TO_PIPE(): Error; declare function $ERR_ILLEGAL_CONSTRUCTOR(): TypeError; declare function $ERR_SERVER_ALREADY_LISTEN(): Error; diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index 1e25949df538..c2efbaa08d25 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -33,7 +33,6 @@ using namespace JSC; macro(ReadableStreamDefaultController) \ macro(ReadableStreamDefaultReader) \ macro(SQL) \ - macro(TextEncoderStreamEncoder) \ macro(TransformStream) \ macro(TransformStreamDefaultController) \ macro(WritableStream) \ @@ -80,7 +79,6 @@ using namespace JSC; macro(disturbed) \ macro(domain) \ macro(drain) \ - macro(encode) \ macro(encoding) \ macro(end) \ macro(errno) \ diff --git a/src/js/builtins/CompressionStream.ts b/src/js/builtins/CompressionStream.ts deleted file mode 100644 index 5ca7520ddc4d..000000000000 --- a/src/js/builtins/CompressionStream.ts +++ /dev/null @@ -1,33 +0,0 @@ -export function initializeCompressionStream(this, format) { - const zlib = require("node:zlib"); - const { newBufferSourceTransformPairFromDuplex } = require("internal/webstreams_adapters"); - - const builders = { - "deflate": zlib.createDeflate, - "deflate-raw": zlib.createDeflateRaw, - "gzip": zlib.createGzip, - "brotli": zlib.createBrotliCompress, - "zstd": zlib.createZstdCompress, - }; - - if (!(format in builders)) - throw $ERR_INVALID_ARG_VALUE("format", format, "must be one of: " + Object.keys(builders).join(", ")); - - const transform = newBufferSourceTransformPairFromDuplex(builders[format]()); - $putByIdDirectPrivate(this, "readable", transform.readable); - $putByIdDirectPrivate(this, "writable", transform.writable); - - return this; -} - -$getter; -export function readable(this) { - if (!$inheritsCompressionStream(this)) throw $makeGetterTypeError("CompressionStream", "readable"); - return $getByIdDirectPrivate(this, "readable"); -} - -$getter; -export function writable(this) { - if (!$inheritsCompressionStream(this)) throw $makeGetterTypeError("CompressionStream", "writable"); - return $getByIdDirectPrivate(this, "writable"); -} diff --git a/src/js/builtins/DecompressionStream.ts b/src/js/builtins/DecompressionStream.ts deleted file mode 100644 index 50b20495c00c..000000000000 --- a/src/js/builtins/DecompressionStream.ts +++ /dev/null @@ -1,35 +0,0 @@ -export function initializeDecompressionStream(this, format) { - const zlib = require("node:zlib"); - const { newBufferSourceTransformPairFromDuplex } = require("internal/webstreams_adapters"); - - const builders = { - "deflate": zlib.createInflate, - "deflate-raw": zlib.createInflateRaw, - "gzip": zlib.createGunzip, - "brotli": zlib.createBrotliDecompress, - "zstd": zlib.createZstdDecompress, - }; - - if (!(format in builders)) - throw $ERR_INVALID_ARG_VALUE("format", format, "must be one of: " + Object.keys(builders).join(", ")); - - // The Compression Streams spec requires erroring on any bytes that follow the - // end of the compressed data. - const transform = newBufferSourceTransformPairFromDuplex(builders[format]({ rejectGarbageAfterEnd: true })); - $putByIdDirectPrivate(this, "readable", transform.readable); - $putByIdDirectPrivate(this, "writable", transform.writable); - - return this; -} - -$getter; -export function readable(this) { - if (!$inheritsDecompressionStream(this)) throw $makeGetterTypeError("DecompressionStream", "readable"); - return $getByIdDirectPrivate(this, "readable"); -} - -$getter; -export function writable(this) { - if (!$inheritsDecompressionStream(this)) throw $makeGetterTypeError("DecompressionStream", "writable"); - return $getByIdDirectPrivate(this, "writable"); -} diff --git a/src/js/internal/webstreams_adapters.ts b/src/js/internal/webstreams_adapters.ts index 97246f0b7b1b..4e3d2ade03b7 100644 --- a/src/js/internal/webstreams_adapters.ts +++ b/src/js/internal/webstreams_adapters.ts @@ -33,9 +33,6 @@ const SafePromisePrototypeFinally = $Promise.prototype.finally; const constants_zlib = $processBindingConstants.zlib; -const kValidateChunk = Symbol("kValidateChunk"); -const kDestroyOnSyncError = Symbol("kDestroyOnSyncError"); - function tryTransferToNativeReadable(stream, options) { const ptr = stream.$bunNativePtr; if (!ptr || ptr === -1) { @@ -191,7 +188,7 @@ function handleKnownInternalErrors(cause: Error | null): Error | null { const noop = () => {}; -function newWritableStreamFromStreamWritable(streamWritable, options = kEmptyObject) { +function newWritableStreamFromStreamWritable(streamWritable) { // Not using the internal/streams/utils isWritableNodeStream utility // here because it will return false if streamWritable is a Duplex // whose writable option is false. For a Duplex that is not writable, @@ -268,34 +265,20 @@ function newWritableStreamFromStreamWritable(streamWritable, options = kEmptyObj }, write(chunk) { - try { - options[kValidateChunk]?.(chunk); - if (!streamWritable.writableObjectMode && isAnyArrayBuffer(chunk)) { - chunk = new Uint8Array(chunk); - } - const needDrainBefore = streamWritable.writableNeedDrain; - if (needDrainBefore || !streamWritable.write(chunk)) { - backpressurePromise = PromiseWithResolvers(); - // write() may set writableNeedDrain; the post-write value is - // what decides whether we resolve immediately. - if (!streamWritable.writableNeedDrain) { - backpressurePromise.resolve(); - } - return SafePromisePrototypeFinally.$call(backpressurePromise.promise, () => { - backpressurePromise = undefined; - }); - } - } catch (error) { - // When the kDestroyOnSyncError flag is set (e.g. for - // CompressionStream), a sync throw must also destroy the - // stream so the readable side is errored too. Without this - // the readable side hangs forever. This replicates the - // TransformStream semantics: error both sides on any throw - // in the transform path. - if (options[kDestroyOnSyncError]) { - destroyer(streamWritable, error); + if (!streamWritable.writableObjectMode && isAnyArrayBuffer(chunk)) { + chunk = new Uint8Array(chunk); + } + const needDrainBefore = streamWritable.writableNeedDrain; + if (needDrainBefore || !streamWritable.write(chunk)) { + backpressurePromise = PromiseWithResolvers(); + // write() may set writableNeedDrain; the post-write value is + // what decides whether we resolve immediately. + if (!streamWritable.writableNeedDrain) { + backpressurePromise.resolve(); } - throw error; + return SafePromisePrototypeFinally.$call(backpressurePromise.promise, () => { + backpressurePromise = undefined; + }); } }, @@ -634,15 +617,7 @@ function newReadableWritablePairFromDuplex(duplex, options = kEmptyObject) { return { readable, writable }; } - const writableOptions = { - __proto__: null, - [kValidateChunk]: options[kValidateChunk], - [kDestroyOnSyncError]: options[kDestroyOnSyncError], - }; - - const writable = isWritable(duplex) - ? newWritableStreamFromStreamWritable(duplex, writableOptions) - : new WritableStream(); + const writable = isWritable(duplex) ? newWritableStreamFromStreamWritable(duplex) : new WritableStream(); if (!isWritable(duplex)) writable.close(); @@ -840,22 +815,6 @@ function newStreamDuplexFromReadableWritablePair(pair = kEmptyObject, options = return duplex; } -// Shared by CompressionStream and DecompressionStream: per the Compression -// Streams spec, chunks must be BufferSource (ArrayBuffer or ArrayBufferView -// not backed by SharedArrayBuffer), and an invalid chunk must error both -// sides of the pair synchronously. -function newBufferSourceTransformPairFromDuplex(duplex) { - const { isArrayBufferView, isSharedArrayBuffer } = require("node:util/types"); - return newReadableWritablePairFromDuplex(duplex, { - [kValidateChunk]: function validateBufferSourceChunk(chunk) { - if (isSharedArrayBuffer(isArrayBufferView(chunk) ? chunk.buffer : chunk)) { - throw $ERR_INVALID_ARG_TYPE("chunk", ["ArrayBuffer", "Buffer", "TypedArray", "DataView"], chunk); - } - }, - [kDestroyOnSyncError]: true, - }); -} - export default { newWritableStreamFromStreamWritable, newReadableStreamFromStreamReadable, @@ -863,8 +822,5 @@ export default { newStreamReadableFromReadableStream, newReadableWritablePairFromDuplex, newStreamDuplexFromReadableWritablePair, - newBufferSourceTransformPairFromDuplex, - kValidateChunk, - kDestroyOnSyncError, _ReadableFromWeb: ReadableFromWeb, }; diff --git a/src/js/node/zlib.ts b/src/js/node/zlib.ts index e4eaa66c6ec9..22059bbb0907 100644 --- a/src/js/node/zlib.ts +++ b/src/js/node/zlib.ts @@ -197,8 +197,6 @@ function ZlibBase(opts, mode, handle, { flush, finishFlush, fullFlush }) { this._defaultFullFlushFlag = fullFlush; this._info = opts && opts.info; this._maxOutputLength = maxOutputLength; - - this._rejectGarbageAfterEnd = opts?.rejectGarbageAfterEnd === true; } $toClass(ZlibBase, "ZlibBase", Transform); @@ -509,13 +507,6 @@ function processCallback() { // This applies to streams where we don't check data past the end of // what was consumed; that is, everything except Gunzip/Unzip. - if (self._rejectGarbageAfterEnd) { - const err = $ERR_TRAILING_JUNK_AFTER_STREAM_END(); - self.destroy(err); - this.cb(err); - return; - } - self.push(null); } diff --git a/src/jsc/bindings/JSCompressionStream.cpp b/src/jsc/bindings/JSCompressionStream.cpp deleted file mode 100644 index 14fdd8249549..000000000000 --- a/src/jsc/bindings/JSCompressionStream.cpp +++ /dev/null @@ -1,139 +0,0 @@ -#include "config.h" -#include "JSCompressionStream.h" - -#include "JSDOMBuiltinConstructor.h" -#include "JSDOMGlobalObjectInlines.h" -#include "WebCoreJSClientData.h" -#include "WebCoreJSBuiltins.h" -#include -#include - -namespace WebCore { - -using namespace JSC; - -static JSC_DECLARE_CUSTOM_GETTER(jsCompressionStreamConstructor); - -class JSCompressionStreamPrototype final : public JSC::JSNonFinalObject { -public: - using Base = JSC::JSNonFinalObject; - static JSCompressionStreamPrototype* create(JSC::VM& vm, JSDOMGlobalObject* globalObject, JSC::Structure* structure) - { - JSCompressionStreamPrototype* ptr = new (NotNull, JSC::allocateCell(vm)) JSCompressionStreamPrototype(vm, globalObject, structure); - ptr->finishCreation(vm); - return ptr; - } - - DECLARE_INFO; - template - static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) - { - STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSCompressionStreamPrototype, Base); - return &vm.plainObjectSpace(); - } - - static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) - { - return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); - } - -private: - JSCompressionStreamPrototype(JSC::VM& vm, JSC::JSGlobalObject*, JSC::Structure* structure) - : JSC::JSNonFinalObject(vm, structure) - { - } - - void finishCreation(JSC::VM&); -}; -STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSCompressionStreamPrototype, JSCompressionStreamPrototype::Base); - -using JSCompressionStreamDOMConstructor = JSDOMBuiltinConstructor; - -template<> const ClassInfo JSCompressionStreamDOMConstructor::s_info = { "CompressionStream"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSCompressionStreamDOMConstructor) }; - -template<> JSValue JSCompressionStreamDOMConstructor::prototypeForStructure(JSC::VM& vm, const JSDOMGlobalObject& globalObject) -{ - UNUSED_PARAM(vm); - return globalObject.functionPrototype(); -} - -template<> void JSCompressionStreamDOMConstructor::initializeProperties(VM& vm, JSDOMGlobalObject& globalObject) -{ - putDirect(vm, vm.propertyNames->length, jsNumber(0), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); - JSString* nameString = jsNontrivialString(vm, "CompressionStream"_s); - m_originalName.set(vm, this, nameString); - putDirect(vm, vm.propertyNames->name, nameString, JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); - putDirect(vm, vm.propertyNames->prototype, JSCompressionStream::prototype(vm, globalObject), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete); -} - -template<> FunctionExecutable* JSCompressionStreamDOMConstructor::initializeExecutable(VM& vm) -{ - return compressionStreamInitializeCompressionStreamCodeGenerator(vm); -} - -static const HashTableValue JSCompressionStreamPrototypeTableValues[] = { - { "constructor"_s, static_cast(JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::GetterSetterType, jsCompressionStreamConstructor, 0 } }, - { "readable"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::Accessor | JSC::PropertyAttribute::Builtin), NoIntrinsic, { HashTableValue::BuiltinAccessorType, compressionStreamReadableCodeGenerator, 0 } }, - { "writable"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::Accessor | JSC::PropertyAttribute::Builtin), NoIntrinsic, { HashTableValue::BuiltinAccessorType, compressionStreamWritableCodeGenerator, 0 } }, -}; - -const ClassInfo JSCompressionStreamPrototype::s_info = { "CompressionStream"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSCompressionStreamPrototype) }; - -void JSCompressionStreamPrototype::finishCreation(VM& vm) -{ - Base::finishCreation(vm); - reifyStaticProperties(vm, JSCompressionStream::info(), JSCompressionStreamPrototypeTableValues, *this); - JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); -} - -const ClassInfo JSCompressionStream::s_info = { "CompressionStream"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSCompressionStream) }; - -JSC::GCClient::IsoSubspace* JSCompressionStream::subspaceForImpl(JSC::VM& vm) -{ - return WebCore::subspaceForImpl( - vm, - [](auto& spaces) { return spaces.m_clientSubspaceForCompressionStream.get(); }, - [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForCompressionStream = std::forward(space); }, - [](auto& spaces) { return spaces.m_subspaceForCompressionStream.get(); }, - [](auto& spaces, auto&& space) { spaces.m_subspaceForCompressionStream = std::forward(space); }); -} - -JSCompressionStream::JSCompressionStream(Structure* structure, JSDOMGlobalObject& globalObject) - : JSDOMObject(structure, globalObject) -{ -} - -void JSCompressionStream::finishCreation(VM& vm) -{ - Base::finishCreation(vm); - ASSERT(inherits(info())); -} - -JSObject* JSCompressionStream::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) -{ - auto* structure = JSCompressionStreamPrototype::createStructure(vm, &globalObject, globalObject.objectPrototype()); - structure->setMayBePrototype(true); - return JSCompressionStreamPrototype::create(vm, &globalObject, structure); -} - -JSObject* JSCompressionStream::prototype(VM& vm, JSDOMGlobalObject& globalObject) -{ - return getDOMPrototype(vm, globalObject); -} - -JSValue JSCompressionStream::getConstructor(VM& vm, const JSGlobalObject* globalObject) -{ - return getDOMConstructor(vm, *uncheckedDowncast(globalObject)); -} - -JSC_DEFINE_CUSTOM_GETTER(jsCompressionStreamConstructor, (JSGlobalObject * lexicalGlobalObject, EncodedJSValue thisValue, PropertyName)) -{ - auto& vm = JSC::getVM(lexicalGlobalObject); - auto throwScope = DECLARE_THROW_SCOPE(vm); - auto* prototype = dynamicDowncast(JSValue::decode(thisValue)); - if (!prototype) - return throwVMTypeError(lexicalGlobalObject, throwScope); - return JSValue::encode(JSCompressionStream::getConstructor(vm, lexicalGlobalObject)); -} - -} // namespace WebCore diff --git a/src/jsc/bindings/JSCompressionStream.h b/src/jsc/bindings/JSCompressionStream.h deleted file mode 100644 index 99833ab99d70..000000000000 --- a/src/jsc/bindings/JSCompressionStream.h +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once - -#include "JSDOMWrapper.h" - -namespace WebCore { - -class JSCompressionStream : public JSDOMObject { -public: - using Base = JSDOMObject; - static JSCompressionStream* create(JSC::Structure* structure, JSDOMGlobalObject* globalObject) - { - JSCompressionStream* ptr = new (NotNull, JSC::allocateCell(globalObject->vm())) JSCompressionStream(structure, *globalObject); - ptr->finishCreation(globalObject->vm()); - return ptr; - } - - static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); - static JSC::JSObject* prototype(JSC::VM&, JSDOMGlobalObject&); - static JSC::JSValue getConstructor(JSC::VM&, const JSC::JSGlobalObject*); - - DECLARE_INFO; - - static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) - { - return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); - } - - template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) - { - if constexpr (mode == JSC::SubspaceAccess::Concurrently) - return nullptr; - return subspaceForImpl(vm); - } - static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); - - JSCompressionStream(JSC::Structure*, JSDOMGlobalObject&); - void finishCreation(JSC::VM&); -}; - -} // namespace WebCore diff --git a/src/jsc/bindings/JSDecompressionStream.cpp b/src/jsc/bindings/JSDecompressionStream.cpp deleted file mode 100644 index ce0ef95f74ca..000000000000 --- a/src/jsc/bindings/JSDecompressionStream.cpp +++ /dev/null @@ -1,139 +0,0 @@ -#include "config.h" -#include "JSDecompressionStream.h" - -#include "JSDOMBuiltinConstructor.h" -#include "JSDOMGlobalObjectInlines.h" -#include "WebCoreJSClientData.h" -#include "WebCoreJSBuiltins.h" -#include -#include - -namespace WebCore { - -using namespace JSC; - -static JSC_DECLARE_CUSTOM_GETTER(jsDecompressionStreamConstructor); - -class JSDecompressionStreamPrototype final : public JSC::JSNonFinalObject { -public: - using Base = JSC::JSNonFinalObject; - static JSDecompressionStreamPrototype* create(JSC::VM& vm, JSDOMGlobalObject* globalObject, JSC::Structure* structure) - { - JSDecompressionStreamPrototype* ptr = new (NotNull, JSC::allocateCell(vm)) JSDecompressionStreamPrototype(vm, globalObject, structure); - ptr->finishCreation(vm); - return ptr; - } - - DECLARE_INFO; - template - static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) - { - STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSDecompressionStreamPrototype, Base); - return &vm.plainObjectSpace(); - } - - static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) - { - return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); - } - -private: - JSDecompressionStreamPrototype(JSC::VM& vm, JSC::JSGlobalObject*, JSC::Structure* structure) - : JSC::JSNonFinalObject(vm, structure) - { - } - - void finishCreation(JSC::VM&); -}; -STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSDecompressionStreamPrototype, JSDecompressionStreamPrototype::Base); - -using JSDecompressionStreamDOMConstructor = JSDOMBuiltinConstructor; - -template<> const ClassInfo JSDecompressionStreamDOMConstructor::s_info = { "DecompressionStream"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSDecompressionStreamDOMConstructor) }; - -template<> JSValue JSDecompressionStreamDOMConstructor::prototypeForStructure(JSC::VM& vm, const JSDOMGlobalObject& globalObject) -{ - UNUSED_PARAM(vm); - return globalObject.functionPrototype(); -} - -template<> void JSDecompressionStreamDOMConstructor::initializeProperties(VM& vm, JSDOMGlobalObject& globalObject) -{ - putDirect(vm, vm.propertyNames->length, jsNumber(0), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); - JSString* nameString = jsNontrivialString(vm, "DecompressionStream"_s); - m_originalName.set(vm, this, nameString); - putDirect(vm, vm.propertyNames->name, nameString, JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); - putDirect(vm, vm.propertyNames->prototype, JSDecompressionStream::prototype(vm, globalObject), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete); -} - -template<> FunctionExecutable* JSDecompressionStreamDOMConstructor::initializeExecutable(VM& vm) -{ - return decompressionStreamInitializeDecompressionStreamCodeGenerator(vm); -} - -static const HashTableValue JSDecompressionStreamPrototypeTableValues[] = { - { "constructor"_s, static_cast(JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::GetterSetterType, jsDecompressionStreamConstructor, 0 } }, - { "readable"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::Accessor | JSC::PropertyAttribute::Builtin), NoIntrinsic, { HashTableValue::BuiltinAccessorType, decompressionStreamReadableCodeGenerator, 0 } }, - { "writable"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::Accessor | JSC::PropertyAttribute::Builtin), NoIntrinsic, { HashTableValue::BuiltinAccessorType, decompressionStreamWritableCodeGenerator, 0 } }, -}; - -const ClassInfo JSDecompressionStreamPrototype::s_info = { "DecompressionStream"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSDecompressionStreamPrototype) }; - -void JSDecompressionStreamPrototype::finishCreation(VM& vm) -{ - Base::finishCreation(vm); - reifyStaticProperties(vm, JSDecompressionStream::info(), JSDecompressionStreamPrototypeTableValues, *this); - JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); -} - -const ClassInfo JSDecompressionStream::s_info = { "DecompressionStream"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSDecompressionStream) }; - -JSC::GCClient::IsoSubspace* JSDecompressionStream::subspaceForImpl(JSC::VM& vm) -{ - return WebCore::subspaceForImpl( - vm, - [](auto& spaces) { return spaces.m_clientSubspaceForDecompressionStream.get(); }, - [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForDecompressionStream = std::forward(space); }, - [](auto& spaces) { return spaces.m_subspaceForDecompressionStream.get(); }, - [](auto& spaces, auto&& space) { spaces.m_subspaceForDecompressionStream = std::forward(space); }); -} - -JSDecompressionStream::JSDecompressionStream(Structure* structure, JSDOMGlobalObject& globalObject) - : JSDOMObject(structure, globalObject) -{ -} - -void JSDecompressionStream::finishCreation(VM& vm) -{ - Base::finishCreation(vm); - ASSERT(inherits(info())); -} - -JSObject* JSDecompressionStream::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) -{ - auto* structure = JSDecompressionStreamPrototype::createStructure(vm, &globalObject, globalObject.objectPrototype()); - structure->setMayBePrototype(true); - return JSDecompressionStreamPrototype::create(vm, &globalObject, structure); -} - -JSObject* JSDecompressionStream::prototype(VM& vm, JSDOMGlobalObject& globalObject) -{ - return getDOMPrototype(vm, globalObject); -} - -JSValue JSDecompressionStream::getConstructor(VM& vm, const JSGlobalObject* globalObject) -{ - return getDOMConstructor(vm, *uncheckedDowncast(globalObject)); -} - -JSC_DEFINE_CUSTOM_GETTER(jsDecompressionStreamConstructor, (JSGlobalObject * lexicalGlobalObject, EncodedJSValue thisValue, PropertyName)) -{ - auto& vm = JSC::getVM(lexicalGlobalObject); - auto throwScope = DECLARE_THROW_SCOPE(vm); - auto* prototype = dynamicDowncast(JSValue::decode(thisValue)); - if (!prototype) - return throwVMTypeError(lexicalGlobalObject, throwScope); - return JSValue::encode(JSDecompressionStream::getConstructor(vm, lexicalGlobalObject)); -} - -} // namespace WebCore diff --git a/src/jsc/bindings/JSDecompressionStream.h b/src/jsc/bindings/JSDecompressionStream.h deleted file mode 100644 index b35119606ad5..000000000000 --- a/src/jsc/bindings/JSDecompressionStream.h +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once - -#include "JSDOMWrapper.h" - -namespace WebCore { - -class JSDecompressionStream : public JSDOMObject { -public: - using Base = JSDOMObject; - static JSDecompressionStream* create(JSC::Structure* structure, JSDOMGlobalObject* globalObject) - { - JSDecompressionStream* ptr = new (NotNull, JSC::allocateCell(globalObject->vm())) JSDecompressionStream(structure, *globalObject); - ptr->finishCreation(globalObject->vm()); - return ptr; - } - - static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); - static JSC::JSObject* prototype(JSC::VM&, JSDOMGlobalObject&); - static JSC::JSValue getConstructor(JSC::VM&, const JSC::JSGlobalObject*); - - DECLARE_INFO; - - static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) - { - return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); - } - - template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) - { - if constexpr (mode == JSC::SubspaceAccess::Concurrently) - return nullptr; - return subspaceForImpl(vm); - } - static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); - - JSDecompressionStream(JSC::Structure*, JSDOMGlobalObject&); - void finishCreation(JSC::VM&); -}; - -} // namespace WebCore diff --git a/src/jsc/bindings/JSFFICString.cpp b/src/jsc/bindings/JSFFICString.cpp index d0852d92aa37..e589f78d8023 100644 --- a/src/jsc/bindings/JSFFICString.cpp +++ b/src/jsc/bindings/JSFFICString.cpp @@ -3,6 +3,7 @@ #include "JSFFICString.h" #include "ZigGlobalObject.h" +#include #include #include diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index f78c5dd371d3..05bd4e2b16b7 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -84,8 +84,8 @@ #include "JSAbortAlgorithm.h" #include "JSAbortController.h" #include "JSAbortSignal.h" -#include "JSCompressionStream.h" -#include "JSDecompressionStream.h" +#include "streams/JSCompressionStream.h" +#include "streams/JSDecompressionStream.h" #include "JSBroadcastChannel.h" #include "JSBuffer.h" #include "JSBufferList.h" @@ -3127,7 +3127,6 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm) GlobalPropertyInfo(builtinNames.internalModuleRegistryPrivateName(), this->internalModuleRegistry(), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly), GlobalPropertyInfo(builtinNames.processBindingConstantsPrivateName(), this->processBindingConstants(), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly), GlobalPropertyInfo(builtinNames.requireMapPrivateName(), this->requireMap(), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly | 0), - GlobalPropertyInfo(builtinNames.TextEncoderStreamEncoderPrivateName(), JSTextEncoderStreamEncoderConstructor(), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly | 0), GlobalPropertyInfo(builtinNames.makeErrorWithCodePrivateName(), JSFunction::create(vm, this, 2, String(), jsFunctionMakeErrorWithCode, ImplementationVisibility::Public), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly), GlobalPropertyInfo(builtinNames.toClassPrivateName(), JSFunction::create(vm, this, 1, String(), jsFunctionToClass, ImplementationVisibility::Public), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly), GlobalPropertyInfo(builtinNames.inheritsPrivateName(), JSFunction::create(vm, this, 1, String(), jsFunctionInherits, ImplementationVisibility::Public), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly), diff --git a/src/jsc/bindings/js_classes.ts b/src/jsc/bindings/js_classes.ts index 04a78a221967..2087a9b062fb 100644 --- a/src/jsc/bindings/js_classes.ts +++ b/src/jsc/bindings/js_classes.ts @@ -8,6 +8,4 @@ export default [ ["WritableStream", "streams/JSWritableStream.h"], ["TransformStream", "streams/JSTransformStream.h"], ["ArrayBuffer"], - ["CompressionStream", "JSCompressionStream.h"], - ["DecompressionStream", "JSDecompressionStream.h"], ]; diff --git a/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h b/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h index 47f0ed50ec1b..08f84d970ec4 100644 --- a/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h +++ b/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h @@ -116,6 +116,8 @@ class DOMClientIsoSubspaces { std::unique_ptr m_clientSubspaceForCountQueuingStrategyConstructor; std::unique_ptr m_clientSubspaceForTextEncoderStreamConstructor; std::unique_ptr m_clientSubspaceForTextDecoderStreamConstructor; + std::unique_ptr m_clientSubspaceForCompressionStreamConstructor; + std::unique_ptr m_clientSubspaceForDecompressionStreamConstructor; std::unique_ptr m_clientSubspaceForStreamPipeToOperation; std::unique_ptr m_clientSubspaceForReadRequest; std::unique_ptr m_clientSubspaceForReadIntoRequest; diff --git a/src/jsc/bindings/webcore/DOMIsoSubspaces.h b/src/jsc/bindings/webcore/DOMIsoSubspaces.h index 87e8fcf3481d..703c758f3b2e 100644 --- a/src/jsc/bindings/webcore/DOMIsoSubspaces.h +++ b/src/jsc/bindings/webcore/DOMIsoSubspaces.h @@ -98,6 +98,8 @@ class DOMIsoSubspaces { std::unique_ptr m_subspaceForCountQueuingStrategyConstructor; std::unique_ptr m_subspaceForTextEncoderStreamConstructor; std::unique_ptr m_subspaceForTextDecoderStreamConstructor; + std::unique_ptr m_subspaceForCompressionStreamConstructor; + std::unique_ptr m_subspaceForDecompressionStreamConstructor; std::unique_ptr m_subspaceForStreamPipeToOperation; std::unique_ptr m_subspaceForReadRequest; std::unique_ptr m_subspaceForReadIntoRequest; diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index 81ef7dcde1b1..8789b49d91d5 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -14,8 +14,11 @@ #include "JSReadRequest.h" #include "JSReadStreamIntoSinkOperation.h" #include "JSReadableStream.h" +#include "JSReadableStreamDefaultController.h" #include "JSReadableStreamDefaultReader.h" #include "JSSink.h" +#include "JSTransformStream.h" +#include "JSTransformStreamDefaultController.h" #include "JSStreamsRuntime.h" #include "WebStreamsHeapAnalyzer.h" #include "WebStreamsInternals.h" @@ -196,6 +199,7 @@ void JSReadStreamIntoSinkOperation::visitChildrenImpl(JSCell* cell, Visitor& vis visitor.appendHidden(thisObject->m_sink); visitor.appendHidden(thisObject->m_result); visitor.appendHidden(thisObject->m_pendingBatch); + visitor.appendHidden(thisObject->m_nativeTransform); } DEFINE_VISIT_CHILDREN(JSReadStreamIntoSinkOperation); @@ -210,6 +214,7 @@ void JSReadStreamIntoSinkOperation::analyzeHeap(JSCell* cell, HeapAnalyzer& anal analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_sink, "sink"_s); analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_result, "result"_s); analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_pendingBatch, "pendingBatch"_s); + analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_nativeTransform, "nativeTransform"_s); } } // namespace WebCore @@ -955,6 +960,12 @@ static void rsisAbrupt(JSC::VM&, JSGlobalObject*, JSReadStreamIntoSinkOperation* static JSValue rsisSinkWrite(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op, JSValue chunk) { + if (auto* view = dynamicDowncast(chunk)) { + if (auto* ctrl = dynamicDowncast(op->m_sink.get())) { + if (void* sinkPtr = ctrl->wrapped()) + return JSValue::decode(Bun__NativeTransformSink__writeBytes(static_cast(ctrl->sinkId()), sinkPtr, globalObject, static_cast(view->vector()), view->byteLength())); + } + } MarkedArgumentBuffer args; args.append(chunk); ASSERT(!args.hasOverflowed()); @@ -993,6 +1004,22 @@ static void rsisRunCatching(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStr rsisAbrupt(vm, globalObject, op, thrown); } +// Detach the native byte transform from this sink. Called BEFORE sink end()/close() +// so a re-entrant transform write cannot see a freed m_sinkPtr. Idempotent. +static void rsisDetachNativeTransform(JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op) +{ + auto* ts = op->m_nativeTransform.get(); + if (!ts) + return; + ts->m_nativeSinkPtr = nullptr; + ts->m_nativeSinkCell.clear(); + if (auto* ready = ts->m_nativeSinkReadyPromise.get()) { + ts->m_nativeSinkReadyPromise.clear(); + resolvePromise(globalObject, ready, jsUndefined()); + } + op->m_nativeTransform.clear(); +} + // The pump's `finally`: release the reader (unless the throw path orphaned it) and detach. static void rsisFinally(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op) { @@ -1009,6 +1036,7 @@ static void rsisFinally(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamI reader->m_pipeOperation.clear(); op->m_reader.clear(); } + rsisDetachNativeTransform(globalObject, op); op->m_sink.clear(); op->m_pendingBatch.clear(); op->m_waitingOnSink = false; @@ -1030,6 +1058,7 @@ static void rsisFinish(JSGlobalObject* globalObject, JSReadStreamIntoSinkOperati auto scope = DECLARE_THROW_SCOPE(vm); op->m_didClose = true; auto* result = op->m_result.get(); + rsisDetachNativeTransform(globalObject, op); JSValue endResult = rsisSinkEnd(vm, globalObject, op); RETURN_IF_EXCEPTION(scope, ); rsisFinally(vm, globalObject, op); @@ -1047,6 +1076,7 @@ static void rsisAbrupt(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIn if (auto* stream = op->m_stream.get()) publicStreamCancelIgnoringResult(vm, globalObject, stream, error); JSValue rejectionValue = error; + rsisDetachNativeTransform(globalObject, op); if (op->m_sink && !op->m_didClose) { op->m_didClose = true; auto catchScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); @@ -1233,6 +1263,28 @@ static void rsisHandleChunk(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStr RELEASE_AND_RETURN(scope, rsisAfterBatch(globalObject, op)); } +// `stream` is the readable half of a byte-producing native JSTransformStream subclass +// (Compression/Decompression/TextEncoder) → that transform; otherwise null. +static JSTransformStream* nativeByteTransformBehind(JSReadableStream* stream) +{ + if (stream->m_controllerKind != ControllerKind::Default) + return nullptr; + auto* defCtrl = uncheckedDowncast(stream->m_controller.get()); + if (!defCtrl || defCtrl->m_algorithms.kind != SourceKind::Transform) + return nullptr; + auto* ts = dynamicDowncast(defCtrl->m_algorithms.algorithmContext.get()); + if (!ts || !ts->m_controller) + return nullptr; + switch (ts->m_controller->m_transformerKind) { + case TransformerKind::Compression: + case TransformerKind::Decompression: + case TransformerKind::TextEncoder: + return ts; + default: + return nullptr; + } +} + static void rsisBegin(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -1243,6 +1295,21 @@ static void rsisBegin(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamInt RETURN_IF_EXCEPTION(scope, ); op->m_reader.set(vm, op, reader); reader->m_pipeOperation.set(vm, reader, op); + // Byte-producing native transform + native JSSink: attach the sink to the transform so its + // transform arms write coder output straight to the sink (no JSUint8Array per chunk). The + // pump below still runs to drain any already-queued chunk, wait for done, and call end(). + // Attached only after the reader is acquired so a failed second attempt (stream already + // locked) cannot overwrite the first pump's attachment. + if (auto* ts = nativeByteTransformBehind(stream); ts && !ts->m_nativeSinkPtr) { + if (auto* sinkCtrl = dynamicDowncast(op->m_sink.get())) { + if (void* sinkPtr = sinkCtrl->wrapped()) { + ts->m_nativeSinkPtr = sinkPtr; + ts->m_nativeSinkId = static_cast(sinkCtrl->sinkId()); + ts->m_nativeSinkCell.set(vm, ts, sinkCtrl); + op->m_nativeTransform.set(vm, op, ts); + } + } + } JSValue many = readableStreamDefaultReaderReadMany(globalObject, reader); RETURN_IF_EXCEPTION(scope, ); if (auto* manyPromise = dynamicDowncast(many)) { @@ -1278,6 +1345,7 @@ JSPromise* readStreamIntoSink(JSGlobalObject* globalObject, JSReadableStream* st static void readStreamIntoSinkOnCloseImpl(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op, JSValue streamValue, JSValue reason) { auto scope = DECLARE_THROW_SCOPE(vm); + rsisDetachNativeTransform(globalObject, op); // The sink closed underneath the pump (which may stay suspended forever): end() FIRST, // before the fallible cancel below, so the controller cell always detaches from the // native sink instead of being collected attached (its destructor would over-release). @@ -1466,6 +1534,16 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_boundReadStreamIntoSinkOnReady, (JS auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); auto* op = uncheckedDowncast(callFrame->argument(0)); + // A native transform arm may be parked on m_nativeSinkReadyPromise without the + // pump itself being suspended (output bypasses the pump's read loop). Resolve it + // regardless of m_waitingOnSink so the transform's in-flight write can settle. + if (auto* ts = op->m_nativeTransform.get()) { + if (auto* ready = ts->m_nativeSinkReadyPromise.get()) { + ts->m_nativeSinkReadyPromise.clear(); + Bun::WebStreams::resolvePromise(globalObject, ready, jsUndefined()); + scope.assertNoException(); + } + } if (!op->m_waitingOnSink) return JSValue::encode(jsUndefined()); op->m_waitingOnSink = false; diff --git a/src/jsc/bindings/webcore/streams/JSCompressionStream.cpp b/src/jsc/bindings/webcore/streams/JSCompressionStream.cpp new file mode 100644 index 000000000000..8bf3172d36c2 --- /dev/null +++ b/src/jsc/bindings/webcore/streams/JSCompressionStream.cpp @@ -0,0 +1,264 @@ +#include "config.h" +#include "JSCompressionStream.h" + +#include "DOMClientIsoSubspaces.h" +#include "DOMIsoSubspaces.h" +#include "ErrorCode.h" +#include "JSCompressionStreamShared.h" +#include "JSDOMExceptionHandling.h" +#include "JSDOMGlobalObjectInlines.h" +#include "JSDOMWrapperCache.h" +#include "JSReadableStream.h" +#include "JSStreamsRuntime.h" +#include "JSTransformStream.h" +#include "JSTransformStreamDefaultController.h" +#include "JSWritableStream.h" +#include "WebCoreJSClientData.h" +#include "WebStreamsInspectCustom.h" +#include "WebStreamsInternals.h" +#include "ZigGlobalObject.h" +#include +#include +#include +#include +#include + +namespace WebCore { + +using namespace JSC; +using namespace Bun::WebStreams; + +// ─── JSCompressionStreamPrototype ─────────────────────────────────────────── + +static JSC_DECLARE_CUSTOM_GETTER(jsCompressionStreamPrototypeGetter_constructor); +static JSC_DECLARE_CUSTOM_GETTER(jsCompressionStreamPrototypeGetter_readable); +static JSC_DECLARE_CUSTOM_GETTER(jsCompressionStreamPrototypeGetter_writable); +static JSC_DECLARE_HOST_FUNCTION(jsCompressionStreamPrototype_inspectCustom); + +class JSCompressionStreamPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static JSCompressionStreamPrototype* create(JSC::VM& vm, JSDOMGlobalObject* globalObject, JSC::Structure* structure) + { + JSCompressionStreamPrototype* ptr = new (NotNull, JSC::allocateCell(vm)) JSCompressionStreamPrototype(vm, structure); + ptr->finishCreation(vm); + return ptr; + } + + DECLARE_INFO; + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSCompressionStreamPrototype, Base); + return &vm.plainObjectSpace(); + } + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSCompressionStreamPrototype(JSC::VM& vm, JSC::Structure* structure) + : JSC::JSNonFinalObject(vm, structure) + { + } + + void finishCreation(JSC::VM&); +}; +STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSCompressionStreamPrototype, JSCompressionStreamPrototype::Base); + +static const HashTableValue JSCompressionStreamPrototypeTableValues[] = { + { "constructor"_s, static_cast(JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::GetterSetterType, jsCompressionStreamPrototypeGetter_constructor, 0 } }, + { "readable"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsCompressionStreamPrototypeGetter_readable, 0 } }, + { "writable"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsCompressionStreamPrototypeGetter_writable, 0 } }, +}; + +const ClassInfo JSCompressionStreamPrototype::s_info = { "CompressionStream"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSCompressionStreamPrototype) }; + +JSC_DEFINE_HOST_FUNCTION(jsCompressionStreamPrototype_inspectCustom, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue thisValue = callFrame->thisValue(); + auto* thisObject = dynamicDowncast(thisValue); + if (!thisObject) [[unlikely]] + return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "CompressionStream"_s); + JSObject* data = constructEmptyObject(lexicalGlobalObject); + data->putDirect(vm, Identifier::fromString(vm, "readable"_s), thisObject->m_readable.get() ? JSValue(thisObject->m_readable.get()) : jsUndefined(), 0); + data->putDirect(vm, Identifier::fromString(vm, "writable"_s), thisObject->m_writable.get() ? JSValue(thisObject->m_writable.get()) : jsUndefined(), 0); + RELEASE_AND_RETURN(scope, Bun::WebStreams::customInspect(lexicalGlobalObject, callFrame, thisValue, "CompressionStream"_s, data)); +} + +void JSCompressionStreamPrototype::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSCompressionStream::info(), JSCompressionStreamPrototypeTableValues, *this); + Bun::WebStreams::installInspectCustom(vm, this, jsCompressionStreamPrototype_inspectCustom); + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +// JSCompressionStreamConstructor = JSStreamConstructor. + +template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSCompressionStreamConstructor::construct(JSGlobalObject*, CallFrame*); +template<> JSValue JSCompressionStreamConstructor::prototypeForStructure(JSC::VM&, const JSDOMGlobalObject&); +template<> void JSCompressionStreamConstructor::finishCreation(JSC::VM&, JSDOMGlobalObject&); +template<> GCClient::IsoSubspace* JSCompressionStreamConstructor::subspaceForImpl(JSC::VM&); +template<> void JSCompressionStreamConstructor::visitChildren(JSCell*, JSC::AbstractSlotVisitor&); +template<> void JSCompressionStreamConstructor::visitChildren(JSCell*, JSC::SlotVisitor&); +template<> +template +void JSCompressionStreamConstructor::visitChildrenImpl(JSCell*, Visitor&); + +template<> const ClassInfo JSCompressionStreamConstructor::s_info = { "CompressionStream"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSCompressionStreamConstructor) }; + +template<> JSValue JSCompressionStreamConstructor::prototypeForStructure(JSC::VM& vm, const JSDOMGlobalObject& globalObject) +{ + return globalObject.functionPrototype(); +} + +template<> +template +void JSCompressionStreamConstructor::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + auto* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_instanceStructure); +} +DEFINE_VISIT_CHILDREN_WITH_MODIFIER(template<>, JSCompressionStreamConstructor); + +template<> GCClient::IsoSubspace* JSCompressionStreamConstructor::subspaceForImpl(JSC::VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForCompressionStreamConstructor.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForCompressionStreamConstructor = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForCompressionStreamConstructor.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForCompressionStreamConstructor = std::forward(space); }); +} + +template<> void JSCompressionStreamConstructor::finishCreation(VM& vm, JSDOMGlobalObject& globalObject) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); + putDirect(vm, vm.propertyNames->length, jsNumber(1), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); + JSString* nameString = jsNontrivialString(vm, "CompressionStream"_s); + m_originalName.set(vm, this, nameString); + putDirect(vm, vm.propertyNames->name, nameString, JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); + putDirect(vm, vm.propertyNames->prototype, JSCompressionStream::prototype(vm, globalObject), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete); + m_instanceStructure.set(vm, this, getDOMStructure(vm, globalObject)); +} + +template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSCompressionStreamConstructor::construct(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* constructor = uncheckedDowncast(callFrame->jsCallee()); + + auto format = parseCompressionFormat(lexicalGlobalObject, callFrame->argument(0)); + RETURN_IF_EXCEPTION(scope, {}); + ASSERT(format.has_value()); + + void* coder = CompressionStreamCoder__create(static_cast(*format), false); + if (!coder) [[unlikely]] { + throwTypeError(lexicalGlobalObject, scope, "failed to initialize compressor"_s); + return {}; + } + + auto* structure = structureForNewTarget(vm, constructor, lexicalGlobalObject, asObject(callFrame->newTarget())); + if (scope.exception()) [[unlikely]] { + CompressionStreamCoder__destroy(coder); + return {}; + } + auto* stream = JSCompressionStream::create(vm, structure); + stream->m_coder = coder; + vm.heap.addFinalizer(stream, static_cast([](JSCell* cell) { + CompressionStreamCoder__destroy(std::exchange(static_cast(cell)->m_coder, nullptr)); + })); + + setUpNativeTransformStream(lexicalGlobalObject, stream, TransformerKind::Compression); + RETURN_IF_EXCEPTION(scope, {}); + + return JSValue::encode(stream); +} +JSC_ANNOTATE_HOST_FUNCTION(JSCompressionStreamConstructorConstruct, JSCompressionStreamConstructor::construct); + +// ─── JSCompressionStream ──────────────────────────────────────────────────── + +const ClassInfo JSCompressionStream::s_info = { "CompressionStream"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSCompressionStream) }; + +JSCompressionStream::JSCompressionStream(VM& vm, Structure* structure) + : Base(vm, structure) +{ +} + +JSCompressionStream* JSCompressionStream::create(VM& vm, Structure* structure) +{ + auto* stream = new (NotNull, allocateCell(vm)) JSCompressionStream(vm, structure); + stream->finishCreation(vm); + return stream; +} + +Structure* JSCompressionStream::createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) +{ + return Structure::create(vm, globalObject, prototype, TypeInfo(ObjectType, StructureFlags), info()); +} + +JSObject* JSCompressionStream::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + auto* structure = JSCompressionStreamPrototype::createStructure(vm, &globalObject, globalObject.objectPrototype()); + structure->setMayBePrototype(true); + return JSCompressionStreamPrototype::create(vm, &globalObject, structure); +} + +JSObject* JSCompressionStream::prototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + return getDOMPrototype(vm, globalObject); +} + +JSValue JSCompressionStream::getConstructor(VM& vm, const JSGlobalObject* globalObject) +{ + return getDOMConstructor(vm, *uncheckedDowncast(globalObject)); +} + +GCClient::IsoSubspace* JSCompressionStream::subspaceForImpl(VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForCompressionStream.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForCompressionStream = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForCompressionStream.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForCompressionStream = std::forward(space); }); +} + +JSC_DEFINE_CUSTOM_GETTER(jsCompressionStreamPrototypeGetter_constructor, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* prototype = dynamicDowncast(JSValue::decode(thisValue)); + if (!prototype) [[unlikely]] + return throwVMTypeError(lexicalGlobalObject, scope); + return JSValue::encode(JSCompressionStream::getConstructor(vm, prototype->globalObject())); +} + +JSC_DEFINE_CUSTOM_GETTER(jsCompressionStreamPrototypeGetter_readable, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* stream = dynamicDowncast(JSValue::decode(thisValue)); + if (!stream) [[unlikely]] + return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "CompressionStream"_s); + return JSValue::encode(stream->m_readable.get()); +} + +JSC_DEFINE_CUSTOM_GETTER(jsCompressionStreamPrototypeGetter_writable, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* stream = dynamicDowncast(JSValue::decode(thisValue)); + if (!stream) [[unlikely]] + return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "CompressionStream"_s); + return JSValue::encode(stream->m_writable.get()); +} + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/streams/JSCompressionStream.h b/src/jsc/bindings/webcore/streams/JSCompressionStream.h new file mode 100644 index 000000000000..7a2e98326249 --- /dev/null +++ b/src/jsc/bindings/webcore/streams/JSCompressionStream.h @@ -0,0 +1,49 @@ +// JSCompressionStream — the CompressionStream instance cell. A JSTransformStream +// subclass whose controller's transformerKind drives the Rust CompressionStreamCoder +// (m_coder) directly. +#pragma once + +#include "root.h" +#include "StreamsForward.h" + +#include "JSDOMGlobalObject.h" +#include "JSTransformStream.h" +#include "StreamConstructor.h" + +namespace WebCore { + +class JSCompressionStream final : public JSTransformStream { +public: + using Base = JSTransformStream; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSCompressionStream* create(JSC::VM&, JSC::Structure*); + + static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); + static JSC::JSObject* prototype(JSC::VM&, JSDOMGlobalObject&); + static JSC::JSValue getConstructor(JSC::VM&, const JSC::JSGlobalObject*); + static JSC::Structure* createStructure(JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue prototype); + + DECLARE_INFO; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM&); + + // the Rust CompressionStreamCoder. Freed eagerly at ClearAlgorithms (post-flush / + // error / cancel); a vm.heap.addFinalizer registered in the constructor is the + // idempotent fallback for an abandoned stream. + void* m_coder { nullptr }; + +private: + JSCompressionStream(JSC::VM&, JSC::Structure*); +}; + +using JSCompressionStreamConstructor = JSStreamConstructor; + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/streams/JSCompressionStreamShared.cpp b/src/jsc/bindings/webcore/streams/JSCompressionStreamShared.cpp new file mode 100644 index 000000000000..df541ba28769 --- /dev/null +++ b/src/jsc/bindings/webcore/streams/JSCompressionStreamShared.cpp @@ -0,0 +1,244 @@ +#include "config.h" +#include "JSCompressionStreamShared.h" + +#include "ErrorCode.h" +#include "JSCompressionStream.h" +#include "JSDecompressionStream.h" +#include "JSSink.h" +#include "JSStreamsRuntime.h" +#include "JSTransformStream.h" +#include "JSTransformStreamDefaultController.h" +#include "WebStreamsInternals.h" +#include "ZigGlobalObject.h" +#include +#include +#include +#include + +namespace Bun { +namespace WebStreams { + +using namespace JSC; +using WebCore::JSCompressionStream; +using WebCore::JSDecompressionStream; +using WebCore::JSTransformStream; +using WebCore::JSTransformStreamDefaultController; + +std::optional parseCompressionFormat(JSGlobalObject* globalObject, JSValue formatValue) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + WTF::String format = formatValue.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, std::nullopt); + if (format == "deflate"_s) + return CompressionFormat::Deflate; + if (format == "deflate-raw"_s) + return CompressionFormat::DeflateRaw; + if (format == "gzip"_s) + return CompressionFormat::Gzip; + if (format == "brotli"_s) + return CompressionFormat::Brotli; + if (format == "zstd"_s) + return CompressionFormat::Zstd; + Bun::ERR::INVALID_ARG_VALUE(scope, globalObject, "format"_s, formatValue, "must be one of: deflate, deflate-raw, gzip, brotli, zstd"_s); + return std::nullopt; +} + +// BufferSource → (ptr, len). `scratch` owns the bytes when `chunk` is a string +// (Node-compat: node:zlib-backed CompressionStream accepts string chunks). +static std::optional> bufferSourceBytes(JSGlobalObject* globalObject, JSValue chunk, WTF::CString& scratch) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + if (chunk.isNull()) { + Bun::throwError(globalObject, scope, Bun::ErrorCode::ERR_STREAM_NULL_VALUES, "May not write null values to stream"_s); + return std::nullopt; + } + if (auto* view = dynamicDowncast(chunk)) { + if (view->isDetached()) [[unlikely]] { + throwTypeError(globalObject, scope, "Cannot transform a detached buffer"_s); + return std::nullopt; + } + if (view->isShared()) [[unlikely]] { + Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "chunk"_s, "BufferSource"_s, chunk); + return std::nullopt; + } + return std::span(static_cast(view->vector()), view->byteLength()); + } + if (auto* buffer = dynamicDowncast(chunk)) { + auto* impl = buffer->impl(); + if (!impl || impl->isDetached()) [[unlikely]] { + throwTypeError(globalObject, scope, "Cannot transform a detached buffer"_s); + return std::nullopt; + } + if (impl->isShared()) [[unlikely]] { + Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "chunk"_s, "BufferSource"_s, chunk); + return std::nullopt; + } + return std::span(static_cast(impl->data()), impl->byteLength()); + } + if (chunk.isString()) { + WTF::String s = asString(chunk)->value(globalObject); + RETURN_IF_EXCEPTION(scope, std::nullopt); + scratch = s.utf8(); + return std::span(reinterpret_cast(scratch.data()), scratch.length()); + } + Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "chunk"_s, "BufferSource"_s, chunk); + return std::nullopt; +} + +static constexpr size_t kAsyncCodecThreshold = 128 * 1024; + +// Runs the Rust coder and delivers the output. When a native JSSink is attached +// (m_nativeSinkPtr), the coder writes straight to it (no JSUint8Array); otherwise +// the result is enqueued on the readable. Abrupt completions become a rejected +// promise — a transform algorithm must never throw synchronously into +// ProcessWrite/ProcessClose. +static JSPromise* codeAndEnqueue(JSGlobalObject* globalObject, JSTransformStream* stream, void* coder, JSTransformStreamDefaultController* controller, JSValue chunk, const uint8_t* input, size_t inputLen, bool finish) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (inputLen > kAsyncCodecThreshold && coder) { + auto* promise = JSPromise::create(vm, globalObject->promiseStructure()); + stream->m_asyncCodecPromise.set(vm, stream, promise); + stream->m_asyncCodecInFlight = true; + CompressionStreamCoder__transformAsync(coder, globalObject, JSValue::encode(stream), JSValue::encode(chunk), input, inputLen, finish); + scope.assertNoException(); + return promise; + } + + JSValue thrown; + bool sinkBackpressure = false; + { + auto catchScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + if (void* sinkPtr = stream->m_nativeSinkPtr) { + JSValue wrote = JSValue::decode(CompressionStreamCoder__transformInto(coder, globalObject, input, inputLen, finish, stream->m_nativeSinkId, sinkPtr)); + if (!catchScope.exception()) + sinkBackpressure = nativeSinkWriteIsBackpressure(vm, wrote); + } else { + JSValue out = JSValue::decode(CompressionStreamCoder__transform(coder, globalObject, input, inputLen, finish)); + if (!catchScope.exception()) { + auto* view = dynamicDowncast(out); + if (view && view->length()) + transformStreamDefaultControllerEnqueue(globalObject, controller, out); + } + } + if (catchScope.exception()) [[unlikely]] + thrown = takeAbruptCompletion(globalObject, catchScope); + } + RETURN_IF_EXCEPTION(scope, nullptr); + if (!thrown.isEmpty()) + RELEASE_AND_RETURN(scope, promiseRejectedWith(globalObject, thrown)); + if (sinkBackpressure) { + auto* ready = JSPromise::create(vm, globalObject->promiseStructure()); + stream->m_nativeSinkReadyPromise.set(vm, stream, ready); + return ready; + } + RELEASE_AND_RETURN(scope, promiseFulfilledWith(globalObject, JSC::jsUndefined())); +} + +template +static JSPromise* compressionStreamTransformImpl(JSGlobalObject* globalObject, JSStream* stream, JSTransformStreamDefaultController* controller, JSValue chunk) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue thrown; + WTF::CString scratch; + std::optional> bytes; + { + auto catchScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + bytes = bufferSourceBytes(globalObject, chunk, scratch); + if (catchScope.exception()) [[unlikely]] + thrown = takeAbruptCompletion(globalObject, catchScope); + } + RETURN_IF_EXCEPTION(scope, nullptr); + if (!thrown.isEmpty()) + RELEASE_AND_RETURN(scope, promiseRejectedWith(globalObject, thrown)); + if (!bytes) [[unlikely]] + return nullptr; + RELEASE_AND_RETURN(scope, codeAndEnqueue(globalObject, stream, stream->m_coder, controller, chunk, bytes->data(), bytes->size(), false)); +} + +JSPromise* compressionStreamTransform(JSGlobalObject* globalObject, JSCompressionStream* stream, JSTransformStreamDefaultController* controller, JSValue chunk) +{ + return compressionStreamTransformImpl(globalObject, stream, controller, chunk); +} + +JSPromise* compressionStreamFlush(JSGlobalObject* globalObject, JSCompressionStream* stream, JSTransformStreamDefaultController* controller) +{ + return codeAndEnqueue(globalObject, stream, stream->m_coder, controller, jsUndefined(), nullptr, 0, true); +} + +JSPromise* decompressionStreamTransform(JSGlobalObject* globalObject, JSDecompressionStream* stream, JSTransformStreamDefaultController* controller, JSValue chunk) +{ + return compressionStreamTransformImpl(globalObject, stream, controller, chunk); +} + +JSPromise* decompressionStreamFlush(JSGlobalObject* globalObject, JSDecompressionStream* stream, JSTransformStreamDefaultController* controller) +{ + return codeAndEnqueue(globalObject, stream, stream->m_coder, controller, jsUndefined(), nullptr, 0, true); +} + +// JS-thread completion for the off-thread codec dispatched by codeAndEnqueue. `out[..outLen]` +// borrows the coder's reusable output buffer, so the bytes are consumed (sink-write or +// controller-enqueue) BEFORE `m_asyncCodecInFlight` is cleared and any deferred coder release +// runs. The transform-algorithm promise to settle is read from `m_asyncCodecPromise`. +extern "C" void Bun__CompressionStream__deliverAsync(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue streamCell, const uint8_t* out, size_t outLen, JSC::EncodedJSValue error) +{ + auto& vm = getVM(globalObject); + auto* stream = dynamicDowncast(JSValue::decode(streamCell)); + ASSERT(stream); + if (!stream) [[unlikely]] + return; + auto* promise = stream->m_asyncCodecPromise.get(); + stream->m_asyncCodecPromise.clear(); + ASSERT(promise); + + JSValue thrown; + bool sinkBackpressure = false; + if (!error) { + auto catchScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + if (void* sinkPtr = stream->m_nativeSinkPtr) { + if (outLen) { + JSValue wrote = JSValue::decode(Bun__NativeTransformSink__writeBytes(stream->m_nativeSinkId, sinkPtr, globalObject, out, outLen)); + if (!catchScope.exception()) + sinkBackpressure = nativeSinkWriteIsBackpressure(vm, wrote); + } + } else if (outLen) { + auto copied = JSC::ArrayBuffer::tryCreate(std::span(out, outLen)); + if (!copied) [[unlikely]] { + thrown = JSC::createOutOfMemoryError(globalObject); + } else { + auto* view = JSC::JSUint8Array::create(globalObject, globalObject->typedArrayStructure(JSC::TypeUint8, false), WTF::move(copied), 0, outLen); + if (!catchScope.exception()) + transformStreamDefaultControllerEnqueue(globalObject, stream->m_controller.get(), JSValue(view)); + } + } + if (catchScope.exception()) [[unlikely]] + thrown = takeAbruptCompletion(globalObject, catchScope); + } + + stream->m_asyncCodecInFlight = false; + if (stream->m_nativeStateReleasePending && !stream->m_nativeStateInUse) [[unlikely]] + nativeTransformReleaseState(stream); + + if (!promise) [[unlikely]] + return; + if (error) { + rejectPromise(globalObject, promise, JSValue::decode(error)); + return; + } + if (!thrown.isEmpty()) { + rejectPromise(globalObject, promise, thrown); + return; + } + if (sinkBackpressure) { + stream->m_nativeSinkReadyPromise.set(vm, stream, promise); + return; + } + resolvePromise(globalObject, promise, jsUndefined()); +} + +} // namespace WebStreams +} // namespace Bun diff --git a/src/jsc/bindings/webcore/streams/JSCompressionStreamShared.h b/src/jsc/bindings/webcore/streams/JSCompressionStreamShared.h new file mode 100644 index 000000000000..e964a95a1335 --- /dev/null +++ b/src/jsc/bindings/webcore/streams/JSCompressionStreamShared.h @@ -0,0 +1,21 @@ +// Shared helpers for JSCompressionStream / JSDecompressionStream — the Rust +// CompressionStreamCoder FFI decls plus the format parser both constructors use. +#pragma once + +#include "root.h" +#include "StreamsForward.h" + +// CompressionStreamCoder.rs +extern "C" void* CompressionStreamCoder__create(uint8_t format, bool decompress); +extern "C" void CompressionStreamCoder__destroy(void* coder); +extern "C" JSC::EncodedJSValue CompressionStreamCoder__transform(void* coder, JSC::JSGlobalObject* global, const uint8_t* input, size_t input_len, bool finish); +extern "C" JSC::EncodedJSValue CompressionStreamCoder__transformInto(void* coder, JSC::JSGlobalObject* global, const uint8_t* input, size_t input_len, bool finish, uint8_t sinkId, void* sinkPtr); +extern "C" void CompressionStreamCoder__transformAsync(void* coder, JSC::JSGlobalObject* global, JSC::EncodedJSValue streamCell, JSC::EncodedJSValue chunk, const uint8_t* input, size_t inputLen, bool finish); + +namespace Bun { +namespace WebStreams { + +std::optional parseCompressionFormat(JSC::JSGlobalObject*, JSC::JSValue formatValue); + +} // namespace WebStreams +} // namespace Bun diff --git a/src/jsc/bindings/webcore/streams/JSDecompressionStream.cpp b/src/jsc/bindings/webcore/streams/JSDecompressionStream.cpp new file mode 100644 index 000000000000..befa8d46cb86 --- /dev/null +++ b/src/jsc/bindings/webcore/streams/JSDecompressionStream.cpp @@ -0,0 +1,264 @@ +#include "config.h" +#include "JSDecompressionStream.h" + +#include "DOMClientIsoSubspaces.h" +#include "DOMIsoSubspaces.h" +#include "ErrorCode.h" +#include "JSCompressionStreamShared.h" +#include "JSDOMExceptionHandling.h" +#include "JSDOMGlobalObjectInlines.h" +#include "JSDOMWrapperCache.h" +#include "JSReadableStream.h" +#include "JSStreamsRuntime.h" +#include "JSTransformStream.h" +#include "JSTransformStreamDefaultController.h" +#include "JSWritableStream.h" +#include "WebCoreJSClientData.h" +#include "WebStreamsInspectCustom.h" +#include "WebStreamsInternals.h" +#include "ZigGlobalObject.h" +#include +#include +#include +#include +#include + +namespace WebCore { + +using namespace JSC; +using namespace Bun::WebStreams; + +// ─── JSDecompressionStreamPrototype ───────────────────────────────────────── + +static JSC_DECLARE_CUSTOM_GETTER(jsDecompressionStreamPrototypeGetter_constructor); +static JSC_DECLARE_CUSTOM_GETTER(jsDecompressionStreamPrototypeGetter_readable); +static JSC_DECLARE_CUSTOM_GETTER(jsDecompressionStreamPrototypeGetter_writable); +static JSC_DECLARE_HOST_FUNCTION(jsDecompressionStreamPrototype_inspectCustom); + +class JSDecompressionStreamPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static JSDecompressionStreamPrototype* create(JSC::VM& vm, JSDOMGlobalObject* globalObject, JSC::Structure* structure) + { + JSDecompressionStreamPrototype* ptr = new (NotNull, JSC::allocateCell(vm)) JSDecompressionStreamPrototype(vm, structure); + ptr->finishCreation(vm); + return ptr; + } + + DECLARE_INFO; + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSDecompressionStreamPrototype, Base); + return &vm.plainObjectSpace(); + } + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSDecompressionStreamPrototype(JSC::VM& vm, JSC::Structure* structure) + : JSC::JSNonFinalObject(vm, structure) + { + } + + void finishCreation(JSC::VM&); +}; +STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSDecompressionStreamPrototype, JSDecompressionStreamPrototype::Base); + +static const HashTableValue JSDecompressionStreamPrototypeTableValues[] = { + { "constructor"_s, static_cast(JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::GetterSetterType, jsDecompressionStreamPrototypeGetter_constructor, 0 } }, + { "readable"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsDecompressionStreamPrototypeGetter_readable, 0 } }, + { "writable"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsDecompressionStreamPrototypeGetter_writable, 0 } }, +}; + +const ClassInfo JSDecompressionStreamPrototype::s_info = { "DecompressionStream"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSDecompressionStreamPrototype) }; + +JSC_DEFINE_HOST_FUNCTION(jsDecompressionStreamPrototype_inspectCustom, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue thisValue = callFrame->thisValue(); + auto* thisObject = dynamicDowncast(thisValue); + if (!thisObject) [[unlikely]] + return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "DecompressionStream"_s); + JSObject* data = constructEmptyObject(lexicalGlobalObject); + data->putDirect(vm, Identifier::fromString(vm, "readable"_s), thisObject->m_readable.get() ? JSValue(thisObject->m_readable.get()) : jsUndefined(), 0); + data->putDirect(vm, Identifier::fromString(vm, "writable"_s), thisObject->m_writable.get() ? JSValue(thisObject->m_writable.get()) : jsUndefined(), 0); + RELEASE_AND_RETURN(scope, Bun::WebStreams::customInspect(lexicalGlobalObject, callFrame, thisValue, "DecompressionStream"_s, data)); +} + +void JSDecompressionStreamPrototype::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSDecompressionStream::info(), JSDecompressionStreamPrototypeTableValues, *this); + Bun::WebStreams::installInspectCustom(vm, this, jsDecompressionStreamPrototype_inspectCustom); + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +// JSDecompressionStreamConstructor = JSStreamConstructor. + +template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSDecompressionStreamConstructor::construct(JSGlobalObject*, CallFrame*); +template<> JSValue JSDecompressionStreamConstructor::prototypeForStructure(JSC::VM&, const JSDOMGlobalObject&); +template<> void JSDecompressionStreamConstructor::finishCreation(JSC::VM&, JSDOMGlobalObject&); +template<> GCClient::IsoSubspace* JSDecompressionStreamConstructor::subspaceForImpl(JSC::VM&); +template<> void JSDecompressionStreamConstructor::visitChildren(JSCell*, JSC::AbstractSlotVisitor&); +template<> void JSDecompressionStreamConstructor::visitChildren(JSCell*, JSC::SlotVisitor&); +template<> +template +void JSDecompressionStreamConstructor::visitChildrenImpl(JSCell*, Visitor&); + +template<> const ClassInfo JSDecompressionStreamConstructor::s_info = { "DecompressionStream"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSDecompressionStreamConstructor) }; + +template<> JSValue JSDecompressionStreamConstructor::prototypeForStructure(JSC::VM& vm, const JSDOMGlobalObject& globalObject) +{ + return globalObject.functionPrototype(); +} + +template<> +template +void JSDecompressionStreamConstructor::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + auto* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_instanceStructure); +} +DEFINE_VISIT_CHILDREN_WITH_MODIFIER(template<>, JSDecompressionStreamConstructor); + +template<> GCClient::IsoSubspace* JSDecompressionStreamConstructor::subspaceForImpl(JSC::VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForDecompressionStreamConstructor.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForDecompressionStreamConstructor = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForDecompressionStreamConstructor.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForDecompressionStreamConstructor = std::forward(space); }); +} + +template<> void JSDecompressionStreamConstructor::finishCreation(VM& vm, JSDOMGlobalObject& globalObject) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); + putDirect(vm, vm.propertyNames->length, jsNumber(1), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); + JSString* nameString = jsNontrivialString(vm, "DecompressionStream"_s); + m_originalName.set(vm, this, nameString); + putDirect(vm, vm.propertyNames->name, nameString, JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); + putDirect(vm, vm.propertyNames->prototype, JSDecompressionStream::prototype(vm, globalObject), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete); + m_instanceStructure.set(vm, this, getDOMStructure(vm, globalObject)); +} + +template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSDecompressionStreamConstructor::construct(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* constructor = uncheckedDowncast(callFrame->jsCallee()); + + auto format = parseCompressionFormat(lexicalGlobalObject, callFrame->argument(0)); + RETURN_IF_EXCEPTION(scope, {}); + ASSERT(format.has_value()); + + void* coder = CompressionStreamCoder__create(static_cast(*format), true); + if (!coder) [[unlikely]] { + throwTypeError(lexicalGlobalObject, scope, "failed to initialize decompressor"_s); + return {}; + } + + auto* structure = structureForNewTarget(vm, constructor, lexicalGlobalObject, asObject(callFrame->newTarget())); + if (scope.exception()) [[unlikely]] { + CompressionStreamCoder__destroy(coder); + return {}; + } + auto* stream = JSDecompressionStream::create(vm, structure); + stream->m_coder = coder; + vm.heap.addFinalizer(stream, static_cast([](JSCell* cell) { + CompressionStreamCoder__destroy(std::exchange(static_cast(cell)->m_coder, nullptr)); + })); + + setUpNativeTransformStream(lexicalGlobalObject, stream, TransformerKind::Decompression); + RETURN_IF_EXCEPTION(scope, {}); + + return JSValue::encode(stream); +} +JSC_ANNOTATE_HOST_FUNCTION(JSDecompressionStreamConstructorConstruct, JSDecompressionStreamConstructor::construct); + +// ─── JSDecompressionStream ────────────────────────────────────────────────── + +const ClassInfo JSDecompressionStream::s_info = { "DecompressionStream"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSDecompressionStream) }; + +JSDecompressionStream::JSDecompressionStream(VM& vm, Structure* structure) + : Base(vm, structure) +{ +} + +JSDecompressionStream* JSDecompressionStream::create(VM& vm, Structure* structure) +{ + auto* stream = new (NotNull, allocateCell(vm)) JSDecompressionStream(vm, structure); + stream->finishCreation(vm); + return stream; +} + +Structure* JSDecompressionStream::createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) +{ + return Structure::create(vm, globalObject, prototype, TypeInfo(ObjectType, StructureFlags), info()); +} + +JSObject* JSDecompressionStream::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + auto* structure = JSDecompressionStreamPrototype::createStructure(vm, &globalObject, globalObject.objectPrototype()); + structure->setMayBePrototype(true); + return JSDecompressionStreamPrototype::create(vm, &globalObject, structure); +} + +JSObject* JSDecompressionStream::prototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + return getDOMPrototype(vm, globalObject); +} + +JSValue JSDecompressionStream::getConstructor(VM& vm, const JSGlobalObject* globalObject) +{ + return getDOMConstructor(vm, *uncheckedDowncast(globalObject)); +} + +GCClient::IsoSubspace* JSDecompressionStream::subspaceForImpl(VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForDecompressionStream.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForDecompressionStream = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForDecompressionStream.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForDecompressionStream = std::forward(space); }); +} + +JSC_DEFINE_CUSTOM_GETTER(jsDecompressionStreamPrototypeGetter_constructor, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* prototype = dynamicDowncast(JSValue::decode(thisValue)); + if (!prototype) [[unlikely]] + return throwVMTypeError(lexicalGlobalObject, scope); + return JSValue::encode(JSDecompressionStream::getConstructor(vm, prototype->globalObject())); +} + +JSC_DEFINE_CUSTOM_GETTER(jsDecompressionStreamPrototypeGetter_readable, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* stream = dynamicDowncast(JSValue::decode(thisValue)); + if (!stream) [[unlikely]] + return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "DecompressionStream"_s); + return JSValue::encode(stream->m_readable.get()); +} + +JSC_DEFINE_CUSTOM_GETTER(jsDecompressionStreamPrototypeGetter_writable, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* stream = dynamicDowncast(JSValue::decode(thisValue)); + if (!stream) [[unlikely]] + return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "DecompressionStream"_s); + return JSValue::encode(stream->m_writable.get()); +} + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/streams/JSDecompressionStream.h b/src/jsc/bindings/webcore/streams/JSDecompressionStream.h new file mode 100644 index 000000000000..b3fe94e0c84c --- /dev/null +++ b/src/jsc/bindings/webcore/streams/JSDecompressionStream.h @@ -0,0 +1,46 @@ +// JSDecompressionStream — the DecompressionStream instance cell. A JSTransformStream +// subclass whose controller's transformerKind drives the Rust CompressionStreamCoder +// (m_coder) directly. +#pragma once + +#include "root.h" +#include "StreamsForward.h" + +#include "JSDOMGlobalObject.h" +#include "JSTransformStream.h" +#include "StreamConstructor.h" + +namespace WebCore { + +class JSDecompressionStream final : public JSTransformStream { +public: + using Base = JSTransformStream; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSDecompressionStream* create(JSC::VM&, JSC::Structure*); + + static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); + static JSC::JSObject* prototype(JSC::VM&, JSDOMGlobalObject&); + static JSC::JSValue getConstructor(JSC::VM&, const JSC::JSGlobalObject*); + static JSC::Structure* createStructure(JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue prototype); + + DECLARE_INFO; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM&); + + void* m_coder { nullptr }; + +private: + JSDecompressionStream(JSC::VM&, JSC::Structure*); +}; + +using JSDecompressionStreamConstructor = JSStreamConstructor; + +} // namespace WebCore diff --git a/src/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.h b/src/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.h index c2d8e359b9f1..1becae5f4f96 100644 --- a/src/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.h +++ b/src/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.h @@ -24,7 +24,7 @@ class JSReadStreamIntoSinkOperation final : public JSC::JSNonFinalObject { static JSC::Structure* createStructure(JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue prototype); DECLARE_INFO; - // visitChildrenImpl MUST visit: m_stream, m_reader, m_sink, m_result, m_pendingBatch. + // visitChildrenImpl MUST visit every WriteBarrier field below. DECLARE_VISIT_CHILDREN; static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); @@ -47,6 +47,10 @@ class JSReadStreamIntoSinkOperation final : public JSC::JSNonFinalObject { JSC::WriteBarrier m_result; // Nullable: the unwritten batch tail stashed on sink backpressure; drained on m_onPull. JSC::WriteBarrier m_pendingBatch; + // Nullable: the byte-producing JSTransformStream subclass whose transform arms + // write output straight to this sink. Set at attach; onReady flips its + // m_backpressure back to false, onClose/finally detaches it. + JSC::WriteBarrier m_nativeTransform; bool m_didThrow : 1 { false }; bool m_didClose : 1 { false }; bool m_started : 1 { false }; diff --git a/src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp b/src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp index 4d6600788645..0555572f9e8b 100644 --- a/src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp +++ b/src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp @@ -13,18 +13,23 @@ #include "JSTransformStreamDefaultController.h" #include "JSWritableStream.h" #include "WebCoreJSClientData.h" -#include "WebStreamsHeapAnalyzer.h" #include "WebStreamsInspectCustom.h" #include "WebStreamsInternals.h" #include "ZigGlobalObject.h" +#include "headers-handwritten.h" #include #include +#include #include #include -#include #include #include +// TextDecoder.rs +extern "C" void* TextDecoder__createForStream(JSC::JSGlobalObject*, JSC::EncodedJSValue label, bool fatal, bool ignoreBOM, bool* outUtf8FastPath, BunString* outEncodingLabel); +extern "C" void TextDecoder__destroyForStream(void*); +extern "C" JSC::EncodedJSValue TextDecoder__decodeForStream(void*, JSC::JSGlobalObject*, const uint8_t* input, size_t inputLen, bool stream); + namespace WebCore { using namespace JSC; @@ -129,15 +134,7 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSTextDecoderStreamConst auto* constructor = uncheckedDowncast(callFrame->jsCallee()); auto& names = builtinNames(vm); - auto* structure = structureForNewTarget(vm, constructor, lexicalGlobalObject, asObject(callFrame->newTarget())); - RETURN_IF_EXCEPTION(scope, {}); - auto* stream = JSTextDecoderStream::create(vm, structure); - - auto* transform = createTransformStream(lexicalGlobalObject, TransformerKind::TextDecoder, stream, 1, nullptr, 0, nullptr); - RETURN_IF_EXCEPTION(scope, {}); - stream->m_transform.set(vm, stream, transform); - - JSValue label = callFrame->argumentCount() >= 1 ? callFrame->uncheckedArgument(0) : jsNontrivialString(vm, "utf-8"_s); + JSValue label = callFrame->argument(0); bool fatal = false; bool ignoreBOM = false; JSValue options = callFrame->argument(1); @@ -155,17 +152,33 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSTextDecoderStreamConst ignoreBOM = ignoreBOMValue.toBoolean(lexicalGlobalObject); } - // `new TextDecoder(label, { fatal, ignoreBOM })` owns the label validation. - auto* decoderOptions = constructEmptyObject(lexicalGlobalObject); - decoderOptions->putDirect(vm, names.fatalPublicName(), jsBoolean(fatal)); - decoderOptions->putDirect(vm, names.ignoreBOMPublicName(), jsBoolean(ignoreBOM)); - MarkedArgumentBuffer decoderArguments; - decoderArguments.append(label); - decoderArguments.append(decoderOptions); - ASSERT(!decoderArguments.hasOverflowed()); - auto* decoder = JSC::construct(lexicalGlobalObject, defaultGlobalObject(lexicalGlobalObject)->JSTextDecoderConstructor(), decoderArguments, "TextDecoder is not constructible"_s); + // Validates label (WebIDL DOMString coercion — may run user JS). For utf-8 + !fatal no + // Rust decoder is allocated: the transform/flush arms use streamingUTF8Decode instead. + bool utf8FastPath = false; + BunString encodingLabel {}; + void* decoder = TextDecoder__createForStream(lexicalGlobalObject, JSValue::encode(label), fatal, ignoreBOM, &utf8FastPath, &encodingLabel); + RETURN_IF_EXCEPTION(scope, {}); + ASSERT(utf8FastPath == !decoder); + + auto* structure = structureForNewTarget(vm, constructor, lexicalGlobalObject, asObject(callFrame->newTarget())); + if (scope.exception()) [[unlikely]] { + TextDecoder__destroyForStream(decoder); + return {}; + } + auto* stream = JSTextDecoderStream::create(vm, structure); + stream->m_decoder = decoder; + stream->m_encodingLabel = encodingLabel; + stream->m_fatal = fatal; + stream->m_ignoreBOM = ignoreBOM; + if (utf8FastPath) + stream->m_utf8State.bomSeen = ignoreBOM; + else + vm.heap.addFinalizer(stream, static_cast([](JSCell* cell) { + TextDecoder__destroyForStream(std::exchange(static_cast(cell)->m_decoder, nullptr)); + })); + + setUpNativeTransformStream(lexicalGlobalObject, stream, TransformerKind::TextDecoder); RETURN_IF_EXCEPTION(scope, {}); - stream->m_decoder.set(vm, stream, decoder); return JSValue::encode(stream); } @@ -194,21 +207,12 @@ JSC_DEFINE_HOST_FUNCTION(jsTextDecoderStreamPrototype_inspectCustom, (JSGlobalOb // streams classes, whose inspect methods just fault on a bad `this`. if (!thisObject) [[unlikely]] return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "TextDecoderStream"_s); - // encoding/fatal/ignoreBOM live on the TextDecoder held by m_decoder; read them via the - // public prototype getters this class already exposes so no extra coupling is introduced. JSObject* data = constructEmptyObject(lexicalGlobalObject); - JSValue encoding = thisObject->get(lexicalGlobalObject, Identifier::fromString(vm, "encoding"_s)); - RETURN_IF_EXCEPTION(scope, {}); - data->putDirect(vm, Identifier::fromString(vm, "encoding"_s), encoding, 0); - JSValue fatal = thisObject->get(lexicalGlobalObject, Identifier::fromString(vm, "fatal"_s)); - RETURN_IF_EXCEPTION(scope, {}); - data->putDirect(vm, Identifier::fromString(vm, "fatal"_s), fatal, 0); - JSValue ignoreBOM = thisObject->get(lexicalGlobalObject, Identifier::fromString(vm, "ignoreBOM"_s)); - RETURN_IF_EXCEPTION(scope, {}); - data->putDirect(vm, Identifier::fromString(vm, "ignoreBOM"_s), ignoreBOM, 0); - auto* transform = thisObject->m_transform.get(); - data->putDirect(vm, Identifier::fromString(vm, "readable"_s), transform && transform->m_readable.get() ? JSValue(transform->m_readable.get()) : jsUndefined(), 0); - data->putDirect(vm, Identifier::fromString(vm, "writable"_s), transform && transform->m_writable.get() ? JSValue(transform->m_writable.get()) : jsUndefined(), 0); + data->putDirect(vm, Identifier::fromString(vm, "encoding"_s), Bun::toJS(lexicalGlobalObject, thisObject->m_encodingLabel), 0); + data->putDirect(vm, Identifier::fromString(vm, "fatal"_s), jsBoolean(thisObject->m_fatal), 0); + data->putDirect(vm, Identifier::fromString(vm, "ignoreBOM"_s), jsBoolean(thisObject->m_ignoreBOM), 0); + data->putDirect(vm, Identifier::fromString(vm, "readable"_s), thisObject->m_readable.get() ? JSValue(thisObject->m_readable.get()) : jsUndefined(), 0); + data->putDirect(vm, Identifier::fromString(vm, "writable"_s), thisObject->m_writable.get() ? JSValue(thisObject->m_writable.get()) : jsUndefined(), 0); RELEASE_AND_RETURN(scope, Bun::WebStreams::customInspect(lexicalGlobalObject, callFrame, thisValue, "TextDecoderStream"_s, data)); } @@ -229,12 +233,6 @@ JSTextDecoderStream::JSTextDecoderStream(VM& vm, Structure* structure) { } -void JSTextDecoderStream::finishCreation(VM& vm) -{ - Base::finishCreation(vm); - ASSERT(inherits(info())); -} - JSTextDecoderStream* JSTextDecoderStream::create(VM& vm, Structure* structure) { auto* stream = new (NotNull, allocateCell(vm)) JSTextDecoderStream(vm, structure); @@ -274,27 +272,6 @@ GCClient::IsoSubspace* JSTextDecoderStream::subspaceForImpl(VM& vm) [](auto& spaces, auto&& space) { spaces.m_subspaceForTextDecoderStream = std::forward(space); }); } -DEFINE_VISIT_CHILDREN(JSTextDecoderStream); - -template -void JSTextDecoderStream::visitChildrenImpl(JSCell* cell, Visitor& visitor) -{ - auto* thisObject = uncheckedDowncast(cell); - ASSERT_GC_OBJECT_INHERITS(thisObject, info()); - Base::visitChildren(thisObject, visitor); - visitor.appendHidden(thisObject->m_transform); - visitor.appendHidden(thisObject->m_decoder); -} - -void JSTextDecoderStream::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) -{ - auto* thisObject = uncheckedDowncast(cell); - auto& vm = cell->vm(); - Base::analyzeHeap(cell, analyzer); - analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_transform, "transform"_s); - analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_decoder, "decoder"_s); -} - // Prototype accessors JSC_DEFINE_CUSTOM_GETTER(jsTextDecoderStreamPrototypeGetter_constructor, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) @@ -307,30 +284,34 @@ JSC_DEFINE_CUSTOM_GETTER(jsTextDecoderStreamPrototypeGetter_constructor, (JSGlob return JSValue::encode(JSTextDecoderStream::getConstructor(vm, prototype->globalObject())); } -// The `encoding` / `fatal` / `ignoreBOM` getters delegate to the wrapped TextDecoder. -static EncodedJSValue textDecoderStreamDelegatedGetter(JSGlobalObject* lexicalGlobalObject, EncodedJSValue thisValue, const Identifier& property, ASCIILiteral attributeName) +JSC_DEFINE_CUSTOM_GETTER(jsTextDecoderStreamPrototypeGetter_encoding, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); const auto* stream = dynamicDowncast(JSValue::decode(thisValue)); if (!stream) [[unlikely]] - return throwThisTypeError(*lexicalGlobalObject, scope, "TextDecoderStream"_s, attributeName); - RELEASE_AND_RETURN(scope, JSValue::encode(stream->m_decoder->get(lexicalGlobalObject, property))); -} - -JSC_DEFINE_CUSTOM_GETTER(jsTextDecoderStreamPrototypeGetter_encoding, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) -{ - return textDecoderStreamDelegatedGetter(lexicalGlobalObject, thisValue, builtinNames(JSC::getVM(lexicalGlobalObject)).encodingPublicName(), "encoding"_s); + return throwThisTypeError(*lexicalGlobalObject, scope, "TextDecoderStream"_s, "encoding"_s); + return JSValue::encode(Bun::toJS(lexicalGlobalObject, stream->m_encodingLabel)); } JSC_DEFINE_CUSTOM_GETTER(jsTextDecoderStreamPrototypeGetter_fatal, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) { - return textDecoderStreamDelegatedGetter(lexicalGlobalObject, thisValue, builtinNames(JSC::getVM(lexicalGlobalObject)).fatalPublicName(), "fatal"_s); + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + const auto* stream = dynamicDowncast(JSValue::decode(thisValue)); + if (!stream) [[unlikely]] + return throwThisTypeError(*lexicalGlobalObject, scope, "TextDecoderStream"_s, "fatal"_s); + return JSValue::encode(jsBoolean(stream->m_fatal)); } JSC_DEFINE_CUSTOM_GETTER(jsTextDecoderStreamPrototypeGetter_ignoreBOM, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) { - return textDecoderStreamDelegatedGetter(lexicalGlobalObject, thisValue, builtinNames(JSC::getVM(lexicalGlobalObject)).ignoreBOMPublicName(), "ignoreBOM"_s); + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + const auto* stream = dynamicDowncast(JSValue::decode(thisValue)); + if (!stream) [[unlikely]] + return throwThisTypeError(*lexicalGlobalObject, scope, "TextDecoderStream"_s, "ignoreBOM"_s); + return JSValue::encode(jsBoolean(stream->m_ignoreBOM)); } JSC_DEFINE_CUSTOM_GETTER(jsTextDecoderStreamPrototypeGetter_readable, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) @@ -340,7 +321,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsTextDecoderStreamPrototypeGetter_readable, (JSGlobalO const auto* stream = dynamicDowncast(JSValue::decode(thisValue)); if (!stream) [[unlikely]] return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "TextDecoderStream"_s); - return JSValue::encode(stream->m_transform->m_readable.get()); + return JSValue::encode(stream->m_readable.get()); } JSC_DEFINE_CUSTOM_GETTER(jsTextDecoderStreamPrototypeGetter_writable, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) @@ -350,7 +331,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsTextDecoderStreamPrototypeGetter_writable, (JSGlobalO const auto* stream = dynamicDowncast(JSValue::decode(thisValue)); if (!stream) [[unlikely]] return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "TextDecoderStream"_s); - return JSValue::encode(stream->m_transform->m_writable.get()); + return JSValue::encode(stream->m_writable.get()); } } // namespace WebCore @@ -361,34 +342,29 @@ namespace WebStreams { using namespace JSC; using WebCore::JSTextDecoderStream; -// `decoder.decode(input, { stream })` on the wrapped TextDecoder. Runs user JS: `decode` -// is looked up on the public TextDecoder.prototype. Empty return = it threw. -static JSValue invokeDecode(JSC::VM& vm, JSGlobalObject* globalObject, JSObject* decoder, JSValue input, bool streaming) +// [AllowShared] BufferSource → (ptr, len); a detached buffer yields the empty sequence. +static std::optional> textDecoderStreamBytes(JSGlobalObject* globalObject, JSValue chunk) { + auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - auto& names = WebCore::builtinNames(vm); - - auto* decodeOptions = constructEmptyObject(globalObject); - decodeOptions->putDirect(vm, names.streamPublicName(), jsBoolean(streaming)); - - JSValue method = decoder->get(globalObject, names.decodePublicName()); - RETURN_IF_EXCEPTION(scope, {}); - auto callData = getCallData(method); - if (callData.type == CallData::Type::None) [[unlikely]] { - throwTypeError(globalObject, scope, "TextDecoder.prototype.decode is not callable"_s); - return {}; + if (auto* view = dynamicDowncast(chunk)) { + if (view->isDetached()) [[unlikely]] + return std::span(); + return std::span(static_cast(view->vector()), view->byteLength()); + } + if (auto* buffer = dynamicDowncast(chunk)) { + auto* impl = buffer->impl(); + if (!impl || impl->isDetached()) [[unlikely]] + return std::span(); + return std::span(static_cast(impl->data()), impl->byteLength()); } - MarkedArgumentBuffer args; - args.append(input); - args.append(decodeOptions); - ASSERT(!args.hasOverflowed()); - RELEASE_AND_RETURN(scope, call(globalObject, method, callData, decoder, args)); + Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "chunk"_s, "BufferSource"_s, chunk); + return std::nullopt; } -// Decodes, then enqueues the non-empty result; abrupt decode OR enqueue completions become -// a rejected promise (a transform algorithm must never throw synchronously into -// ProcessWrite — the in-flight write would never settle). Shared by transform and flush. -static JSPromise* decodeAndEnqueue(JSGlobalObject* globalObject, JSTextDecoderStream* stream, JSTransformStreamDefaultController* controller, JSValue input, bool streaming) +// Abrupt completions from decode OR enqueue become a rejected promise — a transform +// algorithm must never throw synchronously into ProcessWrite/ProcessClose. +static JSPromise* decodeAndEnqueue(JSGlobalObject* globalObject, JSTextDecoderStream* stream, JSTransformStreamDefaultController* controller, const uint8_t* input, size_t inputLen, bool streaming) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -396,13 +372,18 @@ static JSPromise* decodeAndEnqueue(JSGlobalObject* globalObject, JSTextDecoderSt JSValue thrown; { auto catchScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - JSValue decoded = invokeDecode(vm, globalObject, stream->m_decoder.get(), input, streaming); + JSValue decoded; + if (stream->m_decoder) { + decoded = JSValue::decode(TextDecoder__decodeForStream(stream->m_decoder, globalObject, input, inputLen, streaming)); + } else { + auto* s = streamingUTF8Decode(globalObject, std::span { input, inputLen }, stream->m_utf8State, /* flush */ !streaming); + decoded = s ? JSValue(s) : jsUndefined(); + } if (!catchScope.exception() && decoded.isString() && asString(decoded)->length()) transformStreamDefaultControllerEnqueue(globalObject, controller, decoded); if (catchScope.exception()) [[unlikely]] thrown = takeAbruptCompletion(globalObject, catchScope); } - // takeAbruptCompletion leaves a VM termination pending and returns the empty value. RETURN_IF_EXCEPTION(scope, nullptr); if (!thrown.isEmpty()) RELEASE_AND_RETURN(scope, promiseRejectedWith(globalObject, thrown)); @@ -411,12 +392,25 @@ static JSPromise* decodeAndEnqueue(JSGlobalObject* globalObject, JSTextDecoderSt JSPromise* textDecoderStreamTransform(JSGlobalObject* globalObject, JSTextDecoderStream* stream, JSTransformStreamDefaultController* controller, JSValue chunk) { - return decodeAndEnqueue(globalObject, stream, controller, chunk, /* streaming */ true); + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue thrown; + std::optional> bytes; + { + auto catchScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + bytes = textDecoderStreamBytes(globalObject, chunk); + if (catchScope.exception()) [[unlikely]] + thrown = takeAbruptCompletion(globalObject, catchScope); + } + RETURN_IF_EXCEPTION(scope, nullptr); + if (!thrown.isEmpty()) + RELEASE_AND_RETURN(scope, promiseRejectedWith(globalObject, thrown)); + RELEASE_AND_RETURN(scope, decodeAndEnqueue(globalObject, stream, controller, bytes->data(), bytes->size(), true)); } JSPromise* textDecoderStreamFlush(JSGlobalObject* globalObject, JSTextDecoderStream* stream, JSTransformStreamDefaultController* controller) { - return decodeAndEnqueue(globalObject, stream, controller, jsUndefined(), /* streaming */ false); + return decodeAndEnqueue(globalObject, stream, controller, nullptr, 0, false); } } // namespace WebStreams diff --git a/src/jsc/bindings/webcore/streams/JSTextDecoderStream.h b/src/jsc/bindings/webcore/streams/JSTextDecoderStream.h index dec2e3eedb89..e6e3d2e855fa 100644 --- a/src/jsc/bindings/webcore/streams/JSTextDecoderStream.h +++ b/src/jsc/bindings/webcore/streams/JSTextDecoderStream.h @@ -1,24 +1,21 @@ -// JSTextDecoderStream — the TextDecoderStream instance cell: it is -// TransformerKind::TextDecoder's algorithmContext, and the transform/flush algorithms are -// native code over m_decoder ({stream:true} decodes, then a final {stream:false} flush). -// Non-destructible: the decoder state is held as the TextDecoder WRAPPER CELL (a -// WriteBarrier), not a RefPtr. +// JSTextDecoderStream — the TextDecoderStream instance cell. A JSTransformStream +// subclass whose transform/flush arms drive the Rust TextDecoder (m_decoder) +// directly. #pragma once #include "root.h" #include "StreamsForward.h" #include "JSDOMGlobalObject.h" +#include "JSTransformStream.h" #include "StreamConstructor.h" -#include namespace WebCore { -class JSTextDecoderStream final : public JSC::JSNonFinalObject { +class JSTextDecoderStream final : public JSTransformStream { public: - using Base = JSC::JSNonFinalObject; + using Base = JSTransformStream; static constexpr unsigned StructureFlags = Base::StructureFlags; - static constexpr JSC::DestructionMode needsDestruction = JSC::DoesNotNeedDestruction; static JSTextDecoderStream* create(JSC::VM&, JSC::Structure*); @@ -28,9 +25,6 @@ class JSTextDecoderStream final : public JSC::JSNonFinalObject { static JSC::Structure* createStructure(JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue prototype); DECLARE_INFO; - // visitChildrenImpl MUST visit: m_transform, m_decoder. - DECLARE_VISIT_CHILDREN; - static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) @@ -41,17 +35,21 @@ class JSTextDecoderStream final : public JSC::JSNonFinalObject { } static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM&); - // the inner TransformStream (created by createTransformStream with - // TransformerKind::TextDecoder and `this` as the algorithm context). - JSC::WriteBarrier m_transform; - // the native TextDecoder wrapper cell, constructed as - // `new TextDecoder(label, {fatal, ignoreBOM})` at TextDecoderStream construction; the - // `encoding` / `fatal` / `ignoreBOM` getters delegate to it. - JSC::WriteBarrier m_decoder; + // the Rust TextDecoder (encoding state, BOM / partial-sequence buffering). Null for + // the utf-8 non-fatal fast path, which uses m_utf8State instead. Freed eagerly at + // ClearAlgorithms; a vm.heap.addFinalizer registered in the constructor (non-utf8 + // only) is the idempotent fallback for an abandoned stream. + void* m_decoder { nullptr }; + // Immutable for the cell's lifetime (the .encoding getter reads this, never + // m_decoder which the eager release nulls). Static-tagged: no refcount to drop. + BunString m_encodingLabel {}; + // utf-8 non-fatal fast path (shared with Body.textStream()). + Bun::WebStreams::StreamingUTF8DecodeState m_utf8State; + bool m_fatal { false }; + bool m_ignoreBOM { false }; private: JSTextDecoderStream(JSC::VM&, JSC::Structure*); - void finishCreation(JSC::VM&); }; using JSTextDecoderStreamConstructor = JSStreamConstructor; diff --git a/src/jsc/bindings/webcore/streams/JSTextEncoderStream.cpp b/src/jsc/bindings/webcore/streams/JSTextEncoderStream.cpp index ce1158c5126e..893037fc6ee5 100644 --- a/src/jsc/bindings/webcore/streams/JSTextEncoderStream.cpp +++ b/src/jsc/bindings/webcore/streams/JSTextEncoderStream.cpp @@ -13,18 +13,25 @@ #include "JSTransformStreamDefaultController.h" #include "JSWritableStream.h" #include "WebCoreJSClientData.h" -#include "WebStreamsHeapAnalyzer.h" #include "WebStreamsInspectCustom.h" #include "WebStreamsInternals.h" #include "ZigGlobalObject.h" #include #include +#include #include #include -#include #include #include +// TextEncoderStreamEncoder.rs +extern "C" void* TextEncoderStreamEncoder__createForStream(); +extern "C" void TextEncoderStreamEncoder__destroyForStream(void*); +extern "C" JSC::EncodedJSValue TextEncoderStreamEncoder__encodeForStream(void*, JSC::JSGlobalObject*, JSC::EncodedJSValue chunk); +extern "C" JSC::EncodedJSValue TextEncoderStreamEncoder__flushForStream(void*, JSC::JSGlobalObject*); +extern "C" JSC::EncodedJSValue TextEncoderStreamEncoder__encodeIntoSink(void*, JSC::JSGlobalObject*, JSC::EncodedJSValue chunk, uint8_t sinkId, void* sinkPtr); +extern "C" JSC::EncodedJSValue TextEncoderStreamEncoder__flushIntoSink(void*, JSC::JSGlobalObject*, uint8_t sinkId, void* sinkPtr); + namespace WebCore { using namespace JSC; @@ -129,16 +136,13 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSTextEncoderStreamConst auto* structure = structureForNewTarget(vm, constructor, lexicalGlobalObject, asObject(callFrame->newTarget())); RETURN_IF_EXCEPTION(scope, {}); auto* stream = JSTextEncoderStream::create(vm, structure); + stream->m_encoder = TextEncoderStreamEncoder__createForStream(); + vm.heap.addFinalizer(stream, static_cast([](JSCell* cell) { + TextEncoderStreamEncoder__destroyForStream(std::exchange(static_cast(cell)->m_encoder, nullptr)); + })); - // The existing native TextEncoderStreamEncoder owns the lone-surrogate buffering. - MarkedArgumentBuffer noArguments; - auto* encoder = JSC::construct(lexicalGlobalObject, defaultGlobalObject(lexicalGlobalObject)->JSTextEncoderStreamEncoderConstructor(), noArguments, "TextEncoderStreamEncoder is not constructible"_s); - RETURN_IF_EXCEPTION(scope, {}); - stream->m_encoder.set(vm, stream, encoder); - - auto* transform = createTransformStream(lexicalGlobalObject, TransformerKind::TextEncoder, stream, 1, nullptr, 0, nullptr); + setUpNativeTransformStream(lexicalGlobalObject, stream, TransformerKind::TextEncoder); RETURN_IF_EXCEPTION(scope, {}); - stream->m_transform.set(vm, stream, transform); return JSValue::encode(stream); } @@ -167,9 +171,8 @@ JSC_DEFINE_HOST_FUNCTION(jsTextEncoderStreamPrototype_inspectCustom, (JSGlobalOb return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "TextEncoderStream"_s); JSObject* data = constructEmptyObject(lexicalGlobalObject); data->putDirect(vm, Identifier::fromString(vm, "encoding"_s), jsNontrivialString(vm, "utf-8"_s), 0); - auto* transform = thisObject->m_transform.get(); - data->putDirect(vm, Identifier::fromString(vm, "readable"_s), transform && transform->m_readable.get() ? JSValue(transform->m_readable.get()) : jsUndefined(), 0); - data->putDirect(vm, Identifier::fromString(vm, "writable"_s), transform && transform->m_writable.get() ? JSValue(transform->m_writable.get()) : jsUndefined(), 0); + data->putDirect(vm, Identifier::fromString(vm, "readable"_s), thisObject->m_readable.get() ? JSValue(thisObject->m_readable.get()) : jsUndefined(), 0); + data->putDirect(vm, Identifier::fromString(vm, "writable"_s), thisObject->m_writable.get() ? JSValue(thisObject->m_writable.get()) : jsUndefined(), 0); RELEASE_AND_RETURN(scope, Bun::WebStreams::customInspect(lexicalGlobalObject, callFrame, thisValue, "TextEncoderStream"_s, data)); } @@ -190,12 +193,6 @@ JSTextEncoderStream::JSTextEncoderStream(VM& vm, Structure* structure) { } -void JSTextEncoderStream::finishCreation(VM& vm) -{ - Base::finishCreation(vm); - ASSERT(inherits(info())); -} - JSTextEncoderStream* JSTextEncoderStream::create(VM& vm, Structure* structure) { auto* stream = new (NotNull, allocateCell(vm)) JSTextEncoderStream(vm, structure); @@ -235,27 +232,6 @@ GCClient::IsoSubspace* JSTextEncoderStream::subspaceForImpl(VM& vm) [](auto& spaces, auto&& space) { spaces.m_subspaceForTextEncoderStream = std::forward(space); }); } -DEFINE_VISIT_CHILDREN(JSTextEncoderStream); - -template -void JSTextEncoderStream::visitChildrenImpl(JSCell* cell, Visitor& visitor) -{ - auto* thisObject = uncheckedDowncast(cell); - ASSERT_GC_OBJECT_INHERITS(thisObject, info()); - Base::visitChildren(thisObject, visitor); - visitor.appendHidden(thisObject->m_transform); - visitor.appendHidden(thisObject->m_encoder); -} - -void JSTextEncoderStream::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) -{ - auto* thisObject = uncheckedDowncast(cell); - auto& vm = cell->vm(); - Base::analyzeHeap(cell, analyzer); - analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_transform, "transform"_s); - analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_encoder, "encoder"_s); -} - // Prototype accessors JSC_DEFINE_CUSTOM_GETTER(jsTextEncoderStreamPrototypeGetter_constructor, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) @@ -285,7 +261,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsTextEncoderStreamPrototypeGetter_readable, (JSGlobalO auto* stream = dynamicDowncast(JSValue::decode(thisValue)); if (!stream) [[unlikely]] return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "TextEncoderStream"_s); - return JSValue::encode(stream->m_transform->m_readable.get()); + return JSValue::encode(stream->m_readable.get()); } JSC_DEFINE_CUSTOM_GETTER(jsTextEncoderStreamPrototypeGetter_writable, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) @@ -295,7 +271,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsTextEncoderStreamPrototypeGetter_writable, (JSGlobalO auto* stream = dynamicDowncast(JSValue::decode(thisValue)); if (!stream) [[unlikely]] return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "TextEncoderStream"_s); - return JSValue::encode(stream->m_transform->m_writable.get()); + return JSValue::encode(stream->m_writable.get()); } } // namespace WebCore @@ -306,21 +282,6 @@ namespace WebStreams { using namespace JSC; using WebCore::JSTextEncoderStream; -// `encoder.encode(chunk)` / `encoder.flush()` on the TextEncoderStreamEncoder cell. The -// encode arm runs user JS (ToString of the chunk). Empty return = it threw. -static JSValue invokeEncoderMethod(JSC::VM& vm, JSGlobalObject* globalObject, JSObject* encoder, const Identifier& methodName, const MarkedArgumentBuffer& args) -{ - auto scope = DECLARE_THROW_SCOPE(vm); - JSValue method = encoder->get(globalObject, methodName); - RETURN_IF_EXCEPTION(scope, {}); - auto callData = getCallData(method); - if (callData.type == CallData::Type::None) [[unlikely]] { - throwTypeError(globalObject, scope, "TextEncoderStreamEncoder method is not callable"_s); - return {}; - } - RELEASE_AND_RETURN(scope, call(globalObject, method, callData, encoder, args)); -} - static void enqueueIfNonEmptyView(JSGlobalObject* globalObject, JSTransformStreamDefaultController* controller, JSValue buffer) { auto* view = dynamicDowncast(buffer); @@ -329,42 +290,54 @@ static void enqueueIfNonEmptyView(JSGlobalObject* globalObject, JSTransformStrea transformStreamDefaultControllerEnqueue(globalObject, controller, buffer); } -// An abrupt encode OR enqueue completion becomes a rejected promise (a transform algorithm -// must never throw synchronously into ProcessWrite/ProcessClose — the in-flight operation -// would never settle). Shared by the transform and flush arms. -static JSPromise* encodeAndEnqueue(JSGlobalObject* globalObject, JSTextEncoderStream* stream, JSTransformStreamDefaultController* controller, const Identifier& methodName, const MarkedArgumentBuffer& args) +// Abrupt completions from encode (ToString runs user JS) OR enqueue become a rejected +// promise — a transform algorithm must never throw synchronously into +// ProcessWrite/ProcessClose. When a native JSSink is attached (m_nativeSinkPtr), the +// encoder writes straight to it via its reusable scratch buffer (no JSUint8Array). +static JSPromise* encodeAndEnqueue(JSGlobalObject* globalObject, JSTextEncoderStream* stream, JSTransformStreamDefaultController* controller, JSValue chunk, bool flush) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); JSValue thrown; + bool sinkBackpressure = false; { auto catchScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - JSValue buffer = invokeEncoderMethod(vm, globalObject, stream->m_encoder.get(), methodName, args); - if (!catchScope.exception() && !buffer.isEmpty()) - enqueueIfNonEmptyView(globalObject, controller, buffer); + if (void* sinkPtr = stream->m_nativeSinkPtr) { + JSValue wrote = JSValue::decode(flush + ? TextEncoderStreamEncoder__flushIntoSink(stream->m_encoder, globalObject, stream->m_nativeSinkId, sinkPtr) + : TextEncoderStreamEncoder__encodeIntoSink(stream->m_encoder, globalObject, JSValue::encode(chunk), stream->m_nativeSinkId, sinkPtr)); + if (!catchScope.exception()) + sinkBackpressure = nativeSinkWriteIsBackpressure(vm, wrote); + } else { + JSValue buffer = JSValue::decode(flush + ? TextEncoderStreamEncoder__flushForStream(stream->m_encoder, globalObject) + : TextEncoderStreamEncoder__encodeForStream(stream->m_encoder, globalObject, JSValue::encode(chunk))); + if (!catchScope.exception() && !buffer.isEmpty()) + enqueueIfNonEmptyView(globalObject, controller, buffer); + } if (catchScope.exception()) [[unlikely]] thrown = takeAbruptCompletion(globalObject, catchScope); } - // takeAbruptCompletion leaves a VM termination pending and returns the empty value. RETURN_IF_EXCEPTION(scope, nullptr); if (!thrown.isEmpty()) RELEASE_AND_RETURN(scope, promiseRejectedWith(globalObject, thrown)); + if (sinkBackpressure) { + auto* ready = JSPromise::create(vm, globalObject->promiseStructure()); + stream->m_nativeSinkReadyPromise.set(vm, stream, ready); + return ready; + } RELEASE_AND_RETURN(scope, promiseFulfilledWith(globalObject, JSC::jsUndefined())); } JSPromise* textEncoderStreamTransform(JSGlobalObject* globalObject, JSTextEncoderStream* stream, JSTransformStreamDefaultController* controller, JSValue chunk) { - MarkedArgumentBuffer args; - args.append(chunk); - ASSERT(!args.hasOverflowed()); - return encodeAndEnqueue(globalObject, stream, controller, builtinNames(getVM(globalObject)).encodePublicName(), args); + return encodeAndEnqueue(globalObject, stream, controller, chunk, false); } JSPromise* textEncoderStreamFlush(JSGlobalObject* globalObject, JSTextEncoderStream* stream, JSTransformStreamDefaultController* controller) { - MarkedArgumentBuffer noArguments; - return encodeAndEnqueue(globalObject, stream, controller, builtinNames(getVM(globalObject)).flushPublicName(), noArguments); + return encodeAndEnqueue(globalObject, stream, controller, jsUndefined(), true); } } // namespace WebStreams diff --git a/src/jsc/bindings/webcore/streams/JSTextEncoderStream.h b/src/jsc/bindings/webcore/streams/JSTextEncoderStream.h index 27395490dca6..1126e1503117 100644 --- a/src/jsc/bindings/webcore/streams/JSTextEncoderStream.h +++ b/src/jsc/bindings/webcore/streams/JSTextEncoderStream.h @@ -1,23 +1,21 @@ -// JSTextEncoderStream — the TextEncoderStream instance cell: it is -// TransformerKind::TextEncoder's algorithmContext, and the transform/flush algorithms are -// native code over m_encoder. Non-destructible (the lone-surrogate buffering lives in the -// held TextEncoderStreamEncoder cell, not here). +// JSTextEncoderStream — the TextEncoderStream instance cell. A JSTransformStream +// subclass whose transform/flush arms drive the Rust TextEncoderStreamEncoder +// (m_encoder) directly. #pragma once #include "root.h" #include "StreamsForward.h" #include "JSDOMGlobalObject.h" +#include "JSTransformStream.h" #include "StreamConstructor.h" -#include namespace WebCore { -class JSTextEncoderStream final : public JSC::JSNonFinalObject { +class JSTextEncoderStream final : public JSTransformStream { public: - using Base = JSC::JSNonFinalObject; + using Base = JSTransformStream; static constexpr unsigned StructureFlags = Base::StructureFlags; - static constexpr JSC::DestructionMode needsDestruction = JSC::DoesNotNeedDestruction; static JSTextEncoderStream* create(JSC::VM&, JSC::Structure*); @@ -27,9 +25,6 @@ class JSTextEncoderStream final : public JSC::JSNonFinalObject { static JSC::Structure* createStructure(JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue prototype); DECLARE_INFO; - // visitChildrenImpl MUST visit: m_transform, m_encoder. - DECLARE_VISIT_CHILDREN; - static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) @@ -40,15 +35,13 @@ class JSTextEncoderStream final : public JSC::JSNonFinalObject { } static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM&); - // the inner TransformStream (created by createTransformStream with - // TransformerKind::TextEncoder and `this` as the algorithm context). - JSC::WriteBarrier m_transform; - // the existing native TextEncoderStreamEncoder cell (owns the lone-surrogate buffering). - JSC::WriteBarrier m_encoder; + // the Rust TextEncoderStreamEncoder (lone-surrogate state + reusable scratch). Freed + // eagerly at ClearAlgorithms; a vm.heap.addFinalizer registered in the constructor is + // the idempotent fallback for an abandoned stream. + void* m_encoder { nullptr }; private: JSTextEncoderStream(JSC::VM&, JSC::Structure*); - void finishCreation(JSC::VM&); }; using JSTextEncoderStreamConstructor = JSStreamConstructor; diff --git a/src/jsc/bindings/webcore/streams/JSTransformStream.cpp b/src/jsc/bindings/webcore/streams/JSTransformStream.cpp index b54538631b4c..df1d3a574c76 100644 --- a/src/jsc/bindings/webcore/streams/JSTransformStream.cpp +++ b/src/jsc/bindings/webcore/streams/JSTransformStream.cpp @@ -282,6 +282,9 @@ void JSTransformStream::visitChildrenImpl(JSCell* cell, Visitor& visitor) visitor.appendHidden(thisObject->m_controller); visitor.appendHidden(thisObject->m_backpressureChangePromise); visitor.appendHidden(thisObject->m_pendingWriteChunk); + visitor.appendHidden(thisObject->m_nativeSinkCell); + visitor.appendHidden(thisObject->m_nativeSinkReadyPromise); + visitor.appendHidden(thisObject->m_asyncCodecPromise); } void JSTransformStream::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) @@ -294,6 +297,9 @@ void JSTransformStream::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_controller, "controller"_s); analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_backpressureChangePromise, "backpressureChangePromise"_s); analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_pendingWriteChunk, "pendingWriteChunk"_s); + analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_nativeSinkCell, "nativeSinkCell"_s); + analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_nativeSinkReadyPromise, "nativeSinkReadyPromise"_s); + analyzeBarrierEdge(vm, analyzer, cell, thisObject->m_asyncCodecPromise, "asyncCodecPromise"_s); } // Prototype host functions @@ -308,11 +314,23 @@ JSC_DEFINE_CUSTOM_GETTER(jsTransformStreamPrototypeGetter_constructor, (JSGlobal return JSValue::encode(JSTransformStream::getConstructor(vm, prototype->globalObject())); } +// Web IDL brand check: exact classInfo match, not a chain walk, so the native +// C++ subclasses (JSCompressionStream etc.) are rejected like Chrome/Node do. +static ALWAYS_INLINE JSTransformStream* toTransformStreamExact(JSValue thisValue) +{ + if (!thisValue.isCell()) [[unlikely]] + return nullptr; + auto* cell = thisValue.asCell(); + if (cell->classInfo() != JSTransformStream::info()) [[unlikely]] + return nullptr; + return static_cast(cell); +} + JSC_DEFINE_CUSTOM_GETTER(jsTransformStreamPrototypeGetter_readable, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - auto* stream = dynamicDowncast(JSValue::decode(thisValue)); + auto* stream = toTransformStreamExact(JSValue::decode(thisValue)); if (!stream) [[unlikely]] return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "TransformStream"_s); return JSValue::encode(stream->m_readable.get()); @@ -322,7 +340,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsTransformStreamPrototypeGetter_writable, (JSGlobalObj { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - auto* stream = dynamicDowncast(JSValue::decode(thisValue)); + auto* stream = toTransformStreamExact(JSValue::decode(thisValue)); if (!stream) [[unlikely]] return Bun::ERR::INVALID_THIS(scope, lexicalGlobalObject, "TransformStream"_s); return JSValue::encode(stream->m_writable.get()); diff --git a/src/jsc/bindings/webcore/streams/JSTransformStream.h b/src/jsc/bindings/webcore/streams/JSTransformStream.h index 09a0fd21b0e3..cd11ee34ef78 100644 --- a/src/jsc/bindings/webcore/streams/JSTransformStream.h +++ b/src/jsc/bindings/webcore/streams/JSTransformStream.h @@ -1,4 +1,7 @@ -// JSTransformStream — the TransformStream instance cell. Non-destructible. +// JSTransformStream — the TransformStream instance cell, and the C++ base of the +// native TransformerKind specializations (JSCompressionStream, JSDecompressionStream, +// JSTextEncoderStream, JSTextDecoderStream). The subclasses free their native state +// eagerly at ClearAlgorithms with vm.heap.addFinalizer as a fallback; no destroy(). #pragma once #include "root.h" @@ -6,18 +9,16 @@ #include "JSDOMGlobalObject.h" #include "StreamConstructor.h" -#include #include namespace WebCore { -class JSTransformStream final : public JSC::JSNonFinalObject { +class JSTransformStream : public JSC::JSNonFinalObject { public: using Base = JSC::JSNonFinalObject; static constexpr unsigned StructureFlags = Base::StructureFlags; - static constexpr JSC::DestructionMode needsDestruction = JSC::DoesNotNeedDestruction; - // Internal (non-user) allocation entry point (createTransformStream). + // Internal (non-user) allocation entry point (setUpNativeTransformStream). static JSTransformStream* create(JSC::VM&, JSC::Structure*); static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); @@ -26,8 +27,7 @@ class JSTransformStream final : public JSC::JSNonFinalObject { static JSC::Structure* createStructure(JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue prototype); DECLARE_INFO; - // visitChildrenImpl MUST visit: m_readable, m_writable, m_controller, - // m_backpressureChangePromise, m_pendingWriteChunk. + // visitChildrenImpl MUST visit every WriteBarrier field below. DECLARE_VISIT_CHILDREN; static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); @@ -55,8 +55,31 @@ class JSTransformStream final : public JSC::JSNonFinalObject { bool m_backpressure : 1 { false }; // [[Detached]] (transferable streams are not implemented; the slot exists) bool m_detached : 1 { false }; + // Native transform/flush arm is on the stack (coder pointer live); a re-entrant + // ClearAlgorithms defers the eager free to the arm's epilogue instead. + bool m_nativeStateInUse : 1 { false }; + bool m_nativeStateReleasePending : 1 { false }; + // An off-thread codec task holds the coder; ClearAlgorithms / runNativeArm must defer + // the free until the task's JS-thread completion clears this. + bool m_asyncCodecInFlight : 1 { false }; -private: + // Native byte-producing subclasses only: when `readStreamIntoSink` attaches a + // native JSSink controller to this transform, the transform arms write coder + // output straight to `m_nativeSinkPtr` via the Rust SinkHandle dispatcher + // (Bun__NativeTransformSink__writeBytes) instead of wrapping it in a + // JSUint8Array and enqueueing on the readable. + // `m_nativeSinkReadyPromise` is the transform-algorithm result returned on + // sink backpressure; the sink's onReady resolves it. + JSC::WriteBarrier m_nativeSinkCell; + JSC::WriteBarrier m_nativeSinkReadyPromise; + // Pending transform-algorithm promise for the off-thread codec step; the + // WorkTask's single `Strong` roots this cell and this barrier keeps the + // promise alive until deliverAsync settles it. + JSC::WriteBarrier m_asyncCodecPromise; + void* m_nativeSinkPtr { nullptr }; + uint8_t m_nativeSinkId { 0 }; + +protected: JSTransformStream(JSC::VM&, JSC::Structure*); void finishCreation(JSC::VM&); }; diff --git a/src/jsc/bindings/webcore/streams/JSTransformStreamDefaultController.cpp b/src/jsc/bindings/webcore/streams/JSTransformStreamDefaultController.cpp index f7d76a5e2aa3..000f08aa15e3 100644 --- a/src/jsc/bindings/webcore/streams/JSTransformStreamDefaultController.cpp +++ b/src/jsc/bindings/webcore/streams/JSTransformStreamDefaultController.cpp @@ -6,6 +6,8 @@ #include "JSDOMExceptionHandling.h" #include "JSDOMGlobalObjectInlines.h" #include "JSDOMWrapperCache.h" +#include "JSCompressionStream.h" +#include "JSDecompressionStream.h" #include "JSReadableStream.h" #include "JSReadableStreamDefaultController.h" #include "JSStreamsRuntime.h" @@ -102,9 +104,13 @@ static JSPromise* performTransformAlgorithm(JSC::VM& vm, JSGlobalObject* globalO case TransformerKind::Identity: break; case TransformerKind::TextEncoder: - RELEASE_AND_RETURN(scope, textEncoderStreamTransform(globalObject, uncheckedDowncast(controller->m_algorithmContext.get()), controller, chunk)); + RELEASE_AND_RETURN(scope, runNativeArm(controller->m_algorithmContext.get(), [&](auto* s) { return textEncoderStreamTransform(globalObject, s, controller, chunk); })); case TransformerKind::TextDecoder: - RELEASE_AND_RETURN(scope, textDecoderStreamTransform(globalObject, uncheckedDowncast(controller->m_algorithmContext.get()), controller, chunk)); + RELEASE_AND_RETURN(scope, runNativeArm(controller->m_algorithmContext.get(), [&](auto* s) { return textDecoderStreamTransform(globalObject, s, controller, chunk); })); + case TransformerKind::Compression: + RELEASE_AND_RETURN(scope, runNativeArm(controller->m_algorithmContext.get(), [&](auto* s) { return compressionStreamTransform(globalObject, s, controller, chunk); })); + case TransformerKind::Decompression: + RELEASE_AND_RETURN(scope, runNativeArm(controller->m_algorithmContext.get(), [&](auto* s) { return decompressionStreamTransform(globalObject, s, controller, chunk); })); } RELEASE_AND_RETURN(scope, defaultTransformAlgorithm(vm, globalObject, controller, chunk)); } @@ -373,8 +379,47 @@ namespace WebStreams { using namespace JSC; using namespace WebCore; +extern "C" void CompressionStreamCoder__destroy(void*); +extern "C" void TextEncoderStreamEncoder__destroyForStream(void*); +extern "C" void TextDecoder__destroyForStream(void*); + +// Drop the native coder/decoder now rather than waiting for the cell's finalizer: a +// CompressionStream's zlib/brotli/zstd state is hundreds of KB of window buffer, and +// finalizers run late (main-thread, typically only at full GC), so relying on them for +// this in a hot pipe loop lets native memory grow unbounded. The finalizer stays as an +// idempotent fallback for an abandoned stream that never reaches a terminal. +void nativeTransformReleaseState(JSTransformStream* stream) +{ + stream->m_nativeStateReleasePending = false; + if (auto* s = dynamicDowncast(stream)) + CompressionStreamCoder__destroy(std::exchange(s->m_coder, nullptr)); + else if (auto* s = dynamicDowncast(stream)) + CompressionStreamCoder__destroy(std::exchange(s->m_coder, nullptr)); + else if (auto* s = dynamicDowncast(stream)) + TextEncoderStreamEncoder__destroyForStream(std::exchange(s->m_encoder, nullptr)); + else if (auto* s = dynamicDowncast(stream)) + TextDecoder__destroyForStream(std::exchange(s->m_decoder, nullptr)); +} + +// ClearAlgorithms is the one shared terminal (post-flush, error, cancel), but a +// re-entrant reader.cancel() from user JS inside a native arm's chunk coercion reaches +// it while the coder is still in use on the stack. Defer when m_nativeStateInUse; the +// runNativeArm epilogue frees it once control unwinds. +static void nativeTransformReleaseStateOrDefer(JSTransformStreamDefaultController* controller) +{ + auto* stream = dynamicDowncast(controller->m_algorithmContext.get()); + if (!stream) + return; + if (stream->m_nativeStateInUse || stream->m_asyncCodecInFlight) { + stream->m_nativeStateReleasePending = true; + return; + } + nativeTransformReleaseState(stream); +} + void transformStreamDefaultControllerClearAlgorithms(JSTransformStreamDefaultController* controller) { + nativeTransformReleaseStateOrDefer(controller); controller->m_transformerKind = TransformerKind::Identity; controller->m_transformer.clear(); controller->m_transformMethod.clear(); diff --git a/src/jsc/bindings/webcore/streams/StreamsForward.h b/src/jsc/bindings/webcore/streams/StreamsForward.h index 6cb6b69e145e..9c79dcd492d0 100644 --- a/src/jsc/bindings/webcore/streams/StreamsForward.h +++ b/src/jsc/bindings/webcore/streams/StreamsForward.h @@ -78,6 +78,8 @@ class JSAsyncIteratorSourceOperation; class JSReadStreamIntoSinkOperation; class JSTextEncoderStream; class JSTextDecoderStream; +class JSCompressionStream; +class JSDecompressionStream; } // namespace WebCore @@ -135,6 +137,18 @@ enum class TransformerKind : uint8_t { Identity, // new TransformStream() with no `transform` member: enqueue the chunk unchanged TextEncoder, // TextEncoderStream (context = the JSTextEncoderStream cell) TextDecoder, // TextDecoderStream (context = the JSTextDecoderStream cell) + Compression, // CompressionStream (context = the JSCompressionStream cell) + Decompression, // DecompressionStream (context = the JSDecompressionStream cell) +}; + +// CompressionStream / DecompressionStream format. Matches the `Format` enum in +// CompressionStreamCoder.rs. +enum class CompressionFormat : uint8_t { + Deflate = 0, + DeflateRaw = 1, + Gzip = 2, + Brotli = 3, + Zstd = 4, }; // JSReadableStream Bun-mode members diff --git a/src/jsc/bindings/webcore/streams/TransformStreamOperations.cpp b/src/jsc/bindings/webcore/streams/TransformStreamOperations.cpp index c3e1b45e5939..862e4f8e6936 100644 --- a/src/jsc/bindings/webcore/streams/TransformStreamOperations.cpp +++ b/src/jsc/bindings/webcore/streams/TransformStreamOperations.cpp @@ -1,6 +1,8 @@ #include "config.h" #include "WebStreamsInternals.h" +#include "JSCompressionStream.h" +#include "JSDecompressionStream.h" #include "JSDOMBinding.h" #include "JSDOMGlobalObject.h" #include "JSDOMWrapperCache.h" @@ -76,9 +78,13 @@ static JSPromise* performFlushAlgorithm(JSC::VM& vm, JSGlobalObject* globalObjec case TransformerKind::Identity: break; case TransformerKind::TextEncoder: - RELEASE_AND_RETURN(scope, textEncoderStreamFlush(globalObject, uncheckedDowncast(controller->m_algorithmContext.get()), controller)); + RELEASE_AND_RETURN(scope, runNativeArm(controller->m_algorithmContext.get(), [&](auto* s) { return textEncoderStreamFlush(globalObject, s, controller); })); case TransformerKind::TextDecoder: - RELEASE_AND_RETURN(scope, textDecoderStreamFlush(globalObject, uncheckedDowncast(controller->m_algorithmContext.get()), controller)); + RELEASE_AND_RETURN(scope, runNativeArm(controller->m_algorithmContext.get(), [&](auto* s) { return textDecoderStreamFlush(globalObject, s, controller); })); + case TransformerKind::Compression: + RELEASE_AND_RETURN(scope, runNativeArm(controller->m_algorithmContext.get(), [&](auto* s) { return compressionStreamFlush(globalObject, s, controller); })); + case TransformerKind::Decompression: + RELEASE_AND_RETURN(scope, runNativeArm(controller->m_algorithmContext.get(), [&](auto* s) { return decompressionStreamFlush(globalObject, s, controller); })); } RELEASE_AND_RETURN(scope, promiseFulfilledWith(globalObject, JSC::jsUndefined())); } @@ -98,29 +104,25 @@ static JSPromise* performCancelAlgorithm(JSC::VM& vm, JSGlobalObject* globalObje RELEASE_AND_RETURN(scope, promiseFulfilledWith(globalObject, JSC::jsUndefined())); } -JSTransformStream* createTransformStream(JSGlobalObject* globalObject, TransformerKind kind, JSCell* algorithmContext, double writableHighWaterMark, JSObject* writableSizeAlgorithm, double readableHighWaterMark, JSObject* readableSizeAlgorithm) +void setUpNativeTransformStream(JSGlobalObject* globalObject, JSTransformStream* stream, TransformerKind kind) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - ASSERT(writableHighWaterMark >= 0); - ASSERT(readableHighWaterMark >= 0); auto* domGlobalObject = defaultGlobalObject(globalObject); - auto* stream = JSTransformStream::create(vm, WebCore::getDOMStructure(vm, *domGlobalObject)); auto* startPromise = JSPromise::create(vm, globalObject->promiseStructure()); - initializeTransformStream(globalObject, stream, startPromise, writableHighWaterMark, writableSizeAlgorithm, readableHighWaterMark, readableSizeAlgorithm); - RETURN_IF_EXCEPTION(scope, nullptr); + // readableHighWaterMark = 1 (not the spec's 0) so the first write completes without a + // reader attached, matching Node.js and Chromium. + initializeTransformStream(globalObject, stream, startPromise, 1, nullptr, 1, nullptr); + RETURN_IF_EXCEPTION(scope, void()); auto* controller = JSTransformStreamDefaultController::create(vm, WebCore::getDOMStructure(vm, *domGlobalObject)); controller->m_transformerKind = kind; - if (algorithmContext) - controller->m_algorithmContext.set(vm, controller, algorithmContext); + controller->m_algorithmContext.set(vm, controller, stream); setUpTransformStreamDefaultController(vm, stream, controller); - // The internal kinds' start algorithm is trivial. resolvePromise(globalObject, startPromise, jsUndefined()); - RETURN_IF_EXCEPTION(scope, nullptr); - return stream; + scope.assertNoException(); } void initializeTransformStream(JSGlobalObject* globalObject, JSTransformStream* stream, JSPromise* startPromise, double writableHighWaterMark, JSObject* writableSizeAlgorithm, double readableHighWaterMark, JSObject* readableSizeAlgorithm) diff --git a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h index e3779105cc23..99b56429794b 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h +++ b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h @@ -439,8 +439,10 @@ void writableStreamDefaultControllerWrite(JSC::JSGlobalObject*, JSWritableStream // TransformStreamOperations.cpp -// The internal-creation parallel of createReadableStream. -JSTransformStream* createTransformStream(JSC::JSGlobalObject*, TransformerKind, JSC::JSCell* algorithmContext, double writableHighWaterMark = 1, JSC::JSObject* writableSizeAlgorithm = nullptr, double readableHighWaterMark = 0, JSC::JSObject* readableSizeAlgorithm = nullptr); // userJS: yes — TransformStreamOperations.cpp +// Initializes an already-allocated JSTransformStream SUBCLASS as a TransformStream with the +// given native transformer kind (writable/readable HWM = 1/1, no size algorithms, trivial +// start). The controller's algorithmContext is `stream` itself. +void setUpNativeTransformStream(JSC::JSGlobalObject*, JSTransformStream*, TransformerKind); // userJS: yes — TransformStreamOperations.cpp void initializeTransformStream(JSC::JSGlobalObject*, JSTransformStream*, JSC::JSPromise* startPromise, double writableHighWaterMark, JSC::JSObject* writableSizeAlgorithm, double readableHighWaterMark, JSC::JSObject* readableSizeAlgorithm); // userJS: yes — TransformStreamOperations.cpp void transformStreamError(JSC::JSGlobalObject*, JSTransformStream*, JSC::JSValue error); // userJS: yes — TransformStreamOperations.cpp void transformStreamErrorWritableAndUnblockWrite(JSC::JSGlobalObject*, JSTransformStream*, JSC::JSValue error); // userJS: yes — TransformStreamOperations.cpp @@ -460,6 +462,30 @@ void transformStreamDefaultControllerClearAlgorithms(JSTransformStreamDefaultCon // A sanctioned takeAbruptCompletion catch site (catches the readable-side enqueue's abrupt // completion, errors the writable, then throws stream.[[readable]].[[storedError]]). void transformStreamDefaultControllerEnqueue(JSC::JSGlobalObject*, JSTransformStreamDefaultController*, JSC::JSValue chunk); // userJS: yes; throws — JSTransformStreamDefaultController.cpp +void nativeTransformReleaseState(JSTransformStream*); // userJS: no — JSTransformStreamDefaultController.cpp + +// Rust-side single dispatch for the native-transform → native-JSSink byte write, routed +// through SinkHandle::write (src/runtime/webcore/Sink.rs). Returns a negative number for +// native ByteStream/FileReader sources under backpressure, but a pending JSPromise for the +// JSController-sourced sinks that readStreamIntoSink attaches (HTTPResponseSink/FileSink). +// Both mean "suspend" — nativeSinkWriteIsBackpressure below reads either shape. +extern "C" JSC::EncodedJSValue Bun__NativeTransformSink__writeBytes(uint8_t sinkId, void* sinkPtr, JSC::JSGlobalObject*, const uint8_t* ptr, size_t len); // userJS: no — Sink.rs +bool nativeSinkWriteIsBackpressure(JSC::VM&, JSC::JSValue wrote); // userJS: no — WebStreamsMisc.cpp + +// Brackets a native transform/flush arm so a re-entrant ClearAlgorithms (reached via a +// reader.cancel() inside the arm's chunk coercion) defers nativeTransformReleaseState +// until control unwinds back here. +template +JSC::JSPromise* runNativeArm(JSC::JSCell* context, Arm&& arm) +{ + auto* stream = uncheckedDowncast(context); + stream->m_nativeStateInUse = true; + JSC::JSPromise* result = arm(stream); + stream->m_nativeStateInUse = false; + if (stream->m_nativeStateReleasePending && !stream->m_asyncCodecInFlight) [[unlikely]] + nativeTransformReleaseState(stream); + return result; +} void transformStreamDefaultControllerError(JSC::JSGlobalObject*, JSTransformStreamDefaultController*, JSC::JSValue error); // userJS: yes — JSTransformStreamDefaultController.cpp JSC::JSPromise* transformStreamDefaultControllerPerformTransform(JSC::JSGlobalObject*, JSTransformStreamDefaultController*, JSC::JSValue chunk); // userJS: yes (user transform) — JSTransformStreamDefaultController.cpp void transformStreamDefaultControllerTerminate(JSC::JSGlobalObject*, JSTransformStreamDefaultController*); // userJS: yes — JSTransformStreamDefaultController.cpp @@ -478,6 +504,14 @@ JSC::JSPromise* textEncoderStreamFlush(JSC::JSGlobalObject*, JSTextEncoderStream JSC::JSPromise* textDecoderStreamTransform(JSC::JSGlobalObject*, JSTextDecoderStream*, JSTransformStreamDefaultController*, JSC::JSValue chunk); // userJS: yes — JSTextDecoderStream.cpp JSC::JSPromise* textDecoderStreamFlush(JSC::JSGlobalObject*, JSTextDecoderStream*, JSTransformStreamDefaultController*); // userJS: yes — JSTextDecoderStream.cpp +// JSCompressionStreamShared.cpp — the TransformerKind::Compression / ::Decompression algorithm +// ARMS. Same dispatch/bridge relationship as the TextEncoder/TextDecoder arms above. + +JSC::JSPromise* compressionStreamTransform(JSC::JSGlobalObject*, JSCompressionStream*, JSTransformStreamDefaultController*, JSC::JSValue chunk); // userJS: yes — JSCompressionStreamShared.cpp +JSC::JSPromise* compressionStreamFlush(JSC::JSGlobalObject*, JSCompressionStream*, JSTransformStreamDefaultController*); // userJS: yes — JSCompressionStreamShared.cpp +JSC::JSPromise* decompressionStreamTransform(JSC::JSGlobalObject*, JSDecompressionStream*, JSTransformStreamDefaultController*, JSC::JSValue chunk); // userJS: yes — JSCompressionStreamShared.cpp +JSC::JSPromise* decompressionStreamFlush(JSC::JSGlobalObject*, JSDecompressionStream*, JSTransformStreamDefaultController*); // userJS: yes — JSCompressionStreamShared.cpp + // CrossRealmTransform.cpp — transferable streams are NOT implemented. These signatures are // FROZEN, but the .cpp may be a stub whose entry points assert / throw; the per-class // transfer / transfer-receiving steps have no declarations here. diff --git a/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp b/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp index 150d9f99bc92..3d99bc0fab03 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp +++ b/src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp @@ -139,6 +139,17 @@ JSC::JSString* streamingUTF8Decode(JSGlobalObject* globalObject, std::span(wrote)) { + markPromiseAsHandled(vm, p); + return p->status() == JSC::JSPromise::Status::Pending; + } + return false; +} + // spec IsNonNegativeNumber(v). Non-throwing leaf: pure type + range test, no coercion. bool isNonNegativeNumber(JSValue value) { diff --git a/src/jsc/generated_classes_list.rs b/src/jsc/generated_classes_list.rs index 3ab3461f1b36..0a3be533a841 100644 --- a/src/jsc/generated_classes_list.rs +++ b/src/jsc/generated_classes_list.rs @@ -104,7 +104,6 @@ pub mod Classes { pub(crate) use crate::webcore::byte_stream::Source as BytesInternalReadableStreamSource; pub use crate::webcore::crypto::Crypto; pub(crate) use crate::webcore::file_reader::Source as FileInternalReadableStreamSource; - pub use crate::webcore::text_encoder_stream_encoder::TextEncoderStreamEncoder; pub use Bundler as JSBundler; pub use Transpiler as JSTranspiler; pub use bun_jsc::BuildMessage; diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 3eb32122dcae..7a9f2bb1618d 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -504,6 +504,9 @@ pub(crate) fn run_task( task_tag::NativeZlib => compression_arm!(NativeZlib), task_tag::NativeBrotli => compression_arm!(NativeBrotli), task_tag::NativeZstd => compression_arm!(NativeZstd), + task_tag::CompressionStreamCoderTask => { + run_then_destroy!(work crate::webcore::compression_stream_coder::CompressionStreamCoderTask) + } // ── process / signals ──────────────────────────────────────────── task_tag::ProcessWaiterThreadTask => { @@ -687,7 +690,7 @@ fn run_task_cold(task: Task) { /// Compile-time guard that the arm count above tracks /// `bun_event_loop::task_tag::COUNT`. Bump when adding a variant. const _: () = assert!( - task_tag::COUNT == 110, + task_tag::COUNT == 111, "dispatch::run_task arm count out of sync with bun_event_loop::task_tag", ); diff --git a/src/runtime/webcore.rs b/src/runtime/webcore.rs index 665fa9b6f6cd..9aff312bc5b3 100644 --- a/src/runtime/webcore.rs +++ b/src/runtime/webcore.rs @@ -13,6 +13,8 @@ pub mod bake_response; pub mod byte_blob_loader; #[path = "webcore/ByteStream.rs"] pub mod byte_stream; +#[path = "webcore/CompressionStreamCoder.rs"] +pub mod compression_stream_coder; #[path = "webcore/CookieMap.rs"] pub mod cookie_map; #[path = "webcore/Crypto.rs"] @@ -364,6 +366,10 @@ pub enum SinkHandle { S3Upload(bun_ptr::BackRef), FileSink(bun_ptr::BackRef), HTMLRewriter(bun_ptr::BackRef), + HttpResponse(bun_ptr::BackRef), + HttpsResponse(bun_ptr::BackRef), + H3Response(bun_ptr::BackRef), + ArrayBuffer(bun_ptr::BackRef), } impl SinkHandle { @@ -389,6 +395,14 @@ impl SinkHandle { SinkHandle::S3Upload(mut p) => unsafe { p.get_mut() }.write(data), SinkHandle::FileSink(p) => p.write(data), SinkHandle::HTMLRewriter(p) => p.write(data), + // SAFETY: live backref; transform detaches before the JSSink is finalized. + SinkHandle::HttpResponse(mut p) => unsafe { p.get_mut() }.write(data), + // SAFETY: live backref; transform detaches before the JSSink is finalized. + SinkHandle::HttpsResponse(mut p) => unsafe { p.get_mut() }.write(data), + // SAFETY: live backref; transform detaches before the JSSink is finalized. + SinkHandle::H3Response(mut p) => unsafe { p.get_mut() }.write(data), + // SAFETY: live backref; transform detaches before the JSSink is finalized. + SinkHandle::ArrayBuffer(mut p) => unsafe { p.get_mut() }.write(data), } } @@ -405,6 +419,10 @@ impl SinkHandle { SinkHandle::S3Upload(p) => streams::NetworkSink::end_from_stream(p.as_ptr(), err), SinkHandle::FileSink(p) => p.end_from_stream(err), SinkHandle::HTMLRewriter(p) => p.end_from_stream(err), + SinkHandle::HttpResponse(_) => {} + SinkHandle::HttpsResponse(_) => {} + SinkHandle::H3Response(_) => {} + SinkHandle::ArrayBuffer(_) => {} } } } diff --git a/src/runtime/webcore/CompressionStreamCoder.rs b/src/runtime/webcore/CompressionStreamCoder.rs new file mode 100644 index 000000000000..450491bb5840 --- /dev/null +++ b/src/runtime/webcore/CompressionStreamCoder.rs @@ -0,0 +1,875 @@ +//! Native backing for `CompressionStream` / `DecompressionStream`. +//! +//! The WHATWG Compression Streams spec defines five formats (`deflate`, +//! `deflate-raw`, `gzip`, plus Bun's `brotli` / `zstd` extensions), each a +//! `TransformStream` whose transform step feeds bytes into a codec and +//! enqueues whatever comes out. The C++ `JSCompressionStream` / +//! `JSDecompressionStream` cells own one `CompressionStreamCoder` via a +//! `void*` and drive it per chunk through the three `extern "C"` fns at the +//! bottom of this file; the TransformStream machinery already handles +//! backpressure, so this layer is a pure `bytes in → bytes out` pump. + +use core::ffi::c_int; +use core::ptr::{self, NonNull}; + +use bun_jsc::ZigStringJsc as _; +use bun_jsc::work_task::{WorkTask, WorkTaskContext}; +use bun_jsc::zig_string::ZigString as JscZigString; +use bun_jsc::{ErrorCode, JSGlobalObject, JSUint8Array, JSValue, JsTerminated, Strong}; + +use bun_brotli::c as brotli; +use bun_zlib as zlib; +use bun_zstd::c as zstd; + +/// Matches `Bun::WebStreams::CompressionFormat` in `StreamsForward.h`. +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq)] +enum Format { + Deflate = 0, + DeflateRaw = 1, + Gzip = 2, + Brotli = 3, + Zstd = 4, +} + +impl Format { + fn from_u8(n: u8) -> Option { + Some(match n { + 0 => Self::Deflate, + 1 => Self::DeflateRaw, + 2 => Self::Gzip, + 3 => Self::Brotli, + 4 => Self::Zstd, + _ => return None, + }) + } + + fn window_bits(self) -> c_int { + match self { + Self::Deflate => zlib::MAX_WBITS, + Self::DeflateRaw => -zlib::MAX_WBITS, + Self::Gzip => zlib::MAX_WBITS + 16, + Self::Brotli | Self::Zstd => unreachable!(), + } + } +} + +const CHUNK: usize = 16 * 1024; + +enum Backend { + Deflate(Box), + Inflate { + state: Box, + /// Gzip only: after the first member ends, any further bytes must be + /// another gzip member (RFC 1952 §2.2) — the decoder resets and + /// continues. Deflate/deflate-raw have no such concatenation, so + /// leftover input is the spec's "trailing junk" TypeError. + gzip: bool, + }, + BrotliEncode(NonNull), + BrotliDecode(NonNull), + ZstdEncode(NonNull), + ZstdDecode(NonNull), +} + +pub struct CompressionStreamCoder { + backend: Backend, + /// DecompressionStream only: the codec has reported end-of-stream. Any + /// further input is the spec's "trailing junk" TypeError. + ended: bool, + /// Zstd decode only: bytes of a split frame magic carried across chunks. + /// A zstd stream is one or more concatenated frames (RFC 8878 §3.1), but + /// anything else after a completed frame is trailing junk; when a chunk + /// ends with <4 bytes after frame-complete we cannot tell which yet. + zstd_head: [u8; 4], + zstd_head_len: u8, + /// Output buffer for `transform`. Reused across chunks; `transform` clears + /// it on entry. + out: Vec, +} + +// SAFETY: the z_stream / Brotli*Instance / ZSTD_*Ctx handles are single-owner +// heap state with no thread affinity; TransformStream serializes writes so at +// most one chunk is ever in flight per coder. +unsafe impl Send for CompressionStreamCoder {} + +impl Drop for CompressionStreamCoder { + fn drop(&mut self) { + // SAFETY: each pointer was created by the matching `*_create`/`*Init` + // below and has not been freed (the field is consumed exactly once, + // here). + unsafe { + match &mut self.backend { + Backend::Deflate(s) => { + zlib::deflateEnd(&raw mut **s); + } + Backend::Inflate { state, .. } => { + zlib::inflateEnd(&raw mut **state); + } + Backend::BrotliEncode(p) => brotli::BrotliEncoderDestroyInstance(p.as_ptr()), + Backend::BrotliDecode(p) => brotli::BrotliDecoderDestroyInstance(p.as_ptr()), + Backend::ZstdEncode(p) => { + zstd::ZSTD_freeCCtx(p.as_ptr()); + } + Backend::ZstdDecode(p) => { + zstd::ZSTD_freeDCtx(p.as_ptr()); + } + } + } + } +} + +#[derive(Clone, Copy)] +enum CodecError { + TrailingJunk, + Message(&'static str), + /// Brotli decoder error; `BrotliDecoderErrorString` (static C string). + /// Surfaced as TypeError with `.code = "ERR_" + ` for node:zlib compat. + Brotli(&'static str), +} + +impl CompressionStreamCoder { + fn new(format: Format, decompress: bool) -> Result, CodecError> { + let backend = match (format, decompress) { + (Format::Deflate | Format::DeflateRaw | Format::Gzip, false) => { + let mut s = Box::new(bun_core::ffi::zeroed::()); + // Spec: "default compression level". Z_DEFAULT_COMPRESSION = -1. + // SAFETY: `s` is a zeroed, #[repr(C)] z_stream; zlibVersion() is + // a static C string. + let rc = unsafe { + zlib::deflateInit2_( + &raw mut *s, + -1, + 8, // Z_DEFLATED + format.window_bits(), + 8, // default mem_level + 0, // Z_DEFAULT_STRATEGY + zlib::zlibVersion().cast(), + core::mem::size_of::() as c_int, + ) + }; + if rc != zlib::ReturnCode::Ok { + return Err(CodecError::Message("failed to initialize deflate")); + } + Backend::Deflate(s) + } + (Format::Deflate | Format::DeflateRaw | Format::Gzip, true) => { + let mut s = Box::new(bun_core::ffi::zeroed::()); + // SAFETY: as above. + let rc = unsafe { + zlib::inflateInit2_( + &raw mut *s, + format.window_bits(), + zlib::zlibVersion().cast(), + core::mem::size_of::() as c_int, + ) + }; + if rc != zlib::ReturnCode::Ok { + return Err(CodecError::Message("failed to initialize inflate")); + } + Backend::Inflate { + state: s, + gzip: format == Format::Gzip, + } + } + (Format::Brotli, false) => { + // SAFETY: FFI — the default-allocator instance (all nulls). + let p = NonNull::new(unsafe { + brotli::BrotliEncoderCreateInstance(None, None, ptr::null_mut()) + }) + .ok_or(CodecError::Message("failed to initialize brotli encoder"))?; + Backend::BrotliEncode(p) + } + (Format::Brotli, true) => { + // SAFETY: FFI — the default-allocator instance (all nulls). + let p = NonNull::new(unsafe { + brotli::BrotliDecoderCreateInstance(None, None, ptr::null_mut()) + }) + .ok_or(CodecError::Message("failed to initialize brotli decoder"))?; + Backend::BrotliDecode(p) + } + (Format::Zstd, false) => { + let p = NonNull::new(zstd::ZSTD_createCCtx()) + .ok_or(CodecError::Message("failed to initialize zstd encoder"))?; + Backend::ZstdEncode(p) + } + (Format::Zstd, true) => { + let p = NonNull::new(zstd::ZSTD_createDCtx()) + .ok_or(CodecError::Message("failed to initialize zstd decoder"))?; + Backend::ZstdDecode(p) + } + }; + Ok(Box::new(Self { + backend, + ended: false, + zstd_head: [0; 4], + zstd_head_len: 0, + out: Vec::new(), + })) + } + + const ZSTD_MAGIC: [u8; 4] = 0xFD2F_B528u32.to_le_bytes(); + const ZSTD_MAGIC_SKIPPABLE: [u8; 3] = [0x2A, 0x4D, 0x18]; + + /// True when `head` (1..=4 bytes) is a prefix of a zstd or skippable + /// frame magic. + fn is_zstd_frame_prefix(head: &[u8]) -> bool { + head.iter().zip(Self::ZSTD_MAGIC).all(|(a, b)| *a == b) + || ((head[0] & 0xF0) == 0x50 + && head[1..] + .iter() + .zip(Self::ZSTD_MAGIC_SKIPPABLE) + .all(|(a, b)| *a == b)) + } + + /// Feed one chunk (or the final empty flush) and collect every byte the + /// codec produces into `self.out` (cleared on entry). `finish` drives the + /// codec to completion and performs the "unexpected end of file" / + /// "trailing junk" checks. + fn transform(&mut self, input: &[u8], finish: bool) -> Result<(), CodecError> { + let out = &mut self.out; + out.clear(); + match &mut self.backend { + Backend::Deflate(s) => { + // `avail_in` is `uInt`; clamp and refill so a ≥4 GiB chunk + // isn't silently truncated by the `as u32` cast. + let mut remaining = input; + loop { + let take = remaining.len().min(u32::MAX as usize); + let tail = remaining.len() > take; + let flush = if finish && !tail { + zlib::FlushValue::Finish + } else { + zlib::FlushValue::NoFlush + }; + s.next_in = remaining.as_ptr(); + s.avail_in = take as u32; + out.reserve(CHUNK); + let spare = out.spare_capacity_mut(); + s.next_out = spare.as_mut_ptr().cast(); + s.avail_out = spare.len().min(u32::MAX as usize) as u32; + let before = s.avail_out; + // SAFETY: `s` was initialized by `deflateInit2_`; next_in/ + // avail_in borrow `remaining`, next_out/avail_out borrow + // the Vec's spare capacity for this one call. + let rc = unsafe { zlib::deflate(&raw mut **s, flush) }; + let written = (before - s.avail_out) as usize; + // SAFETY: deflate wrote exactly `written` bytes. + unsafe { out.set_len(out.len() + written) }; + let consumed = take - s.avail_in as usize; + remaining = &remaining[consumed..]; + match rc { + zlib::ReturnCode::Ok | zlib::ReturnCode::BufError => {} + zlib::ReturnCode::StreamEnd => break, + _ => return Err(CodecError::Message("deflate failed")), + } + if s.avail_out != 0 && remaining.is_empty() { + break; + } + } + } + Backend::Inflate { state: s, gzip } => { + let gzip = *gzip; + // `self.ended` = "the last inflate returned StreamEnd" = "at a + // member boundary". For gzip, a following chunk starts the next + // member; for deflate/deflate-raw it is trailing junk. + if self.ended && !input.is_empty() { + if !gzip { + return Err(CodecError::TrailingJunk); + } + // SAFETY: `s` is an initialized inflate stream. + if unsafe { zlib::inflateReset(&raw mut **s) } != zlib::ReturnCode::Ok { + return Err(CodecError::Message("inflate failed")); + } + self.ended = false; + } + if input.is_empty() && (self.ended || !finish) { + return Ok(()); + } + let mut remaining = input; + loop { + let take = remaining.len().min(u32::MAX as usize); + let tail = remaining.len() > take; + let flush = if finish && !tail { + zlib::FlushValue::Finish + } else { + zlib::FlushValue::NoFlush + }; + s.next_in = remaining.as_ptr(); + s.avail_in = take as u32; + out.reserve(CHUNK); + let spare = out.spare_capacity_mut(); + s.next_out = spare.as_mut_ptr().cast(); + s.avail_out = spare.len().min(u32::MAX as usize) as u32; + let before = s.avail_out; + // SAFETY: `s` was initialized by `inflateInit2_`; buffers + // as in the deflate arm. + let rc = unsafe { zlib::inflate(&raw mut **s, flush) }; + let written = (before - s.avail_out) as usize; + // SAFETY: inflate wrote exactly `written` bytes. + unsafe { out.set_len(out.len() + written) }; + let consumed = take - s.avail_in as usize; + remaining = &remaining[consumed..]; + match rc { + zlib::ReturnCode::Ok => {} + zlib::ReturnCode::BufError => { + if finish && !tail { + return Err(CodecError::Message("unexpected end of file")); + } + } + zlib::ReturnCode::StreamEnd => { + self.ended = true; + if !remaining.is_empty() { + if gzip { + // SAFETY: `s` is an initialized inflate stream. + if unsafe { zlib::inflateReset(&raw mut **s) } + != zlib::ReturnCode::Ok + { + return Err(CodecError::Message("inflate failed")); + } + self.ended = false; + continue; + } + return Err(CodecError::TrailingJunk); + } + break; + } + zlib::ReturnCode::NeedDict => { + return Err(CodecError::Message("Missing dictionary")); + } + _ => return Err(CodecError::Message("inflate failed")), + } + if s.avail_out != 0 && remaining.is_empty() { + if finish && !self.ended { + return Err(CodecError::Message("unexpected end of file")); + } + break; + } + } + } + Backend::BrotliEncode(p) => { + let op = if finish { + brotli::BrotliEncoderOperation::finish + } else { + brotli::BrotliEncoderOperation::process + }; + let mut next_in: *const u8 = input.as_ptr(); + let mut avail_in: usize = input.len(); + loop { + out.reserve(CHUNK); + let spare = out.spare_capacity_mut(); + let mut next_out: *mut u8 = spare.as_mut_ptr().cast(); + let mut avail_out: usize = spare.len(); + let before = avail_out; + // SAFETY: `p` is a live encoder; the four ptrs borrow the + // locals / spare for this one call. + let ok = unsafe { + brotli::BrotliEncoderCompressStream( + p.as_ptr(), + op, + &raw mut avail_in, + &raw mut next_in, + &raw mut avail_out, + &raw mut next_out, + ptr::null_mut(), + ) + }; + let written = before - avail_out; + // SAFETY: the encoder wrote exactly `written` bytes. + unsafe { out.set_len(out.len() + written) }; + if ok == 0 { + return Err(CodecError::Message("brotli encode failed")); + } + if avail_in == 0 && avail_out != 0 { + break; + } + } + } + Backend::BrotliDecode(p) => { + if self.ended { + if !input.is_empty() { + return Err(CodecError::TrailingJunk); + } + return Ok(()); + } + let mut next_in: *const u8 = input.as_ptr(); + let mut avail_in: usize = input.len(); + loop { + out.reserve(CHUNK); + let spare = out.spare_capacity_mut(); + let mut next_out: *mut u8 = spare.as_mut_ptr().cast(); + let mut avail_out: usize = spare.len(); + let before = avail_out; + // SAFETY: `p` is a live decoder; buffers as above. + let result = unsafe { + brotli::BrotliDecoderDecompressStream( + p.as_ptr(), + &raw mut avail_in, + &raw mut next_in, + &raw mut avail_out, + &raw mut next_out, + ptr::null_mut(), + ) + }; + let written = before - avail_out; + // SAFETY: the decoder wrote exactly `written` bytes. + unsafe { out.set_len(out.len() + written) }; + match result { + brotli::BrotliDecoderResult::success => { + self.ended = true; + if avail_in != 0 { + return Err(CodecError::TrailingJunk); + } + break; + } + brotli::BrotliDecoderResult::needs_more_input => { + if finish { + return Err(CodecError::Message("unexpected end of file")); + } + break; + } + brotli::BrotliDecoderResult::needs_more_output => continue, + brotli::BrotliDecoderResult::err => { + // SAFETY: `p` is a live decoder; the error string is a + // static C string owned by the brotli library. + let code = unsafe { + let ec = brotli::BrotliDecoderGetErrorCode(&*p.as_ptr()); + core::ffi::CStr::from_ptr(brotli::BrotliDecoderErrorString(ec)) + }; + return Err(CodecError::Brotli( + code.to_str().unwrap_or("brotli decode failed"), + )); + } + } + } + } + Backend::ZstdEncode(p) => { + // ZSTD_EndDirective: 0 = ZSTD_e_continue, 2 = ZSTD_e_end. + let end: core::ffi::c_uint = if finish { 2 } else { 0 }; + let mut input_buf = zstd::ZSTD_inBuffer { + src: input.as_ptr().cast(), + size: input.len(), + pos: 0, + }; + loop { + out.reserve(CHUNK); + let spare = out.spare_capacity_mut(); + let mut output_buf = zstd::ZSTD_outBuffer { + dst: spare.as_mut_ptr().cast(), + size: spare.len(), + pos: 0, + }; + // SAFETY: `p` is a live CCtx; the buffers borrow locals / + // spare for this one call. + let remaining = unsafe { + zstd::ZSTD_compressStream2( + p.as_ptr(), + &raw mut output_buf, + &raw mut input_buf, + end, + ) + }; + // SAFETY: compressStream2 wrote exactly `output_buf.pos` + // bytes. + unsafe { out.set_len(out.len() + output_buf.pos) }; + if zstd::ZSTD_isError(remaining) != 0 { + return Err(CodecError::Message("zstd encode failed")); + } + if input_buf.pos == input_buf.size && (!finish || remaining == 0) { + break; + } + } + } + Backend::ZstdDecode(p) => { + // A zstd stream is one or more concatenated frames (RFC 8878 + // §3.1, including skippable frames). After a frame completes, + // the next bytes must be another frame magic; a chunk boundary + // may fall inside that 4-byte magic, which `zstd_head` carries. + let joined; + let bytes: &[u8] = if self.zstd_head_len > 0 { + joined = [&self.zstd_head[..self.zstd_head_len as usize], input].concat(); + self.zstd_head_len = 0; + &joined + } else { + input + }; + let mut input_buf = zstd::ZSTD_inBuffer { + src: bytes.as_ptr().cast(), + size: bytes.len(), + pos: 0, + }; + loop { + if self.ended { + let rest = &bytes[input_buf.pos..]; + if rest.is_empty() { + break; + } + let head = &rest[..rest.len().min(4)]; + if !Self::is_zstd_frame_prefix(head) { + return Err(CodecError::TrailingJunk); + } + if head.len() < 4 { + if finish { + return Err(CodecError::TrailingJunk); + } + self.zstd_head[..head.len()].copy_from_slice(head); + self.zstd_head_len = head.len() as u8; + break; + } + // SAFETY: `p` is a live DCtx. + unsafe { + zstd::ZSTD_DCtx_reset( + p.as_ptr(), + zstd::ZSTD_reset_session_and_parameters, + ) + }; + self.ended = false; + } + out.reserve(CHUNK); + let spare = out.spare_capacity_mut(); + let mut output_buf = zstd::ZSTD_outBuffer { + dst: spare.as_mut_ptr().cast(), + size: spare.len(), + pos: 0, + }; + // SAFETY: `p` is a live DCtx; buffers borrow locals / spare + // for this one call. + let remaining = unsafe { + zstd::ZSTD_decompressStream( + p.as_ptr(), + &raw mut output_buf, + &raw mut input_buf, + ) + }; + // SAFETY: decompressStream wrote exactly `output_buf.pos` + // bytes. + unsafe { out.set_len(out.len() + output_buf.pos) }; + if zstd::ZSTD_isError(remaining) != 0 { + return Err(CodecError::Message("zstd decode failed")); + } + if remaining == 0 { + self.ended = true; + continue; + } + if input_buf.pos == input_buf.size && output_buf.pos < output_buf.size { + if finish { + return Err(CodecError::Message("unexpected end of file")); + } + break; + } + } + } + } + Ok(()) + } +} + +/// Input bytes for an off-thread codec step. When the chunk is a pinnable +/// `ArrayBuffer`/view, the backing store is pinned (cannot be detached) and +/// the `JSValue` is `protect()`ed (cannot be collected) so the worker thread +/// reads the bytes in place; otherwise the bytes are copied. `Drop` releases +/// both on the JS thread (the ctx box is reclaimed in `then`). +pub(crate) enum AsyncInput { + Pinned { + value: JSValue, + ptr: *const u8, + len: usize, + }, + Owned(Vec), +} + +// SAFETY: `Pinned.ptr` borrows a JS ArrayBuffer backing store that is pinned +// and GC-protected for the lifetime of this value; the worker only reads +// through it. The `JSValue` word is only dereferenced (unpin/unprotect) back +// on the JS thread in `Drop`. +unsafe impl Send for AsyncInput {} + +impl AsyncInput { + /// Pin `chunk`'s backing store and GC-protect it, borrowing its bytes; or + /// copy `fallback` when `chunk` is not a pinnable BufferSource (the + /// string → `WTF::CString`-scratch branch of `bufferSourceBytes`). + pub(crate) fn new(global: &JSGlobalObject, chunk: JSValue, fallback: &[u8]) -> Self { + if let Some(buf) = chunk.as_pinned_arraybuffer(global) { + // A resizable non-shared backing can `mprotect()` pages out on + // `resize()`; pinning does not block that, so spill to a copy. + if buf.resizable && !buf.shared { + chunk.unpin_array_buffer(); + return Self::Owned(fallback.to_vec()); + } + chunk.protect(); + return Self::Pinned { + value: chunk, + ptr: buf.ptr, + len: buf.byte_len, + }; + } + Self::Owned(fallback.to_vec()) + } + + #[inline] + pub(crate) fn slice(&self) -> &[u8] { + match self { + Self::Pinned { ptr, len, .. } => { + if ptr.is_null() { + return &[]; + } + // SAFETY: backing store is pinned + GC-protected for `self`'s + // lifetime; `(ptr, len)` came from a live `ArrayBuffer` view. + unsafe { core::slice::from_raw_parts(*ptr, *len) } + } + Self::Owned(v) => v.as_slice(), + } + } +} + +impl Drop for AsyncInput { + fn drop(&mut self) { + if let Self::Pinned { value, .. } = *self { + value.unpin_array_buffer(); + value.unprotect(); + } + } +} + +// ─── extern "C" surface (called from JSCompressionStream.cpp) ────────────── + +#[unsafe(no_mangle)] +pub extern "C" fn CompressionStreamCoder__create( + format: u8, + decompress: bool, +) -> *mut CompressionStreamCoder { + let Some(format) = Format::from_u8(format) else { + return ptr::null_mut(); + }; + match CompressionStreamCoder::new(format, decompress) { + Ok(b) => Box::into_raw(b), + Err(_) => ptr::null_mut(), + } +} + +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn CompressionStreamCoder__destroy(this: *mut CompressionStreamCoder) { + if !this.is_null() { + // SAFETY: `this` was returned by `CompressionStreamCoder__create` and + // has not been freed (the C++ cell clears its pointer before calling). + drop(unsafe { Box::from_raw(this) }); + } +} + +/// Runs one transform step. Returns a fresh `Uint8Array` on success, or +/// `JSValue::zero` with a `TypeError` thrown on `global` on failure. +/// `input` may be null iff `input_len == 0`. +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn CompressionStreamCoder__transform( + this: *mut CompressionStreamCoder, + global: &JSGlobalObject, + input: *const u8, + input_len: usize, + finish: bool, +) -> JSValue { + let slice = if input.is_null() { + &[][..] + } else { + // SAFETY: the caller passes a BufferSource's bytes; `slice` does not + // escape this call. + unsafe { core::slice::from_raw_parts(input, input_len) } + }; + // SAFETY: `this` is the live coder owned by the calling JS cell; it is + // only driven from the JS thread, so the call-scoped `&mut *this` has no + // alias. No JS runs between `transform` and `take` below. + match unsafe { (*this).transform(slice, finish) } { + Ok(()) => { + // SAFETY: as above. + let out = unsafe { core::mem::take(&mut (*this).out) }; + if out.is_empty() { + JSUint8Array::create_empty(global) + } else { + JSUint8Array::from_bytes(global, out.into()) + } + } + Err(e) => { + throw_codec_error(global, e); + JSValue::ZERO + } + } +} + +fn codec_error_to_js(global: &JSGlobalObject, e: &CodecError) -> JSValue { + match *e { + CodecError::TrailingJunk => global + .err( + ErrorCode::ERR_TRAILING_JUNK_AFTER_STREAM_END, + format_args!("Trailing junk found after the end of the compressed stream"), + ) + .to_js(), + CodecError::Message(msg) => global.create_type_error_instance(format_args!("{msg}")), + CodecError::Brotli(detail) => { + let code = format!("ERR_{detail}"); + let err = global.create_type_error_instance(format_args!("brotli decode failed")); + let code_js = JscZigString::init(code.as_bytes()).to_js(global); + err.put(global, b"code", code_js); + let cause = global.create_error_instance(format_args!("{detail}")); + cause.put(global, b"code", code_js); + err.put(global, b"cause", cause); + err + } + } +} + +fn throw_codec_error(global: &JSGlobalObject, e: CodecError) { + let _ = global.throw_value(codec_error_to_js(global, &e)); +} + +/// Runs one transform step and writes the output straight to a native JSSink +/// (`m_sinkPtr`), so the chunk never becomes a `JSUint8Array`. Returns the +/// sink's `write_bytes` result (see nativeSinkWriteIsBackpressure for the +/// backpressure-signal shapes), `undefined` for an empty output, or +/// `JSValue::zero` with an exception pending on `global` on codec failure. +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn CompressionStreamCoder__transformInto( + this: *mut CompressionStreamCoder, + global: &JSGlobalObject, + input: *const u8, + input_len: usize, + finish: bool, + sink_id: u8, + sink_ptr: *mut core::ffi::c_void, +) -> JSValue { + let slice = if input.is_null() { + &[][..] + } else { + // SAFETY: as in `__transform`. + unsafe { core::slice::from_raw_parts(input, input_len) } + }; + // SAFETY: as in `__transform`. + match unsafe { (*this).transform(slice, finish) } { + Ok(()) => { + // SAFETY: as above; the sink copies before returning. + let out = unsafe { &(*this).out }; + if out.is_empty() { + return JSValue::UNDEFINED; + } + let Some(sink_ptr) = NonNull::new(sink_ptr) else { + return JSValue::UNDEFINED; + }; + // SAFETY: `sink_ptr` is a live JSSink of type `sink_id`; the sink + // copies what it needs before returning. + let handle = unsafe { crate::webcore::sink::sink_handle_from_id(sink_id, sink_ptr) }; + if handle.is_none() { + return JSValue::UNDEFINED; + } + handle + .write(&crate::webcore::streams::Result::Temporary( + bun_ptr::RawSlice::new(out), + )) + .to_js(global) + } + Err(e) => { + throw_codec_error(global, e); + JSValue::ZERO + } + } +} + +// ─── off-thread path (chunks > kAsyncCodecThreshold) ─────────────────────── + +unsafe extern "C" { + /// JS-thread completion hook in `JSCompressionStreamShared.cpp`. Copies + /// `out[..out_len]` into the sink / a fresh Uint8Array before it clears + /// `m_asyncCodecInFlight` (so a deferred coder release cannot free the + /// bytes under it), then settles the stream's `m_asyncCodecPromise`. + fn Bun__CompressionStream__deliverAsync( + global: &JSGlobalObject, + stream_cell: JSValue, + out: *const u8, + out_len: usize, + error: JSValue, + ); +} + +pub struct CompressionAsyncCtx { + coder: *mut CompressionStreamCoder, + input: AsyncInput, + finish: bool, + /// GC root for the `JSTransformStream` cell that owns `coder`; its + /// `m_asyncCodecInFlight` flag defers `m_coder` teardown while this task + /// holds it, and its `m_asyncCodecPromise` WriteBarrier keeps the pending + /// transform-algorithm promise alive. + stream: Strong, + error: Option, +} + +pub type CompressionStreamCoderTask = WorkTask; + +#[allow(clippy::not_unsafe_ptr_arg_deref)] +impl WorkTaskContext for CompressionAsyncCtx { + const TASK_TAG: bun_event_loop::TaskTag = bun_event_loop::task_tag::CompressionStreamCoderTask; + + fn run(this: *mut Self, task: *mut WorkTask) { + // SAFETY: work-pool hand-off; `this`/`task` are live and exclusive. + // `coder` is kept alive by `m_asyncCodecInFlight` on the rooted stream + // cell, and TransformStream serializes writes so nothing else aliases it. + unsafe { + let ctx = &mut *this; + ctx.error = (*ctx.coder).transform(ctx.input.slice(), ctx.finish).err(); + WorkTask::on_finish(&mut *task); + } + } + + fn then(this: *mut Self, global: &JSGlobalObject) -> Result<(), JsTerminated> { + // SAFETY: heap-allocated in `__transformAsync`; consumed here so + // `input` (unpin/unprotect) and `stream` drop on the JS thread. + let ctx = unsafe { bun_core::heap::take(this) }; + let stream = ctx.stream.get(); + let (out, out_len, err) = match ctx.error { + None => { + // SAFETY: `m_asyncCodecInFlight` still holds; `coder` (and its + // `out` buffer) stay live until `deliverAsync` copies and clears it. + let coder = unsafe { &*ctx.coder }; + (coder.out.as_ptr(), coder.out.len(), JSValue::ZERO) + } + Some(e) => (core::ptr::null(), 0, codec_error_to_js(global, &e)), + }; + // SAFETY: FFI into `JSCompressionStreamShared.cpp`; the callee copies + // `out[..out_len]` before releasing the coder. + unsafe { Bun__CompressionStream__deliverAsync(global, stream, out, out_len, err) }; + Ok(()) + } +} + +/// Schedules one transform step on the WorkPool. The caller +/// (`codeAndEnqueue`) has already created the pending `JSPromise`, stored it +/// on `stream_cell->m_asyncCodecPromise`, and set `m_asyncCodecInFlight`; +/// `Bun__CompressionStream__deliverAsync` settles that promise. +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn CompressionStreamCoder__transformAsync( + this: *mut CompressionStreamCoder, + global: &JSGlobalObject, + stream_cell: JSValue, + chunk: JSValue, + input: *const u8, + input_len: usize, + finish: bool, +) { + let fallback = if input.is_null() { + &[][..] + } else { + // SAFETY: the caller passes a BufferSource's bytes; either pinned (and + // `fallback` is ignored) or copied into an owned Vec. + unsafe { core::slice::from_raw_parts(input, input_len) } + }; + let ctx = bun_core::heap::into_raw(Box::new(CompressionAsyncCtx { + coder: this, + input: AsyncInput::new(global, chunk, fallback), + finish, + stream: Strong::create(stream_cell, global), + error: None, + })); + let task = WorkTask::::create_on_js_thread(global, ctx); + // SAFETY: `task` is a freshly-allocated WorkTask; sole owner until scheduled. + WorkTask::schedule(unsafe { &mut *task }); +} diff --git a/src/runtime/webcore/Sink.rs b/src/runtime/webcore/Sink.rs index a87dcae1fee1..147bd536be70 100644 --- a/src/runtime/webcore/Sink.rs +++ b/src/runtime/webcore/Sink.rs @@ -667,6 +667,120 @@ impl JSSink { } } +// ────────────────────────────────────────────────────────────────────────── +// Native-transform → native-sink byte-write dispatch +// +// Replaces the generated per-sink `${name}__writeBytes` thunks + the C++ +// `JSSink__writeBytes` SinkID switch with a single Rust entry point that +// routes through `SinkHandle::write`. +// ────────────────────────────────────────────────────────────────────────── + +/// Map a C++ `WebCore::SinkID` + erased `m_sinkPtr` to a [`SinkHandle`]. +/// +/// `ptr` is the `m_sinkPtr` stored on the JS wrapper (a `*mut JSSink` for +/// the `T` selected by `id`); `JSSink` is `#[repr(transparent)]` over `T`, +/// so the cast to `*mut T` is an address-preserving no-op. +/// +/// # Safety +/// `ptr` must be a live, properly-aligned pointer to the concrete sink type +/// that `id` names (the same pointer the generated `${name}__*` thunks +/// receive), valid for the lifetime of the returned handle. +pub(crate) unsafe fn sink_handle_from_id( + id: u8, + ptr: NonNull, +) -> crate::webcore::SinkHandle { + use crate::webcore::SinkHandle; + // Mirrors `enum SinkID` in src/jsc/bindings/Sink.h. + const ARRAY_BUFFER_SINK: u8 = 0; + const FILE_SINK: u8 = 2; + const HTML_REWRITER_SINK: u8 = 3; + const HTTP_RESPONSE_SINK: u8 = 4; + const HTTPS_RESPONSE_SINK: u8 = 5; + const NETWORK_SINK: u8 = 6; + const H3_RESPONSE_SINK: u8 = 7; + const FETCH_REQUEST_BODY_SINK: u8 = 8; + + let raw = ptr.as_ptr(); + match id { + // SAFETY: caller contract — `raw` is a live `*mut ArrayBufferSink`. + ARRAY_BUFFER_SINK => SinkHandle::ArrayBuffer(unsafe { + bun_ptr::BackRef::from_raw_mut(raw.cast::()) + }), + // SAFETY: caller contract — `raw` is a live `*mut FileSink`. + FILE_SINK => SinkHandle::FileSink(unsafe { + bun_ptr::BackRef::from_raw(raw.cast::()) + }), + // SAFETY: caller contract — `raw` is a live `*mut RewriterPipe`. + HTML_REWRITER_SINK => SinkHandle::HTMLRewriter(unsafe { + bun_ptr::BackRef::from_raw(raw.cast::()) + }), + // SAFETY: caller contract — `raw` is a live `*mut HTTPResponseSink`. + HTTP_RESPONSE_SINK => SinkHandle::HttpResponse(unsafe { + bun_ptr::BackRef::from_raw_mut(raw.cast::()) + }), + // SAFETY: caller contract — `raw` is a live `*mut HTTPSResponseSink`. + HTTPS_RESPONSE_SINK => SinkHandle::HttpsResponse(unsafe { + bun_ptr::BackRef::from_raw_mut(raw.cast::()) + }), + // SAFETY: caller contract — `raw` is a live `*mut NetworkSink`. + NETWORK_SINK => SinkHandle::S3Upload(unsafe { + bun_ptr::BackRef::from_raw_mut(raw.cast::()) + }), + // SAFETY: caller contract — `raw` is a live `*mut H3ResponseSink`. + H3_RESPONSE_SINK => SinkHandle::H3Response(unsafe { + bun_ptr::BackRef::from_raw_mut(raw.cast::()) + }), + // SAFETY: caller contract — `raw` is a live `*mut FetchRequestBodySink`. + FETCH_REQUEST_BODY_SINK => SinkHandle::FetchRequestBody(unsafe { + bun_ptr::BackRef::from_raw_mut( + raw.cast::(), + ) + }), + // 1 (TextSink) and any unknown id → no native sink. + _ => SinkHandle::None, + } +} + +/// Route a borrowed byte chunk from a native transform (`JSTransformStream` +/// with `m_nativeSinkPtr` attached) into the concrete sink via +/// [`SinkHandle::write`]. +/// +/// Return shape matches [`streams::result::Writable::to_js`] so +/// `nativeSinkWriteIsBackpressure` reads a negative number / pending promise +/// exactly as the previous `js_write_bytes` path produced. No +/// [`JsSinkType::get_pending_error`] guard: every sink uses the trait-default +/// `None`, so omitting it is behavior-preserving. +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn Bun__NativeTransformSink__writeBytes( + sink_id: u8, + sink_ptr: *mut c_void, + global: &JSGlobalObject, + ptr: *const u8, + len: usize, +) -> JSValue { + bun_core::mark_binding!(); + let Some(sink_ptr) = NonNull::new(sink_ptr) else { + return JSValue::js_number(0.0); + }; + if len == 0 || ptr.is_null() { + return JSValue::js_number(0.0); + } + // SAFETY: C++ caller passes a live `m_sinkPtr` of the type `sink_id` + // names, valid for the duration of this synchronous call. + let handle = unsafe { sink_handle_from_id(sink_id, sink_ptr) }; + if handle.is_none() { + return JSValue::UNDEFINED; + } + // SAFETY: caller guarantees `[ptr, ptr+len)` is a live readable byte + // buffer for the duration of this call (a GC-kept `JSArrayBufferView` or + // a caller-owned scratch buffer). + let slice = unsafe { core::slice::from_raw_parts(ptr, len) }; + handle + .write(&streams::Result::Temporary(bun_ptr::RawSlice::new(slice))) + .to_js(global) +} + // ────────────────────────────────────────────────────────────────────────── // DestructorPtr / Bun__onSinkDestroyed // ────────────────────────────────────────────────────────────────────────── diff --git a/src/runtime/webcore/TextDecoder.rs b/src/runtime/webcore/TextDecoder.rs index b94116a3e878..a5b999bd013d 100644 --- a/src/runtime/webcore/TextDecoder.rs +++ b/src/runtime/webcore/TextDecoder.rs @@ -649,3 +649,111 @@ impl TextDecoder { Ok(bun_core::heap::into_raw(TextDecoder::new(decoder))) } } + +// ─── extern "C" surface (JSTextDecoderStream.cpp) ───────────────────────── +// The TextDecoderStream cell owns its decoder directly as a `void*` (no JS +// `TextDecoder` wrapper cell, no `decode` prototype lookup, no per-chunk +// `{stream: true}` options object) and drives it through these. + +/// Validates `label` (WebIDL DOMString coercion — may run user JS) and +/// returns a fresh decoder configured for the matching encoding. Returns null +/// with an exception pending on `global` on a bad label. +/// +/// For the overwhelmingly common `utf-8` + non-fatal case the C++ side uses +/// the inline `StreamingUTF8DecodeState` instead of this decoder, so no +/// `TextDecoder` is allocated: `*out_utf8_fast_path` is set and null is +/// returned with no exception. +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn TextDecoder__createForStream( + global: &JSGlobalObject, + label: JSValue, + fatal: bool, + ignore_bom: bool, + out_utf8_fast_path: *mut bool, + out_encoding_label: *mut bun_core::String, +) -> *mut TextDecoder { + // SAFETY: both out-params are stack locals on the caller's frame. + unsafe { *out_utf8_fast_path = false }; + let encoding = if label.is_undefined() { + EncodingLabel::Utf8 + } else { + let converted = match bun_core::String::from_js(label, global) { + Ok(s) => OwnedString::new(s), + Err(_) => return core::ptr::null_mut(), + }; + let str = converted.to_utf8(); + match EncodingLabel::which(str.slice()) { + Some(l) if l != EncodingLabel::Replacement => l, + _ => { + let _ = global + .err( + jsc::ErrorCode::ERR_ENCODING_NOT_SUPPORTED, + format_args!( + "Unsupported encoding label \"{}\"", + bstr::BStr::new(str.slice()) + ), + ) + .throw(); + return core::ptr::null_mut(); + } + } + }; + // SAFETY: as above; the label borrows a 'static byte slice (no refcount). + unsafe { *out_encoding_label = bun_core::String::static_(encoding.get_label()) }; + if matches!(encoding, EncodingLabel::Utf8) && !fatal { + // SAFETY: as above. + unsafe { *out_utf8_fast_path = true }; + return core::ptr::null_mut(); + } + let mut decoder = TextDecoder::default(); + decoder.fatal = fatal; + decoder.ignore_bom = ignore_bom; + decoder.encoding = encoding; + bun_core::heap::into_raw(TextDecoder::new(decoder)) +} + +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn TextDecoder__destroyForStream(this: *mut TextDecoder) { + if !this.is_null() { + // SAFETY: `this` was returned by `__createForStream` and has not been + // freed (the C++ cell clears its pointer before calling). + unsafe { bun_core::heap::destroy(this) }; + } +} + +/// The TextDecoderStream transform/flush step. `stream = true` for a mid- +/// stream chunk, `false` for the final flush. `input` may be null iff +/// `input_len == 0`. Returns a JSString on success, or `JSValue::zero` with +/// the exception pending on `global`. +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn TextDecoder__decodeForStream( + this: *mut TextDecoder, + global: &JSGlobalObject, + input: *const u8, + input_len: usize, + stream: bool, +) -> JSValue { + // SAFETY: `this` is the live decoder owned by the calling JS cell; driven + // only from the JS thread, so `&*this` has no mutable alias. + let this = unsafe { &*this }; + let slice = if input.is_null() { + &[][..] + } else { + // SAFETY: the caller passes a BufferSource's bytes; `slice` does not + // escape this call. + unsafe { core::slice::from_raw_parts(input, input_len) } + }; + // https://encoding.spec.whatwg.org/#dom-textdecoder-decode steps 1-2. + if !this.do_not_flush.replace(stream) { + this.bom_seen.set(false); + } + let result = if stream { + this.decode_slice::(global, slice) + } else { + this.decode_slice::(global, slice) + }; + result.unwrap_or(JSValue::ZERO) +} diff --git a/src/runtime/webcore/TextEncoderStreamEncoder.rs b/src/runtime/webcore/TextEncoderStreamEncoder.rs index 8833bfd78b7b..b19cc103dc23 100644 --- a/src/runtime/webcore/TextEncoderStreamEncoder.rs +++ b/src/runtime/webcore/TextEncoderStreamEncoder.rs @@ -1,69 +1,54 @@ use core::cell::Cell; +use core::ptr::NonNull; use bun_collections::VecExt as _; use bun_core::strings; -use bun_jsc::{CallFrame, JSGlobalObject, JSUint8Array, JSValue, JsResult}; +use bun_jsc::{JSGlobalObject, JSUint8Array, JSValue}; +use bun_ptr::RawSlice; use bun_simdutf_sys::simdutf; +use crate::webcore::sink::sink_handle_from_id; +use crate::webcore::streams; + bun_output::declare_scope!(TextEncoderStreamEncoder, visible); -// R-2 (host-fn re-entrancy): every JS-exposed method takes `&self`; the single -// mutable field is `Cell>` (Copy). +/// Held by `JSTextEncoderStream` as a raw `void*` and driven via the +/// `extern "C"` fns below; no JS wrapper class. `scratch` is moved out via +/// `.take()` before any call that could re-enter. #[derive(Default)] -#[bun_jsc::JsClass] pub struct TextEncoderStreamEncoder { pending_lead_surrogate: Cell>, + /// Reusable output buffer for the native-sink path so a + /// `ByteStream → TextEncoderStream → JSSink` chain allocates nothing per + /// chunk. Borrowed only after user-JS coercion has run. + scratch: core::cell::RefCell>, } impl TextEncoderStreamEncoder { - // No `#[bun_jsc::host_fn]` here — that macro's free-fn arm emits - // a bare `constructor(...)` which cannot resolve inside an `impl`. The - // `#[bun_jsc::JsClass]` derive already emits the `::constructor` shim. - pub(crate) fn constructor( - _global: &JSGlobalObject, - _frame: &CallFrame, - ) -> JsResult> { - Ok(Box::new(TextEncoderStreamEncoder::default())) - } - - #[bun_jsc::host_fn(method)] - pub(crate) fn encode(&self, global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - let arguments = frame.arguments(); - if arguments.is_empty() { - return Err(global.throw_not_enough_arguments( - "TextEncoderStreamEncoder.encode", - 1, - arguments.len(), - )); - } - - let str = arguments[0].get_zig_string(global)?; - - if str.is_16bit() { - return Ok(self.encode_utf16(global, str.utf16_slice_aligned())); + fn encode_latin1(&self, global: &JSGlobalObject, input: &[u8]) -> JSValue { + if input.is_empty() { + return JSUint8Array::create_empty(global); } - - Ok(self.encode_latin1(global, str.slice())) + let mut buffer = Vec::new(); + self.encode_latin1_into(input, &mut buffer); + JSUint8Array::from_bytes(global, buffer.into()) } - fn encode_latin1(&self, global: &JSGlobalObject, input: &[u8]) -> JSValue { + fn encode_latin1_into(&self, input: &[u8], buffer: &mut Vec) { bun_output::scoped_log!( TextEncoderStreamEncoder, "encodeLatin1: \"{}\"", bstr::BStr::new(input) ); - if input.is_empty() { - return JSUint8Array::create_empty(global); + return; } - let prepend_replacement_len: usize = 'prepend_replacement: { - if self.pending_lead_surrogate.take().is_some() { - // no latin1 surrogate pairs - break 'prepend_replacement 3; - } - - break 'prepend_replacement 0; + let prepend_replacement_len: usize = if self.pending_lead_surrogate.take().is_some() { + // no latin1 surrogate pairs + 3 + } else { + 0 }; // In a previous benchmark, counting the length took about as much time as allocating the buffer. // @@ -72,7 +57,7 @@ impl TextEncoderStreamEncoder { // 278.00 ms 13.0% 278.00 ms simdutf::arm64::implementation::utf8_length_from_latin1(char const*, unsigned long) const // // - let mut buffer: Vec = Vec::with_capacity(input.len() + prepend_replacement_len); + buffer.reserve(input.len() + prepend_replacement_len); if prepend_replacement_len > 0 { buffer.extend_from_slice(&[0xef, 0xbf, 0xbd]); } @@ -82,7 +67,7 @@ impl TextEncoderStreamEncoder { // SAFETY: copy_latin1_into_utf8 writes initialized bytes into the spare capacity and // returns the number written; fill_spare commits exactly that many. let result = unsafe { - bun_core::vec::fill_spare(&mut buffer, 0, |spare| { + bun_core::vec::fill_spare(buffer, 0, |spare| { let r = strings::copy_latin1_into_utf8(spare, remain); (r.written as usize, r) }) @@ -98,19 +83,30 @@ impl TextEncoderStreamEncoder { debug_assert!( buffer.len() == (simdutf::length::utf8::from::latin1(input) + prepend_replacement_len) ); - - JSUint8Array::from_bytes(global, buffer.into()) } fn encode_utf16(&self, global: &JSGlobalObject, input: &[u16]) -> JSValue { + if input.is_empty() { + return JSUint8Array::create_empty(global); + } + let mut buf = Vec::new(); + if self.encode_utf16_into(input, &mut buf).is_err() { + return global.throw_out_of_memory_value(); + } + if buf.is_empty() { + return JSUint8Array::create_empty(global); + } + JSUint8Array::from_bytes(global, buf.into()) + } + + fn encode_utf16_into(&self, input: &[u16], buf: &mut Vec) -> Result<(), ()> { bun_output::scoped_log!( TextEncoderStreamEncoder, "encodeUTF16: \"{}\"", bun_core::fmt::utf16(input) ); - if input.is_empty() { - return JSUint8Array::create_empty(global); + return Ok(()); } #[derive(Clone, Copy)] @@ -148,10 +144,8 @@ impl TextEncoderStreamEncoder { remain = &remain[1..]; if remain.is_empty() { - return JSUint8Array::from_bytes_copy( - global, - &sequence[0..converted.utf8_width() as usize], - ); + buf.extend_from_slice(&sequence[0..converted.utf8_width() as usize]); + return Ok(()); } break 'prepend Some(Prepend::from_sequence(sequence, converted.utf8_width())); @@ -164,7 +158,7 @@ impl TextEncoderStreamEncoder { let length = simdutf::length::utf8::from::utf16::le(remain); - let mut buf: Vec = Vec::with_capacity( + buf.reserve( length + match prepend { Some(pre) => pre.len as usize, @@ -179,7 +173,7 @@ impl TextEncoderStreamEncoder { // SAFETY: simdutf writes initialized bytes into the spare capacity and returns the // count; on non-SUCCESS we commit 0 and fall through to the slow path. let result = unsafe { - bun_core::vec::fill_spare(&mut buf, 0, |spare| { + bun_core::vec::fill_spare(buf, 0, |spare| { let r = simdutf::convert::utf16::to::utf8::with_errors::le(remain, spare); ( if r.status == simdutf::Status::SUCCESS { @@ -192,30 +186,15 @@ impl TextEncoderStreamEncoder { }) }; - if result.status == simdutf::Status::SUCCESS { - JSUint8Array::from_bytes(global, buf.into()) - } else { + if result.status != simdutf::Status::SUCCESS { // Slow path: there was invalid UTF-16, so we need to convert it without simdutf. - let lead_surrogate = match strings::to_utf8_list_with_type_bun::(&mut buf, remain) - { - Ok(v) => v, - Err(_) => return global.throw_out_of_memory_value(), - }; - + let lead_surrogate = + strings::to_utf8_list_with_type_bun::(buf, remain).map_err(|_| ())?; if let Some(pending_lead) = lead_surrogate { self.pending_lead_surrogate.set(Some(pending_lead)); - if buf.is_empty() { - return JSUint8Array::create_empty(global); - } } - - JSUint8Array::from_bytes(global, buf.into()) } - } - - #[bun_jsc::host_fn(method)] - pub(crate) fn flush(&self, global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { - Ok(self.flush_body(global)) + Ok(()) } fn flush_body(&self, global: &JSGlobalObject) -> JSValue { @@ -226,3 +205,148 @@ impl TextEncoderStreamEncoder { } } } + +// ─── extern "C" surface (JSTextEncoderStream.cpp) ───────────────────────── +// The TextEncoderStream cell owns its encoder directly as a `void*` (no JS +// wrapper cell, no prototype lookup) and drives it through these. + +#[unsafe(no_mangle)] +pub extern "C" fn TextEncoderStreamEncoder__createForStream() -> *mut TextEncoderStreamEncoder { + Box::into_raw(Box::new(TextEncoderStreamEncoder::default())) +} + +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn TextEncoderStreamEncoder__destroyForStream(this: *mut TextEncoderStreamEncoder) { + if !this.is_null() { + // SAFETY: `this` was returned by `__createForStream` and has not been + // freed (the C++ cell clears its pointer before calling). + drop(unsafe { Box::from_raw(this) }); + } +} + +/// The TextEncoderStream transform step: WebIDL `USVString` conversion of +/// `chunk` (user JS — may throw), then encode. Returns a fresh `Uint8Array` +/// on success, or `JSValue::zero` with the exception pending on `global`. +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn TextEncoderStreamEncoder__encodeForStream( + this: *mut TextEncoderStreamEncoder, + global: &JSGlobalObject, + chunk: JSValue, +) -> JSValue { + let Ok(str) = chunk.get_zig_string(global) else { + return JSValue::ZERO; + }; + // SAFETY: `this` is the live encoder owned by the calling JS cell; driven + // only from the JS thread, so `&*this` has no mutable alias. Taken after + // the coercion so no user JS runs while the borrow is live. + let this = unsafe { &*this }; + if str.is_16bit() { + this.encode_utf16(global, str.utf16_slice_aligned()) + } else { + this.encode_latin1(global, str.slice()) + } +} + +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn TextEncoderStreamEncoder__flushForStream( + this: *mut TextEncoderStreamEncoder, + global: &JSGlobalObject, +) -> JSValue { + // SAFETY: as in `__encodeForStream`. + unsafe { &*this }.flush_body(global) +} + +/// Cap on the reusable scratch buffer so a single huge chunk doesn't pin +/// that much memory for the life of the encoder. +const SCRATCH_CAP: usize = 64 * 1024; + +/// Native-sink transform step: encodes `chunk` into the encoder's reusable +/// scratch buffer and writes it straight to the sink, so a +/// `ByteStream → TextEncoderStream → JSSink` chain allocates no +/// `JSUint8Array` per chunk. Returns the sink's `write_bytes` result (see +/// nativeSinkWriteIsBackpressure for the backpressure-signal shapes), +/// `undefined` for an empty output, or `JSValue::zero` with the exception +/// pending on `global`. +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn TextEncoderStreamEncoder__encodeIntoSink( + this: *mut TextEncoderStreamEncoder, + global: &JSGlobalObject, + chunk: JSValue, + sink_id: u8, + sink_ptr: *mut core::ffi::c_void, +) -> JSValue { + let Ok(str) = chunk.get_zig_string(global) else { + return JSValue::ZERO; + }; + // SAFETY: `this` is the live encoder owned by the calling JS cell; taken + // after the coercion so no user JS runs while the borrow is live. + let this = unsafe { &*this }; + // Move the Vec out of the RefCell for the duration of the sink write so a + // (theoretical) re-entrant encode-into-sink call cannot BorrowMut-panic. + let mut buf = this.scratch.take(); + buf.clear(); + if str.is_16bit() { + if this + .encode_utf16_into(str.utf16_slice_aligned(), &mut buf) + .is_err() + { + return global.throw_out_of_memory_value(); + } + } else { + this.encode_latin1_into(str.slice(), &mut buf); + } + if buf.is_empty() { + this.scratch.replace(buf); + return JSValue::UNDEFINED; + } + let Some(ptr) = NonNull::new(sink_ptr) else { + this.scratch.replace(buf); + return JSValue::UNDEFINED; + }; + // SAFETY: `sink_ptr` is a live JSSink of type `sink_id` (the C++ caller + // null-checks it before attaching); the sink copies what it needs. + let handle = unsafe { sink_handle_from_id(sink_id, ptr) }; + if handle.is_none() { + this.scratch.replace(buf); + return JSValue::UNDEFINED; + } + let wrote = handle + .write(&streams::Result::Temporary(RawSlice::new(&buf))) + .to_js(global); + if buf.capacity() <= SCRATCH_CAP { + this.scratch.replace(buf); + } + wrote +} + +/// Native-sink flush step; see `__encodeIntoSink` for the return contract. +#[unsafe(no_mangle)] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn TextEncoderStreamEncoder__flushIntoSink( + this: *mut TextEncoderStreamEncoder, + global: &JSGlobalObject, + sink_id: u8, + sink_ptr: *mut core::ffi::c_void, +) -> JSValue { + // SAFETY: as in `__encodeForStream`. + let this = unsafe { &*this }; + if this.pending_lead_surrogate.get().is_none() { + return JSValue::UNDEFINED; + } + const REPLACEMENT: [u8; 3] = [0xef, 0xbf, 0xbd]; + let Some(ptr) = NonNull::new(sink_ptr) else { + return JSValue::UNDEFINED; + }; + // SAFETY: as in `__encodeIntoSink`. + let handle = unsafe { sink_handle_from_id(sink_id, ptr) }; + if handle.is_none() { + return JSValue::UNDEFINED; + } + handle + .write(&streams::Result::Temporary(RawSlice::new(&REPLACEMENT))) + .to_js(global) +} diff --git a/src/runtime/webcore/encoding.classes.ts b/src/runtime/webcore/encoding.classes.ts index b26078ccc1fa..7fd70406f4dd 100644 --- a/src/runtime/webcore/encoding.classes.ts +++ b/src/runtime/webcore/encoding.classes.ts @@ -31,32 +31,4 @@ export default [ }, }, }), - define({ - name: "TextEncoderStreamEncoder", - construct: true, - finalize: true, - JSType: "0b11101110", - configurable: false, - klass: {}, - proto: { - encode: { - fn: "encode", - length: 1, - - DOMJIT: { - returns: "JSUint8Array", - args: ["JSString"], - }, - }, - flush: { - fn: "flush", - length: 0, - - DOMJIT: { - returns: "JSUint8Array", - args: [], - }, - }, - }, - }), ]; diff --git a/test/js/web/encoding/text-decoder-stream.test.ts b/test/js/web/encoding/text-decoder-stream.test.ts index 93d60d824862..cf603bfc031d 100644 --- a/test/js/web/encoding/text-decoder-stream.test.ts +++ b/test/js/web/encoding/text-decoder-stream.test.ts @@ -105,6 +105,20 @@ import { readableStreamFromArray } from "harness"; }); } + test("encoding attribute is stable after close/abort/cancel", async () => { + for (const terminate of [ + (s: TextDecoderStream) => s.writable.getWriter().close(), + (s: TextDecoderStream) => s.writable.getWriter().abort(), + (s: TextDecoderStream) => s.readable.getReader().cancel(), + ]) { + const s = new TextDecoderStream("utf-16le"); + expect(s.encoding).toBe("utf-16le"); + await terminate(s); + expect(s.encoding).toBe("utf-16le"); + expect(s.fatal).toBe(false); + } + }); + for (const falseValue of [false, 0, "", undefined, null]) { test(`setting fatal to '${falseValue}' should set the attribute to false`, () => { const stream = new TextDecoderStream("utf-8", { fatal: falseValue }); @@ -191,50 +205,82 @@ test("TextDecoderStream accepts undefined and null options", () => { expect(new TextDecoderStream("utf-8", { fatal: true }).fatal).toBe(true); }); -// https://github.com/oven-sh/bun/pull/33193 — a transform/flush failure must reject the -// write/close promise, not throw synchronously or leave the in-flight op unsettled. -test("cancelling the readable inside a patched decode() rejects the write instead of throwing", async () => { - const original = TextDecoder.prototype.decode; - try { - const stream = new TextDecoderStream(); - const reader = stream.readable.getReader(); - const writer = stream.writable.getWriter(); - reader.read(); - (await null, await null, await null); // open the synchronous-transform window (backpressure cleared, write runs the transform inline) +// utf-8 non-fatal fast path (shared with Body.textStream()): a leading BOM is +// stripped by default, preserved with ignoreBOM, and maximal-subpart +// replacement matches TextDecoder. +test("utf-8 fast path: BOM stripping matches ignoreBOM", async () => { + const bom = new Uint8Array([0xef, 0xbb, 0xbf, 0x41]); + for (const [ignoreBOM, expected] of [ + [false, "A"], + [true, "\uFEFFA"], + ] as const) { + const out = await Bun.readableStreamToArray( + readableStreamFromArray([bom]).pipeThrough(new TextDecoderStream("utf-8", { ignoreBOM })), + ); + expect(out.join("")).toBe(expected); + } +}); - TextDecoder.prototype.decode = function (...args) { - TextDecoder.prototype.decode = original; - reader.cancel(); - return "x"; - }; - const writePromise = writer.write(new Uint8Array([120])); // must NOT throw synchronously - expect(writePromise).toBeInstanceOf(Promise); - await expect(writePromise).rejects.toBeInstanceOf(TypeError); - await writer.abort("bye"); // must settle - } finally { - TextDecoder.prototype.decode = original; +test("utf-8 fast path: malformed-sequence replacement matches TextDecoder", async () => { + // Overlong / surrogate-range sequences: each byte is its own maximal subpart. + const cases: Array<[number[], string]> = [ + [[0xf0, 0x8f, 0x92], "\uFFFD\uFFFD\uFFFD"], + [[0xe0, 0x80], "\uFFFD\uFFFD"], + [[0xf0, 0x9f, 0x41], "\uFFFDA"], + ]; + for (const [bytes, expected] of cases) { + const td = new TextDecoder("utf-8"); + expect(td.decode(new Uint8Array(bytes))).toBe(expected); + const out = await Bun.readableStreamToArray( + readableStreamFromArray([new Uint8Array(bytes)]).pipeThrough(new TextDecoderStream()), + ); + expect(out.join("")).toBe(expected); } }); -test("cancelling the readable inside a patched decode() during flush rejects close()", async () => { +test("utf-8 fast path: a split BOM across chunks is stripped", async () => { + const out = await Bun.readableStreamToArray( + readableStreamFromArray([new Uint8Array([0xef]), new Uint8Array([0xbb, 0xbf, 0x42])]).pipeThrough( + new TextDecoderStream(), + ), + ); + expect(out.join("")).toBe("B"); +}); + +// fatal:true takes the Rust TextDecoder path (not the fast path). +test("utf-8 fatal: split valid sequence across chunks decodes", async () => { + const out = await Bun.readableStreamToArray( + readableStreamFromArray([new Uint8Array([0xf0, 0x9f]), new Uint8Array([0x92, 0x99])]).pipeThrough( + new TextDecoderStream("utf-8", { fatal: true }), + ), + ); + expect(out.join("")).toBe("\u{1F499}"); +}); + +test("utf-8 fatal: truncated sequence rejects at flush", async () => { + const out = readableStreamFromArray([new Uint8Array([0xf0, 0x9f, 0x92])]).pipeThrough( + new TextDecoderStream("utf-8", { fatal: true }), + ); + await expect(Bun.readableStreamToArray(out)).rejects.toBeInstanceOf(TypeError); +}); + +// The transform/flush arm runs the native decoder directly, so monkeypatching +// TextDecoder.prototype.decode no longer reaches it. +test("TextDecoderStream does not call a patched TextDecoder.prototype.decode", async () => { const original = TextDecoder.prototype.decode; + let called = false; try { + TextDecoder.prototype.decode = function (...args) { + called = true; + return original.apply(this, args); + }; const stream = new TextDecoderStream(); const reader = stream.readable.getReader(); const writer = stream.writable.getWriter(); reader.read(); - (await null, await null, await null); // open the synchronous-transform window (backpressure cleared, write runs the transform inline) - - TextDecoder.prototype.decode = function (...args) { - TextDecoder.prototype.decode = original; - // cancel() during an in-flight close returns the finish promise, which rejects - // together with the flush — handle it so it isn't an unhandled rejection. - reader.cancel().catch(() => {}); - return "tail"; - }; - const closePromise = writer.close(); // flush runs the patched decode; must NOT throw synchronously - expect(closePromise).toBeInstanceOf(Promise); - await expect(closePromise).rejects.toBeInstanceOf(TypeError); + await writer.write(new Uint8Array([120])); + await writer.close(); + expect(called).toBe(false); } finally { TextDecoder.prototype.decode = original; } diff --git a/test/js/web/encoding/text-encoder-stream.test.ts b/test/js/web/encoding/text-encoder-stream.test.ts index a9a74bfa19cb..5c2578ec9479 100644 --- a/test/js/web/encoding/text-encoder-stream.test.ts +++ b/test/js/web/encoding/text-encoder-stream.test.ts @@ -163,3 +163,107 @@ test("cancelling the readable inside the chunk's toString() rejects the write in await expect(writePromise).rejects.toBeInstanceOf(TypeError); await writer.abort("bye"); // must settle }); + +// Native-sink output path: when the readable is consumed by a native JSSink +// (here HTTPResponseSink via Bun.serve), the encoder writes into a reusable +// scratch buffer and straight to the sink's m_sinkPtr instead of wrapping each +// chunk in a JSUint8Array. +test("TextEncoderStream -> native HTTP response sink round-trips (incl. split surrogate)", async () => { + await using server = Bun.serve({ + port: 0, + fetch() { + const body = new ReadableStream({ + start(c) { + c.enqueue("I \u{1F499} "); + c.enqueue(leading); + c.enqueue(trailing + " streams"); + c.close(); + }, + }); + return new Response(body.pipeThrough(new TextEncoderStream())); + }, + }); + const text = await (await fetch(server.url)).text(); + expect(text).toBe("I \u{1F499} \u{1F499} streams"); +}); + +test("TextEncoderStream -> native HTTP response sink: flush emits FFFD for a dangling lead surrogate", async () => { + await using server = Bun.serve({ + port: 0, + fetch() { + const body = new ReadableStream({ + start(c) { + c.enqueue("a"); + c.enqueue(leading); + c.close(); + }, + }); + return new Response(body.pipeThrough(new TextEncoderStream())); + }, + }); + const text = await (await fetch(server.url)).text(); + expect(text).toBe("a\uFFFD"); +}); + +// Each encoder/decoder owns its own reusable scratch buffer; a chain of them +// must not let one stage observe another's buffer before the bytes are copied. +test("TextEncoderStream -> TextDecoderStream -> TextEncoderStream round-trips many chunks without corruption", async () => { + const chunks = [ + "ascii-only-1", + "mañana café ", // latin-1 non-ascii + "\u{1F499}".repeat(50), // surrogate pairs (BMP-out) + "x".repeat(8000), // larger than one internal CHUNK span + leading, // split surrogate across chunk boundary + trailing + "tail", + "\u{1F1EE}\u{1F1F3}zz", // regional indicators + Buffer.alloc(3000, "日").toString(), // 3-byte utf8 + "", + "end", + ]; + const expected = new TextEncoder().encode(chunks.join("")); + + const src = new ReadableStream({ + start(c) { + for (const s of chunks) c.enqueue(s); + c.close(); + }, + }); + + const out = Buffer.from( + await new Response( + src + .pipeThrough(new TextEncoderStream()) + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextEncoderStream()), + ).arrayBuffer(), + ); + + expect(out.byteLength).toBe(expected.byteLength); + expect(Buffer.compare(out, Buffer.from(expected))).toBe(0); +}); + +test("TextEncoderStream -> TextDecoderStream -> TextEncoderStream -> native HTTP sink round-trips", async () => { + const chunks = ["hello ", "\u{1F499}".repeat(200), leading, trailing, " world"]; + const expected = new TextEncoder().encode(chunks.join("")); + + await using server = Bun.serve({ + port: 0, + fetch() { + const body = new ReadableStream({ + start(c) { + for (const s of chunks) c.enqueue(s); + c.close(); + }, + }); + return new Response( + body + .pipeThrough(new TextEncoderStream()) + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextEncoderStream()), + ); + }, + }); + const out = Buffer.from(await (await fetch(server.url)).arrayBuffer()); + expect(out.byteLength).toBe(expected.byteLength); + expect(Buffer.compare(out, Buffer.from(expected))).toBe(0); +}); diff --git a/test/js/web/streams/compression.test.ts b/test/js/web/streams/compression.test.ts index adca372f0813..9ca2519b1d6d 100644 --- a/test/js/web/streams/compression.test.ts +++ b/test/js/web/streams/compression.test.ts @@ -1,6 +1,22 @@ import { describe, expect, test } from "bun:test"; import zlib from "node:zlib"; +// CompressionStream et al are C++ subclasses of JSTransformStream so that +// $inheritsTransformStream() returns true (node:stream utils). The JS-visible +// prototype chain is unchanged, and TransformStream.prototype's own brand- +// checked getters still reject them (Web IDL: separate interfaces). +test.each([ + () => new CompressionStream("gzip"), + () => new DecompressionStream("gzip"), + () => new TextEncoderStream(), + () => new TextDecoderStream(), +])("TransformStream.prototype getters reject native transform subclasses (%#)", ctor => { + const x = ctor(); + expect(x instanceof TransformStream).toBe(false); + const get = Object.getOwnPropertyDescriptor(TransformStream.prototype, "readable")!.get!; + expect(() => get.call(x)).toThrow(); +}); + describe("CompressionStream and DecompressionStream", () => { describe("brotli", () => { test("compresses data with brotli", async () => { @@ -321,6 +337,103 @@ describe("CompressionStream and DecompressionStream", () => { } }); }); + + // Chunks larger than 128KB are handed to the threadpool so the codec work + // doesn't block the JS thread; these cover byte-identical output on that + // path and that the write promise is observably asynchronous. + describe("large chunks (>128KB async codec path)", () => { + function randomBytes(len: number) { + const out = new Uint8Array(len); + for (let off = 0; off < len; off += 65536) { + crypto.getRandomValues(out.subarray(off, Math.min(off + 65536, len))); + } + return out; + } + + test("2MB chunk round-trips through CompressionStream('gzip') -> DecompressionStream('gzip')", async () => { + const big = randomBytes(2 * 1024 * 1024); + + const cs = new CompressionStream("gzip"); + const cw = cs.writable.getWriter(); + const compressedP = new Response(cs.readable).arrayBuffer(); + await cw.write(big); + await cw.close(); + const compressed = new Uint8Array(await compressedP); + expect(compressed.byteLength).toBeGreaterThan(0); + + const ds = new DecompressionStream("gzip"); + const dw = ds.writable.getWriter(); + const decompressedP = new Response(ds.readable).arrayBuffer(); + await dw.write(compressed); + await dw.close(); + const decompressed = Buffer.from(await decompressedP); + + expect(decompressed.byteLength).toBe(big.byteLength); + expect(Buffer.compare(decompressed, Buffer.from(big.buffer))).toBe(0); + }); + + test("DecompressionStream('gzip') handles a single >128KB compressed chunk", async () => { + const big = randomBytes(2 * 1024 * 1024); + // Incompressible input so the gzipped output itself exceeds the 128KB + // threshold and takes the threadpool path as a single write. + const compressed = zlib.gzipSync(big); + expect(compressed.byteLength).toBeGreaterThan(128 * 1024); + + const ds = new DecompressionStream("gzip"); + const dw = ds.writable.getWriter(); + const outP = new Response(ds.readable).arrayBuffer(); + await dw.write(compressed); + await dw.close(); + const out = Buffer.from(await outP); + + expect(out.byteLength).toBe(big.byteLength); + expect(Buffer.compare(out, Buffer.from(big.buffer))).toBe(0); + }); + + test("writing a 2MB chunk does not block the JS thread (setImmediate fires before the write settles)", async () => { + const big = randomBytes(2 * 1024 * 1024); + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + const drained = cs.readable.pipeTo(new WritableStream({ write() {} })); + + const order: string[] = []; + const writeP = writer.write(big).then(() => order.push("write")); + await new Promise(r => + setImmediate(() => { + order.push("immediate"); + r(); + }), + ); + await writeP; + expect(order).toEqual(["immediate", "write"]); + + await writer.close(); + await drained; + }); + + // Async-codec × native-sink: deliverAsync's m_nativeSinkPtr arm. The other + // >128KB tests drain via Response().arrayBuffer() (no native sink); the + // other Bun.serve tests enqueue ≤64KB (below the async threshold). + test("CompressionStream('gzip') -> native HTTP response sink handles a single >128KB chunk", async () => { + const big = randomBytes(256 * 1024); + await using server = Bun.serve({ + port: 0, + fetch() { + const body = new ReadableStream({ + start(c) { + c.enqueue(big.slice()); + c.close(); + }, + }); + return new Response(body.pipeThrough(new CompressionStream("gzip"))); + }, + }); + const res = await fetch(server.url); + const out = Buffer.from(await new Response(res.body!.pipeThrough(new DecompressionStream("gzip"))).arrayBuffer()); + expect(out.byteLength).toBe(big.byteLength); + expect(Buffer.compare(out, Buffer.from(big.buffer))).toBe(0); + }); + }); }); // Ported behaviors from Node v26's webstreams adapters @@ -363,6 +476,12 @@ describe("CompressionStream chunk handling (Node v26 semantics)", () => { test("rejects SharedArrayBuffer chunks with ERR_INVALID_ARG_TYPE", async () => { const cs = new CompressionStream("gzip"); const writer = cs.writable.getWriter(); + // Per the TransformStream spec the transform step (and its chunk + // validation) waits for the readable side to lift backpressure first. + cs.readable + .getReader() + .read() + .catch(() => {}); expect.assertions(1); try { await writer.write(new SharedArrayBuffer(8)); @@ -377,8 +496,6 @@ describe("CompressionStream chunk handling (Node v26 semantics)", () => { const reader = cs.readable.getReader(); const writeError = writer.write(42).catch(e => e); - // Without the kDestroyOnSyncError handling the readable side hangs - // forever here. const readError = reader.read().catch(e => e); const [we, re] = await Promise.all([writeError, readError]); @@ -411,6 +528,174 @@ describe("CompressionStream chunk handling (Node v26 semantics)", () => { } }); + // Native-sink output path: when cs.readable is consumed by a native JSSink + // (here HTTPResponseSink via Bun.serve), the transform arms write coder output + // straight to the sink's m_sinkPtr instead of wrapping each chunk in a + // JSUint8Array and enqueueing it on the readable. + test.each(["gzip", "brotli", "zstd", "deflate"] as const)( + "CompressionStream(%s) -> native HTTP response sink round-trips", + async format => { + await using server = Bun.serve({ + port: 0, + fetch() { + const body = new ReadableStream({ + start(c) { + for (let i = 0; i < 50; i++) c.enqueue(new Uint8Array(1024).fill(65 + (i % 26))); + c.close(); + }, + }); + return new Response(body.pipeThrough(new CompressionStream(format))); + }, + }); + const buf = new Uint8Array(await (await fetch(server.url)).arrayBuffer()); + const out = new Uint8Array( + await new Response(new Blob([buf]).stream().pipeThrough(new DecompressionStream(format))).arrayBuffer(), + ); + expect(out.byteLength).toBe(51200); + for (let i = 0; i < 50; i++) expect(out[i * 1024]).toBe(65 + (i % 26)); + }, + ); + + // Poll `get()` once per tick until it stays unchanged for 5 consecutive + // samples (parked on backpressure) or reaches `total` (ran away). The caller + // asserts the parked value is < total. + async function waitUntilStable(get: () => number, total: number) { + let last = get(); + let stable = 0; + while (stable < 5 && get() < total) { + await Bun.sleep(1); + const now = get(); + if (now === last) stable++; + else { + stable = 0; + last = now; + } + } + } + + // Native-sink backpressure: when the HTTP response sink's socket buffer fills + // (slow client), the transform arm's writeBytes returns a pending promise and + // the writable side parks on m_nativeSinkReadyPromise. Without that, a fast + // source with a stalled client fills the sink buffer unboundedly. + test("CompressionStream -> native HTTP sink applies backpressure to a stalled client", async () => { + let pulls = 0; + // Incompressible data so the gzipped output is ~as large as the input. + const chunk = crypto.getRandomValues(new Uint8Array(64 * 1024)); + // Backpressure parks after ~tens of pulls (a few MB of socket+sink buffer / + // 64KB); 200 is enough headroom to distinguish "parked" from "ran away" + // without pushing ~32MB through gzip+HTTP under debug+ASAN. + const TOTAL = 200; + await using server = Bun.serve({ + port: 0, + fetch() { + const body = new ReadableStream({ + pull(c) { + pulls++; + c.enqueue(chunk.slice()); + if (pulls >= TOTAL) c.close(); + }, + }); + return new Response(body.pipeThrough(new CompressionStream("gzip"))); + }, + }); + const res = await fetch(server.url); + const reader = res.body!.getReader(); + await reader.read(); + // Let the server's pull loop run until it either parks on backpressure or + // runs away to TOTAL. + await waitUntilStable(() => pulls, TOTAL); + const pullsWhileStalled = pulls; + while (!(await reader.read()).done) {} + // Without backpressure the pull loop reaches TOTAL while the client is + // stalled; with it, pulls stay bounded by the socket + sink buffer + // (~a few MB / 64KB ≈ tens of pulls). + expect(pullsWhileStalled).toBeLessThan(TOTAL); + expect(pulls).toBe(TOTAL); + }); + + test("request body -> DecompressionStream propagates backpressure to the client", async () => { + let clientPulls = 0; + let clientPullsWhileServerStalled = -1; + const chunk = crypto.getRandomValues(new Uint8Array(64 * 1024)); + const compressed = new Uint8Array( + await new Response(new Blob([chunk]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer(), + ); + const TOTAL = 200; + const { promise: drain, resolve: startDrain } = Promise.withResolvers(); + await using server = Bun.serve({ + port: 0, + async fetch(req) { + const reader = req.body!.pipeThrough(new DecompressionStream("gzip")).getReader(); + await reader.read(); + // Stall: let the client's pull loop either park or run away. + await waitUntilStable(() => clientPulls, TOTAL); + clientPullsWhileServerStalled = clientPulls; + startDrain(); + while (!(await reader.read()).done) {} + return new Response("ok"); + }, + }); + const body = new ReadableStream({ + pull(c) { + clientPulls++; + c.enqueue(compressed.slice()); + if (clientPulls >= TOTAL) c.close(); + }, + }); + const res = await fetch(server.url, { method: "POST", body, duplex: "half" } as RequestInit); + await drain; + expect(await res.text()).toBe("ok"); + expect(clientPullsWhileServerStalled).toBeGreaterThan(0); + expect(clientPullsWhileServerStalled).toBeLessThan(TOTAL); + expect(clientPulls).toBe(TOTAL); + }); + + test("req.clone().textStream() -> TextEncoderStream -> CompressionStream -> Response round-trips", async () => { + const input = Buffer.alloc(64 * 1024, "The quick brown fox. ").toString(); + await using server = Bun.serve({ + port: 0, + fetch(req) { + const cloned = req.clone(); + return new Response( + cloned.textStream().pipeThrough(new TextEncoderStream()).pipeThrough(new CompressionStream("gzip")), + ); + }, + }); + const res = await fetch(server.url, { method: "POST", body: input }); + const out = await new Response(res.body!.pipeThrough(new DecompressionStream("gzip"))).text(); + expect(out).toBe(input); + }); + + // readable highWaterMark is 1 (matching Node.js and Chromium), so a single + // write completes before any reader is attached. + test("a single write completes without a reader attached", async () => { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + await writer.write(new Uint8Array(1024)); + void writer.close(); + const out = await Array.fromAsync(cs.readable); + expect(out.length).toBeGreaterThan(0); + }); + + // Backpressure still kicks in once the readable queue is full. + test("a second write stays pending until the readable side is drained", async () => { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + const reader = cs.readable.getReader(); + + await writer.write(new Uint8Array(1024)); + const second = writer.write(new Uint8Array(1024)); + const raced = await Promise.race([second.then(() => "done"), Bun.sleep(0).then(() => "pending")]); + expect(raced).toBe("pending"); + + const { value } = await reader.read(); + expect(value!.byteLength).toBeGreaterThan(0); + await second; + + void writer.close(); + while (!(await reader.read()).done) {} + }); + // DecompressionStream rejects trailing bytes after the compressed data. Concatenated // gzip members are a single valid stream per RFC 1952 section 2.2, not trailing junk, // so they must still decode in full. diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 208d71801d38..4cbc9dff4dc4 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -7,7 +7,7 @@ import { readableStreamToText, } from "bun"; import { describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, isMacOS, isWindows, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isMacOS, isWindows, tempDir, tmpdirSync } from "harness"; import { mkfifo } from "mkfifo"; import { createReadStream, realpathSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -1720,11 +1720,14 @@ it("Bun.file().stream() read text from large file", async () => { // Guard against reading the same repeating chunks // There were bugs previously where the stream would // repeat the same chunk over and over again + // Debug+ASAN makes the ~260k SHA1 calls below ~50x slower; 1MB still spans + // multiple stream chunks so the repeating-chunk guard holds. + const targetSize = 1024 * 1024 * (isDebug || isASAN ? 1 : 10); var sink = new ArrayBufferSink(); - sink.start({ highWaterMark: 1024 * 1024 * 10 }); + sink.start({ highWaterMark: targetSize }); var written = 0; var i = 0; - while (written < 1024 * 1024 * 10) { + while (written < targetSize) { written += sink.write(Bun.SHA1.hash((i++).toString(10), "hex")); } const hugely = Buffer.from(sink.end()).toString(); @@ -1836,10 +1839,16 @@ recursiveFunction(); it("handles exceptions during empty stream creation", () => { expect(() => { + // Only the unwind frames nearest the stack limit exercise the + // ReadableStream__empty exception path this test covers; the remaining + // thousands of frames re-run the trivially-succeeding case and push + // debug+ASAN past the per-test timeout. + let unwound = 0; function foo() { try { foo(); } catch (e) {} + if (unwound++ > 256) return; const v8 = new Blob(); v8.stream(); }