From 29562818458cdc07f9636c5cc98c265af0a80f5f Mon Sep 17 00:00:00 2001 From: CountBleck Date: Mon, 28 Jul 2025 11:59:45 -0700 Subject: [PATCH] Support `WebAssembly.{instantiate,compile}Streaming()` (#20503) ### What does this PR do? This PR should fix #14219 and implement `WebAssembly.instantiateStreaming()` and `WebAssembly.compileStreaming()`. This is a mixture of WebKit's implementation (using a helper, `handleResponseOnStreamingAction`, also containing a fast-path for blobs) and some of Node.js's validation (error messages) and its builtin-based strategy to consume chunks from streams. `src/bun.js/bindings/GlobalObject.zig` has a helper function (`getBodyStreamOrBytesForWasmStreaming`), called by C++, to validate the response (like [Node.js](https://github.com/nodejs/node/blob/214e4db60ef55f1a59c93336ab956371b0b058ed/lib/internal/wasm_web_api.js) does) and to extract the data from the response, either as a slice/span (if we can get the data synchronously), or as a `ReadableStream` body (if the data is still pending or if it is a file/S3 `Blob`). In C++, `handleResponseOnStreamingAction` is called by `compileStreaming` and `instantiateStreaming` on the `JSC::GlobalObjectMethodTable`, just like in [WebKit](https://github.com/oven-sh/WebKit/blob/97ee3c598ac4226dd1c759739acf94f70cb10ed0/Source/WebCore/bindings/js/JSDOMGlobalObject.cpp#L517). It calls the aforementioned Zig helper for validation and getting the response data. The data is then fed into `JSC::Wasm::StreamingCompiler`. If the data is received as a `ReadableStream`, then we call a JS builtin in `WasmStreaming.ts` to iterate over each chunk of the stream, like [Node.js](https://github.com/nodejs/node/blob/214e4db60ef55f1a59c93336ab956371b0b058ed/lib/internal/wasm_web_api.js#L50-L52) does. The `JSC::Wasm::StreamingCompiler` is passed into JS through a new wrapper object, `WebCore::WasmStreamingCompiler`, like [Node.js](https://github.com/nodejs/node/blob/214e4db60ef55f1a59c93336ab956371b0b058ed/src/node_wasm_web_api.h) does. It has `addBytes`, `finalize`, `error`, and (unused) `cancel` methods to mirror the underlying JSC class. (If there's a simpler way to do this, please let me know...that would be very much appreciated) - [x] Code changes ### How did you verify your code works? I wrote automated tests (`test/js/web/fetch/wasm-streaming.test`). - [x] I included a test for the new code, or existing tests cover it - [x] I ran my tests locally and they pass (`bun-debug test test/js/web/fetch/wasm-streaming.test`) - [x] I checked the lifetime of memory allocated to verify it's (1) freed and (2) only freed when it should be (NOTE: consumed `AnyBlob` bodies are freed, and all other allocations are in C++ and either GCed or ref-counted) - [x] I included a test for the new code, or an existing test covers it (NOTE: via JS/TS unit test) - [x] JSValue used outside of the stack is either wrapped in a JSC.Strong or is JSValueProtect'ed (NOTE: N/A, JSValue never used outside the stack) - [x] I wrote TypeScript/JavaScript tests and they pass locally (`bun-debug test test/js/web/fetch/wasm-streaming.test`) --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> --- cmake/sources/CxxSources.txt | 1 + cmake/sources/JavaScriptSources.txt | 1 + src/bun.js/bindings/ErrorCode.ts | 1 + src/bun.js/bindings/JSGlobalObject.zig | 68 +++++ src/bun.js/bindings/ZigGlobalObject.cpp | 69 ++++- src/bun.js/bindings/ZigGlobalObject.h | 5 + .../bindings/webcore/DOMClientIsoSubspaces.h | 2 + src/bun.js/bindings/webcore/DOMIsoSubspaces.h | 1 + .../webcore/JSWasmStreamingCompiler.cpp | 238 +++++++++++++++++ .../webcore/JSWasmStreamingCompiler.h | 73 ++++++ src/js/builtins/WasmStreaming.ts | 11 + test/js/web/fetch/wasm-streaming.test.ts | 239 ++++++++++++++++++ 12 files changed, 705 insertions(+), 4 deletions(-) create mode 100644 src/bun.js/bindings/webcore/JSWasmStreamingCompiler.cpp create mode 100644 src/bun.js/bindings/webcore/JSWasmStreamingCompiler.h create mode 100644 src/js/builtins/WasmStreaming.ts create mode 100644 test/js/web/fetch/wasm-streaming.test.ts diff --git a/cmake/sources/CxxSources.txt b/cmake/sources/CxxSources.txt index 04e1d2c79d..ddc959fa37 100644 --- a/cmake/sources/CxxSources.txt +++ b/cmake/sources/CxxSources.txt @@ -350,6 +350,7 @@ src/bun.js/bindings/webcore/JSTextEncoderStream.cpp src/bun.js/bindings/webcore/JSTransformStream.cpp src/bun.js/bindings/webcore/JSTransformStreamDefaultController.cpp src/bun.js/bindings/webcore/JSURLSearchParams.cpp +src/bun.js/bindings/webcore/JSWasmStreamingCompiler.cpp src/bun.js/bindings/webcore/JSWebSocket.cpp src/bun.js/bindings/webcore/JSWorker.cpp src/bun.js/bindings/webcore/JSWorkerOptions.cpp diff --git a/cmake/sources/JavaScriptSources.txt b/cmake/sources/JavaScriptSources.txt index ee4b684713..f6a44973e0 100644 --- a/cmake/sources/JavaScriptSources.txt +++ b/cmake/sources/JavaScriptSources.txt @@ -29,6 +29,7 @@ src/js/builtins/TransformStream.ts src/js/builtins/TransformStreamDefaultController.ts src/js/builtins/TransformStreamInternals.ts src/js/builtins/UtilInspect.ts +src/js/builtins/WasmStreaming.ts src/js/builtins/WritableStreamDefaultController.ts src/js/builtins/WritableStreamDefaultWriter.ts src/js/builtins/WritableStreamInternals.ts diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index 33dcec4582..fcdf9ef6c2 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -258,6 +258,7 @@ const errors: ErrorCodeMapping = [ ["ERR_ZSTD_INVALID_PARAM", RangeError], ["ERR_USE_AFTER_CLOSE", Error], ["ERR_WASI_NOT_STARTED", Error], + ["ERR_WEBASSEMBLY_RESPONSE", TypeError], ["ERR_WORKER_INIT_FAILED", Error], ["ERR_WORKER_NOT_RUNNING", Error], ["ERR_ZLIB_INITIALIZATION_FAILED", Error], diff --git a/src/bun.js/bindings/JSGlobalObject.zig b/src/bun.js/bindings/JSGlobalObject.zig index 4c88bc8dee..7cbada9a94 100644 --- a/src/bun.js/bindings/JSGlobalObject.zig +++ b/src/bun.js/bindings/JSGlobalObject.zig @@ -819,6 +819,73 @@ pub const JSGlobalObject = opaque { @panic("A C++ exception occurred"); } + extern fn JSC__Wasm__StreamingCompiler__addBytes(streaming_compiler: *anyopaque, bytes_ptr: [*]const u8, bytes_len: usize) void; + + fn getBodyStreamOrBytesForWasmStreaming( + this: *jsc.JSGlobalObject, + response_value: jsc.JSValue, + streaming_compiler: *anyopaque, + ) bun.JSError!jsc.JSValue { + const response = jsc.WebCore.Response.fromJS(response_value) orelse return this.throwInvalidArgumentTypeValue2( + "source", + "an instance of Response or an Promise resolving to Response", + response_value, + ); + + const content_type = if (try response.getContentType()) |content_type| + content_type.toZigString() + else + ZigString.static("null").*; + + if (!content_type.eqlComptime("application/wasm")) { + return this.ERR(.WEBASSEMBLY_RESPONSE, "WebAssembly response has unsupported MIME type '{}'", .{content_type}).throw(); + } + + if (!response.isOK()) { + return this.ERR(.WEBASSEMBLY_RESPONSE, "WebAssembly response has status code {}", .{response.statusCode()}).throw(); + } + + if (response.getBodyUsed(this).toBoolean()) { + return this.ERR(.WEBASSEMBLY_RESPONSE, "WebAssembly response body has already been used", .{}).throw(); + } + + const body = response.getBodyValue(); + if (body.* == .Error) { + return this.throwValue(body.Error.toJS(this)); + } + + // We're done validating. From now on, deal with extracting the body. + body.toBlobIfPossible(); + + var any_blob = switch (body.*) { + .Locked => body.tryUseAsAnyBlob() orelse return body.toReadableStream(this), + else => body.useAsAnyBlob(), + }; + + if (any_blob.store()) |store| { + if (store.data != .bytes) { + // This is a file or an S3 object, which aren't accessible synchronously. + // (using any_blob.slice() would return a bogus empty slice) + + // Logic from JSC.WebCore.Body.Value.toReadableStream + var blob = any_blob.Blob; + defer blob.detach(); + + blob.resolveSize(); + return jsc.WebCore.ReadableStream.fromBlobCopyRef(this, &blob, blob.size); + } + } + + defer any_blob.detach(); + + // Push the blob contents into the streaming compiler by passing a pointer and + // length, and return null to signify this has been done. + const slice = any_blob.slice(); + JSC__Wasm__StreamingCompiler__addBytes(streaming_compiler, slice.ptr, slice.len); + + return .null; + } + pub fn createError( globalThis: *jsc.JSGlobalObject, comptime fmt: string, @@ -875,6 +942,7 @@ pub const JSGlobalObject = opaque { @export(&resolve, .{ .name = "Zig__GlobalObject__resolve" }); @export(&reportUncaughtException, .{ .name = "Zig__GlobalObject__reportUncaughtException" }); @export(&onCrash, .{ .name = "Zig__GlobalObject__onCrash" }); + @export(&jsc.host_fn.wrap3(getBodyStreamOrBytesForWasmStreaming), .{ .name = "Zig__GlobalObject__getBodyStreamOrBytesForWasmStreaming" }); } }; diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index 5d566de68a..91fc27aa34 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -127,6 +127,7 @@ #include "JSTransformStream.h" #include "JSTransformStreamDefaultController.h" #include "JSURLSearchParams.h" +#include "JSWasmStreamingCompiler.h" #include "JSWebSocket.h" #include "JSWorker.h" #include "JSWritableStream.h" @@ -1242,8 +1243,8 @@ const JSC::GlobalObjectMethodTable& GlobalObject::globalObjectMethodTable() &scriptExecutionStatus, nullptr, // reportViolationForUnsafeEval nullptr, // defaultLanguage - nullptr, // compileStreaming - nullptr, // instantiateStreaming + &compileStreaming, + &instantiateStreaming, &Zig::deriveShadowRealmGlobalObject, &codeForEval, // codeForEval &canCompileStrings, // canCompileStrings @@ -1272,8 +1273,8 @@ const JSC::GlobalObjectMethodTable& EvalGlobalObject::globalObjectMethodTable() &scriptExecutionStatus, nullptr, // reportViolationForUnsafeEval nullptr, // defaultLanguage - nullptr, // compileStreaming - nullptr, // instantiateStreaming + &compileStreaming, + &instantiateStreaming, &Zig::deriveShadowRealmGlobalObject, &codeForEval, // codeForEval &canCompileStrings, // canCompileStrings @@ -3077,6 +3078,11 @@ void GlobalObject::finishCreation(VM& vm) init.set(JSC::JSFunction::create(init.vm, init.owner, utilInspectStylizeWithNoColorCodeGenerator(init.vm), init.owner)); }); + m_wasmStreamingConsumeStreamFunction.initLater( + [](const Initializer& init) { + init.set(JSC::JSFunction::create(init.vm, init.owner, wasmStreamingConsumeStreamCodeGenerator(init.vm), init.owner)); + }); + m_nativeMicrotaskTrampoline.initLater( [](const Initializer& init) { init.set(JSFunction::create(init.vm, init.owner, 2, ""_s, functionNativeMicrotaskTrampoline, ImplementationVisibility::Public)); @@ -4443,6 +4449,61 @@ JSC::JSValue EvalGlobalObject::moduleLoaderEvaluate(JSGlobalObject* lexicalGloba return result; } +extern "C" JSC::EncodedJSValue Zig__GlobalObject__getBodyStreamOrBytesForWasmStreaming(JSGlobalObject*, EncodedJSValue response, JSC::Wasm::StreamingCompiler* compiler); + +extern "C" void JSC__Wasm__StreamingCompiler__addBytes(JSC::Wasm::StreamingCompiler* compiler, const uint8_t* spanPtr, size_t spanSize) +{ + compiler->addBytes(std::span(spanPtr, spanSize)); +} + +static JSC::JSPromise* handleResponseOnStreamingAction(JSGlobalObject* lexicalGlobalObject, JSC::JSValue source, JSC::Wasm::CompilerMode mode, JSC::JSObject* importObject) +{ + auto globalObject = defaultGlobalObject(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSC::JSLockHolder locker(vm); + + auto promise = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + auto compiler = JSC::Wasm::StreamingCompiler::create(vm, mode, globalObject, promise, importObject); + + // getBodyStreamOrBytesForWasmStreaming throws the proper exception. Since this is being + // executed in a .then(...) callback, throwing is perfectly fine. + + auto readableStreamMaybe = JSC::JSValue::decode(Zig__GlobalObject__getBodyStreamOrBytesForWasmStreaming( + globalObject, JSC::JSValue::encode(source), compiler.ptr())); + + RETURN_IF_EXCEPTION(scope, nullptr); + + // We were able to get the slice synchronously. + if (readableStreamMaybe.isNull()) { + compiler->finalize(globalObject); + + // Apparently rejecting a Promise (done in JSC::Wasm::StreamingCompiler#fail) can throw + RETURN_IF_EXCEPTION(scope, nullptr); + return promise; + } + + auto wrapper = WebCore::toJSNewlyCreated(globalObject, globalObject, WTFMove(compiler)); + auto builtin = globalObject->wasmStreamingConsumeStreamFunction(); + auto callData = JSC::getCallData(builtin); + MarkedArgumentBuffer arguments; + + arguments.append(readableStreamMaybe); + JSC::call(globalObject, builtin, callData, wrapper, arguments); + scope.assertNoException(); + return promise; +} + +JSC::JSPromise* GlobalObject::compileStreaming(JSGlobalObject* globalObject, JSC::JSValue source) +{ + return handleResponseOnStreamingAction(globalObject, source, JSC::Wasm::CompilerMode::Validation, nullptr); +} + +JSC::JSPromise* GlobalObject::instantiateStreaming(JSGlobalObject* globalObject, JSC::JSValue source, JSC::JSObject* importObject) +{ + return handleResponseOnStreamingAction(globalObject, source, JSC::Wasm::CompilerMode::FullCompile, importObject); +} + GlobalObject::PromiseFunctions GlobalObject::promiseHandlerID(Zig::FFIFunction handler) { if (handler == BunServe__onResolvePlugins) { diff --git a/src/bun.js/bindings/ZigGlobalObject.h b/src/bun.js/bindings/ZigGlobalObject.h index bb4f13126d..003afb1ff7 100644 --- a/src/bun.js/bindings/ZigGlobalObject.h +++ b/src/bun.js/bindings/ZigGlobalObject.h @@ -193,6 +193,8 @@ public: static JSC::JSInternalPromise* moduleLoaderFetch(JSGlobalObject*, JSC::JSModuleLoader*, JSC::JSValue key, JSC::JSValue parameters, JSC::JSValue script); static JSC::JSObject* moduleLoaderCreateImportMetaProperties(JSGlobalObject*, JSC::JSModuleLoader*, JSC::JSValue key, JSC::JSModuleRecord*, JSC::JSValue val); static JSC::JSValue moduleLoaderEvaluate(JSGlobalObject*, JSC::JSModuleLoader*, JSValue key, JSValue moduleRecordValue, JSValue scriptFetcher, JSValue sentValue, JSValue resumeMode); + static JSC::JSPromise* compileStreaming(JSGlobalObject*, JSC::JSValue source); + static JSC::JSPromise* instantiateStreaming(JSGlobalObject*, JSC::JSValue source, JSC::JSObject* importObject); static ScriptExecutionStatus scriptExecutionStatus(JSGlobalObject*, JSObject*); static void promiseRejectionTracker(JSGlobalObject*, JSC::JSPromise*, JSC::JSPromiseRejectionOperation); @@ -274,6 +276,8 @@ public: JSC::JSFunction* utilInspectStylizeColorFunction() const { return m_utilInspectStylizeColorFunction.getInitializedOnMainThread(this); } JSC::JSFunction* utilInspectStylizeNoColorFunction() const { return m_utilInspectStylizeNoColorFunction.getInitializedOnMainThread(this); } + JSC::JSFunction* wasmStreamingConsumeStreamFunction() const { return m_wasmStreamingConsumeStreamFunction.getInitializedOnMainThread(this); } + JSObject* requireFunctionUnbound() const { return m_requireFunctionUnbound.getInitializedOnMainThread(this); } JSObject* requireResolveFunctionUnbound() const { return m_requireResolveFunctionUnbound.getInitializedOnMainThread(this); } Bun::InternalModuleRegistry* internalModuleRegistry() const { return m_internalModuleRegistry.getInitializedOnMainThread(this); } @@ -561,6 +565,7 @@ public: V(private, LazyPropertyOfGlobalObject, m_utilInspectOptionsStructure) \ V(private, LazyPropertyOfGlobalObject, m_utilInspectStylizeColorFunction) \ V(private, LazyPropertyOfGlobalObject, m_utilInspectStylizeNoColorFunction) \ + V(private, LazyPropertyOfGlobalObject, m_wasmStreamingConsumeStreamFunction) \ V(private, LazyPropertyOfGlobalObject, m_lazyReadableStreamPrototypeMap) \ V(private, LazyPropertyOfGlobalObject, m_requireMap) \ V(private, LazyPropertyOfGlobalObject, m_esmRegistryMap) \ diff --git a/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h b/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h index 427954d53c..cfe8e1f653 100644 --- a/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h +++ b/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h @@ -72,6 +72,8 @@ public: std::unique_ptr m_clientSubspaceForJSS3File; std::unique_ptr m_clientSubspaceForJSX509Certificate; std::unique_ptr m_clientSubspaceForJSNodePerformanceHooksHistogram; + std::unique_ptr m_clientSubspaceForWasmStreamingCompiler; + #include "ZigGeneratedClasses+DOMClientIsoSubspaces.h" /* --- bun --- */ diff --git a/src/bun.js/bindings/webcore/DOMIsoSubspaces.h b/src/bun.js/bindings/webcore/DOMIsoSubspaces.h index 12a275ca46..9335b8a3f8 100644 --- a/src/bun.js/bindings/webcore/DOMIsoSubspaces.h +++ b/src/bun.js/bindings/webcore/DOMIsoSubspaces.h @@ -69,6 +69,7 @@ public: std::unique_ptr m_subspaceForJSS3File; std::unique_ptr m_subspaceForJSX509Certificate; std::unique_ptr m_subspaceForJSNodePerformanceHooksHistogram; + std::unique_ptr m_subspaceForWasmStreamingCompiler; #include "ZigGeneratedClasses+DOMIsoSubspaces.h" /*-- BUN --*/ diff --git a/src/bun.js/bindings/webcore/JSWasmStreamingCompiler.cpp b/src/bun.js/bindings/webcore/JSWasmStreamingCompiler.cpp new file mode 100644 index 0000000000..3b327f5b61 --- /dev/null +++ b/src/bun.js/bindings/webcore/JSWasmStreamingCompiler.cpp @@ -0,0 +1,238 @@ +#include "config.h" +#include "JSWasmStreamingCompiler.h" + +#include "DOMClientIsoSubspaces.h" +#include "DOMIsoSubspaces.h" +#include "JSDOMBinding.h" +#include "JSDOMOperation.h" +#include + +#include "ErrorCode.h" + +namespace WebCore { + +using namespace JSC; + +// Define the toWrapped template function for WasmStreamingCompiler +template +Wasm::StreamingCompiler* toWrapped(JSGlobalObject& lexicalGlobalObject, ExceptionThrower&& exceptionThrower, JSValue value) +{ + auto& vm = getVM(&lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* impl = JSWasmStreamingCompiler::toWrapped(vm, value); + if (!impl) [[unlikely]] + exceptionThrower(lexicalGlobalObject, scope); + return impl; +} + +static JSC_DECLARE_HOST_FUNCTION(jsWasmStreamingCompilerPrototypeFunction_addBytes); +static JSC_DECLARE_HOST_FUNCTION(jsWasmStreamingCompilerPrototypeFunction_finalize); +static JSC_DECLARE_HOST_FUNCTION(jsWasmStreamingCompilerPrototypeFunction_fail); +static JSC_DECLARE_HOST_FUNCTION(jsWasmStreamingCompilerPrototypeFunction_cancel); + +class JSWasmStreamingCompilerPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static JSWasmStreamingCompilerPrototype* create(JSC::VM& vm, JSDOMGlobalObject* globalObject, JSC::Structure* structure) + { + JSWasmStreamingCompilerPrototype* ptr = new (NotNull, JSC::allocateCell(vm)) JSWasmStreamingCompilerPrototype(vm, globalObject, structure); + ptr->finishCreation(vm); + return ptr; + } + + DECLARE_INFO; + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSWasmStreamingCompilerPrototype, 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: + JSWasmStreamingCompilerPrototype(JSC::VM& vm, JSC::JSGlobalObject*, JSC::Structure* structure) + : JSC::JSNonFinalObject(vm, structure) + { + } + + void finishCreation(JSC::VM&); +}; + +STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSWasmStreamingCompilerPrototype, JSWasmStreamingCompilerPrototype::Base); + +static const HashTableValue JSWasmStreamingCompilerPrototypeTableValues[] = { + { "addBytes"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWasmStreamingCompilerPrototypeFunction_addBytes, 1 } }, + { "finalize"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWasmStreamingCompilerPrototypeFunction_finalize, 0 } }, + { "fail"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWasmStreamingCompilerPrototypeFunction_fail, 1 } }, + { "cancel"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWasmStreamingCompilerPrototypeFunction_cancel, 0 } } +}; + +const ClassInfo JSWasmStreamingCompilerPrototype::s_info = { "WasmStreamingCompiler"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSWasmStreamingCompilerPrototype) }; + +void JSWasmStreamingCompilerPrototype::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSWasmStreamingCompiler::info(), JSWasmStreamingCompilerPrototypeTableValues, *this); + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +const ClassInfo JSWasmStreamingCompiler::s_info = { "WasmStreamingCompiler"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSWasmStreamingCompiler) }; + +JSWasmStreamingCompiler::JSWasmStreamingCompiler(Structure* structure, JSDOMGlobalObject& globalObject, Ref&& impl) + : JSDOMWrapper(structure, globalObject, WTFMove(impl)) +{ +} + +void JSWasmStreamingCompiler::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); +} + +JSObject* JSWasmStreamingCompiler::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + auto* structure = JSWasmStreamingCompilerPrototype::createStructure(vm, &globalObject, globalObject.objectPrototype()); + structure->setMayBePrototype(true); + return JSWasmStreamingCompilerPrototype::create(vm, &globalObject, structure); +} + +JSObject* JSWasmStreamingCompiler::prototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + return getDOMPrototype(vm, globalObject); +} + +void JSWasmStreamingCompiler::destroy(JSCell* cell) +{ + auto* thisObject = static_cast(cell); + thisObject->JSWasmStreamingCompiler::~JSWasmStreamingCompiler(); +} + +static inline EncodedJSValue jsWasmStreamingCompilerPrototypeFunction_addBytesBody(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + + auto chunkValue = callFrame->uncheckedArgument(0); + + // See getWasmBufferFromValue in JSC's JSWebAssemblyHelpers.h + if (auto arrayBufferView = jsDynamicCast(chunkValue)) { + if (isTypedArrayType(arrayBufferView->type())) { + validateTypedArray(lexicalGlobalObject, arrayBufferView); + RETURN_IF_EXCEPTION(throwScope, {}); + } else { + // DataView + IdempotentArrayBufferByteLengthGetter getter; + if (!jsCast(arrayBufferView)->viewByteLength(getter)) [[unlikely]] { + throwTypeError(lexicalGlobalObject, throwScope, typedArrayBufferHasBeenDetachedErrorMessage); + return {}; + } + } + + impl.addBytes(arrayBufferView->span()); + return encodedJSUndefined(); + } else if (auto arrayBuffer = jsDynamicCast(chunkValue)) { + auto arrayBufferImpl = arrayBuffer->impl(); + if (arrayBufferImpl->isDetached()) { + throwTypeError(lexicalGlobalObject, throwScope, typedArrayBufferHasBeenDetachedErrorMessage); + return {}; + } + + impl.addBytes(arrayBufferImpl->span()); + return encodedJSUndefined(); + } else [[unlikely]] { + // See WasmStreamingObject::Push in Node.js's node_wasm_web_api.cc + return Bun::ERR::INVALID_ARG_TYPE(throwScope, lexicalGlobalObject, "chunk must be an ArrayBufferView or an ArrayBuffer"_s); + } +} + +JSC_DEFINE_HOST_FUNCTION(jsWasmStreamingCompilerPrototypeFunction_addBytes, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "addBytes"_s); +} + +static inline EncodedJSValue jsWasmStreamingCompilerPrototypeFunction_finalizeBody(JSGlobalObject* lexicalGlobalObject, CallFrame*, typename IDLOperation::ClassParameter castedThis) +{ + castedThis->wrapped().finalize(lexicalGlobalObject); + return encodedJSUndefined(); +} + +JSC_DEFINE_HOST_FUNCTION(jsWasmStreamingCompilerPrototypeFunction_finalize, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "finalize"_s); +} + +static inline EncodedJSValue jsWasmStreamingCompilerPrototypeFunction_failBody(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + // This should never fail since this method is only called internally + auto error = callFrame->uncheckedArgument(0); + castedThis->wrapped().fail(lexicalGlobalObject, error); + return encodedJSUndefined(); +} + +JSC_DEFINE_HOST_FUNCTION(jsWasmStreamingCompilerPrototypeFunction_fail, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "fail"_s); +} + +static inline EncodedJSValue jsWasmStreamingCompilerPrototypeFunction_cancelBody(JSGlobalObject*, CallFrame*, typename IDLOperation::ClassParameter castedThis) +{ + castedThis->wrapped().cancel(); + return encodedJSUndefined(); +} + +JSC_DEFINE_HOST_FUNCTION(jsWasmStreamingCompilerPrototypeFunction_cancel, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "cancel"_s); +} + +GCClient::IsoSubspace* JSWasmStreamingCompiler::subspaceForImpl(VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForWasmStreamingCompiler.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForWasmStreamingCompiler = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForWasmStreamingCompiler.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForWasmStreamingCompiler = std::forward(space); }); +} + +void JSWasmStreamingCompiler::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) +{ + auto* thisObject = jsCast(cell); + analyzer.setWrappedObjectForCell(cell, &thisObject->wrapped()); + Base::analyzeHeap(cell, analyzer); +} + +bool JSWasmStreamingCompilerOwner::isReachableFromOpaqueRoots(JSC::Handle handle, void*, AbstractSlotVisitor&, ASCIILiteral*) +{ + return false; +} + +void JSWasmStreamingCompilerOwner::finalize(JSC::Handle handle, void* context) +{ + auto* jsWasmStreamingCompiler = static_cast(handle.slot()->asCell()); + auto& world = *static_cast(context); + uncacheWrapper(world, &jsWasmStreamingCompiler->wrapped(), jsWasmStreamingCompiler); +} + +JSValue toJSNewlyCreated(JSGlobalObject*, JSDOMGlobalObject* globalObject, Ref&& impl) +{ + return createWrapper(globalObject, WTFMove(impl)); +} + +JSValue toJS(JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, Wasm::StreamingCompiler& impl) +{ + return wrap(lexicalGlobalObject, globalObject, impl); +} + +Wasm::StreamingCompiler* JSWasmStreamingCompiler::toWrapped(VM& vm, JSValue value) +{ + if (auto* wrapper = jsDynamicCast(value)) + return &wrapper->wrapped(); + return nullptr; +} + +} diff --git a/src/bun.js/bindings/webcore/JSWasmStreamingCompiler.h b/src/bun.js/bindings/webcore/JSWasmStreamingCompiler.h new file mode 100644 index 0000000000..86bdc4db83 --- /dev/null +++ b/src/bun.js/bindings/webcore/JSWasmStreamingCompiler.h @@ -0,0 +1,73 @@ +#pragma once + +#include "JSDOMWrapper.h" +#include "JavaScriptCore/WasmStreamingCompiler.h" +#include + +namespace WebCore { + +class JSWasmStreamingCompiler : public JSDOMWrapper { +public: + using Base = JSDOMWrapper; + static JSWasmStreamingCompiler* create(JSC::Structure* structure, JSDOMGlobalObject* globalObject, Ref&& impl) + { + JSWasmStreamingCompiler* ptr = new (NotNull, JSC::allocateCell(globalObject->vm())) JSWasmStreamingCompiler(structure, *globalObject, WTFMove(impl)); + ptr->finishCreation(globalObject->vm()); + return ptr; + } + + static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); + static JSC::JSObject* prototype(JSC::VM&, JSDOMGlobalObject&); + static JSC::Wasm::StreamingCompiler* toWrapped(JSC::VM&, JSC::JSValue); + static void destroy(JSC::JSCell*); + + 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(), JSC::NonArray); + } + + // static JSC::JSValue getConstructor(JSC::VM&, const JSC::JSGlobalObject*); + 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); + static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); + +protected: + JSWasmStreamingCompiler(JSC::Structure*, JSDOMGlobalObject&, Ref&&); + + void finishCreation(JSC::VM&); +}; +class JSWasmStreamingCompilerOwner final : public JSC::WeakHandleOwner { +public: + bool isReachableFromOpaqueRoots(JSC::Handle, void* context, JSC::AbstractSlotVisitor&, ASCIILiteral*) final; + void finalize(JSC::Handle, void* context) final; +}; + +inline JSC::WeakHandleOwner* wrapperOwner(DOMWrapperWorld&, JSC::Wasm::StreamingCompiler*) +{ + static NeverDestroyed owner; + return &owner.get(); +} + +inline void* wrapperKey(JSC::Wasm::StreamingCompiler* wrappableObject) +{ + return wrappableObject; +} + +JSC::JSValue toJS(JSC::JSGlobalObject*, JSDOMGlobalObject*, JSC::Wasm::StreamingCompiler&); +inline JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, JSC::Wasm::StreamingCompiler* impl) { return impl ? toJS(lexicalGlobalObject, globalObject, *impl) : JSC::jsNull(); } +JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject*, Ref&&); +inline JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, RefPtr&& impl) { return impl ? toJSNewlyCreated(lexicalGlobalObject, globalObject, impl.releaseNonNull()) : JSC::jsNull(); } + +template<> struct JSDOMWrapperConverterTraits { + using WrapperClass = JSWasmStreamingCompiler; + using ToWrappedReturnType = JSC::Wasm::StreamingCompiler*; +}; + +} // namespace WebCore diff --git a/src/js/builtins/WasmStreaming.ts b/src/js/builtins/WasmStreaming.ts new file mode 100644 index 0000000000..dbd05df925 --- /dev/null +++ b/src/js/builtins/WasmStreaming.ts @@ -0,0 +1,11 @@ +export async function consumeStream(this: any, stream: ReadableStream) { + // NOTE: We're not using this.cancel()...where should that be used? + try { + for await (const chunk of stream) this.addBytes(chunk); + } catch (error) { + this.fail(error); + return; + } + + this.finalize(); +} diff --git a/test/js/web/fetch/wasm-streaming.test.ts b/test/js/web/fetch/wasm-streaming.test.ts new file mode 100644 index 0000000000..d0a53ebed8 --- /dev/null +++ b/test/js/web/fetch/wasm-streaming.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, test } from "bun:test"; +import { tmpdirSync } from "harness"; + +import { ok } from "node:assert/strict"; + +const wasmDataUriPrefix = "data:application/wasm;base64,"; + +// (module +// (import "env" "reciprocal" (func $reciprocal (param f64) (result f64))) +// (export "div" (func $div)) +// (func $div (param f64 f64) (result f64) +// (f64.mul +// (local.get 0) +// (call $reciprocal (local.get 1)) +// ) +// ) +// ) +const simpleWasm = "AGFzbQEAAAABDAJgAXwBfGACfHwBfAISAQNlbnYKcmVjaXByb2NhbAAAAwIBAQcHAQNkaXYAAQoLAQkAIAAgARAAogs="; +const simpleWasmUri = wasmDataUriPrefix + simpleWasm; + +// (module +// (export "add" (func $add)) +// (func $add (param i32 i32) (result i32) +// (i32.add (local.get 0) (local.get 1)) +// ) +// ) +const simplerWasmUri = wasmDataUriPrefix + "AGFzbQEAAAABBwFgAn9/AX8DAgEABwcBA2FkZAAACgkBBwAgACABags="; + +// (module +// (export "foo" (func $foo)) +// (func $foo (param i64) (result f64) +// local.get 0 +// i64.extend8_s ;; 0xC2 +// f64.reinterpret_i64 ;; 0xBF +// ) +// ) +const validUtf8Wasm = + // The ¿ near the end of the string is represented by two bytes (0xC2 and 0xBF) in UTF-8. + "\x00asm\x01\x00\x00\x00\x01\x06\x01`\x01~\x01|\x03\x02\x01\x00\x07\x07\x01\x03foo\x00\x00\n\b\x01\x06\x00 \x00¿\x0B"; + +const responseFromStream = (pull: (controller: ReadableStreamDefaultController) => void | PromiseLike) => + new Response(new ReadableStream({ pull }), { + headers: { + "Content-Type": "application/wasm", + }, + }); + +describe("WebAssembly.compileStreaming", () => { + test("compiles a non-streaming Response", async () => { + const response = await fetch(simpleWasmUri); + expect(WebAssembly.compileStreaming(response)).resolves.toBeInstanceOf(WebAssembly.Module); + }); + + test("compiles a resolved Promise to a non-streaming Response", async () => { + const promise = Promise.resolve(await fetch(simpleWasmUri)); + expect(WebAssembly.compileStreaming(promise)).resolves.toBeInstanceOf(WebAssembly.Module); + }); + + test("compiles a pending Promise to a non-streaming Response", async () => { + const response = await fetch(simpleWasmUri); + const promise = Bun.sleep(100).then(() => response); + expect(WebAssembly.compileStreaming(promise)).resolves.toBeInstanceOf(WebAssembly.Module); + }); + + // Errors: + + test("doesn't compile a rejected Promise", async () => { + const error = new Error("sudden explosion"); + const promise = Promise.reject(error); + expect(WebAssembly.compileStreaming(promise)).rejects.toBe(error); + }); + + test("doesn't compile a non-Response", async () => { + const nonResponse = Buffer.from("not a Response"); + // @ts-expect-error nonResponse is not a Response + expect(WebAssembly.compileStreaming(nonResponse)).rejects.toThrow( + `The "source" argument must be an instance of Response or an Promise resolving to Response. Received an instance of Buffer`, + ); + }); + + test("doesn't compile a response with the wrong MIME type", async () => { + const response = await fetch("data:image/png;base64," + simpleWasm); + expect(WebAssembly.compileStreaming(response)).rejects.toThrow( + "WebAssembly response has unsupported MIME type 'image/png'", + ); + }); + + test("doesn't compile a Response that isn't OK", async () => { + const response = new Response(Buffer.from(simpleWasm), { + headers: { + "Content-Type": "application/wasm", + }, + status: 418, + }); + + expect(WebAssembly.compileStreaming(response)).rejects.toThrow("WebAssembly response has status code 418"); + }); + + test("doesn't compile a used streaming response", async () => { + let i = 0; + const response = responseFromStream(async controller => { + controller.enqueue(new Uint8Array([1, 2, 3])); + if (i == 3) controller.close(); + i++; + }); + + // @ts-expect-error ReadableStreams are in fact async iterables + for await (const _ of response.body); // Consume the stream + ok(response.bodyUsed); + + expect(WebAssembly.compileStreaming(response)).rejects.toThrow("WebAssembly response body has already been used"); + }); + + test("doesn't compile a streaming response that throws while streaming", async () => { + let i = 0; + const error = new Error("sudden explosion in stream"); + const response = responseFromStream(async controller => { + controller.enqueue(new Uint8Array([1, 2, 3])); + if (i == 3) throw error; + i++; + }); + + expect(WebAssembly.compileStreaming(response)).rejects.toBe(error); + }); + + test("doesn't compile a streaming response that yields neither ArrayBuffer nor ArrayBufferView", async () => { + const response = responseFromStream(async controller => { + controller.enqueue("something random"); + }); + + expect(WebAssembly.compileStreaming(response)).rejects.toThrow( + "chunk must be an ArrayBufferView or an ArrayBuffer", + ); + }); + + test("doesn't compile a streaming response that yields a detached TypedArray", async () => { + const response = responseFromStream(async controller => { + const array = new Uint8Array(123); + array.buffer.transfer(); + controller.enqueue(array); + }); + + expect(WebAssembly.compileStreaming(response)).rejects.toThrow( + "Underlying ArrayBuffer has been detached from the view or out-of-bounds", + ); + }); + + test("doesn't compile a streaming response that yields a detached ArrayBuffer", async () => { + const response = responseFromStream(async controller => { + const buffer = new ArrayBuffer(123); + buffer.transfer(); + controller.enqueue(buffer); + }); + + expect(WebAssembly.compileStreaming(response)).rejects.toThrow( + "Underlying ArrayBuffer has been detached from the view or out-of-bounds", + ); + }); + + test("doesn't compile a response that isn't valid WebAssembly", async () => { + const response = await fetch("data:application/wasm,This is not actually Wasm"); + expect(WebAssembly.compileStreaming(response)).rejects.toBeInstanceOf(WebAssembly.CompileError); + }); +}); + +describe("WebAssembly.instantiateStreaming", () => { + const imports = { + env: { + reciprocal: (x: number) => 1 / x, + }, + }; + + const instantiateAndGetExports = async ( + responseOrPromise: Response | PromiseLike, + importsMaybe?: Bun.WebAssembly.Imports, + ) => { + const { instance } = await WebAssembly.instantiateStreaming(responseOrPromise, importsMaybe); + return instance.exports; + }; + + test("instantiates a non-streaming response", async () => { + const response = await fetch(simpleWasmUri); + expect(instantiateAndGetExports(response, imports)).resolves.toHaveProperty("div"); + }); + + test("instantiates a non-streaming response, without an import object", async () => { + const response = await fetch(simplerWasmUri); + expect(instantiateAndGetExports(response)).resolves.toHaveProperty("add"); + }); + + test("instantiates a pending Promise to a non-streaming response", async () => { + const response = await fetch(simpleWasmUri); + const promise = Bun.sleep(100).then(() => response); + expect(instantiateAndGetExports(promise, imports)).resolves.toHaveProperty("div"); + }); + + test("instantiates a Bun.file() response", async () => { + const path = tmpdirSync() + "/simple.wasm"; + await Bun.write(path, Buffer.from(simpleWasm, "base64")); + + const response = new Response(Bun.file(path)); + expect(instantiateAndGetExports(response, imports)).resolves.toHaveProperty("div"); + }); + + test("instantiates a ReadableStream response", async () => { + const buffer = Buffer.from(simpleWasm, "base64"); + let i = 0; + const response = responseFromStream(async controller => { + const chunkSize = 10; + + await Bun.sleep(10); + controller.enqueue(buffer.subarray(i, i + chunkSize)); + + i += chunkSize; + if (i >= buffer.length) controller.close(); + }); + + expect(instantiateAndGetExports(response, imports)).resolves.toHaveProperty("div"); + }); + + test("instantiates a string response", async () => { + const response = new Response(validUtf8Wasm, { + headers: { + "Content-Type": "application/wasm", + }, + }); + + expect(instantiateAndGetExports(response)).resolves.toHaveProperty("foo"); + }); + + // Errors: + + test("doesn't instantiate a response without the correct import object", async () => { + const response = await fetch(simpleWasmUri); + expect(instantiateAndGetExports(response)).rejects.toThrow( + "can't make WebAssembly.Instance because there is no imports Object and the WebAssembly.Module requires imports", + ); + }); +});