From e62fabe9d2cfed5d87f94eb6962de550a1e25f0b Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Tue, 21 Jul 2026 21:55:02 -0700 Subject: [PATCH 01/10] Add optional WHATWG Streams polyfill Vendor the ES5 web-streams-polyfill 4.3.0 ponyfill and expose an idempotent Streams initializer that preserves constructors supplied by the selected JavaScript engine. Cover readable, writable, transform, BYOB, error, tee, subclassing, and host-constructor behavior with focused ports from WPT plus Firefox and Chromium regression tests. Validate the implementation on JavaScriptCore under ASan/UBSan and QuickJS Release. --- CMakeLists.txt | 1 + Polyfills/CMakeLists.txt | 6 +- Polyfills/Streams/CMakeLists.txt | 30 ++++ .../Include/Babylon/Polyfills/Streams.h | 11 ++ Polyfills/Streams/README.md | 14 ++ Polyfills/Streams/Source/Streams.cpp | 55 ++++++ Polyfills/Streams/Source/StreamsScripts.h.in | 36 ++++ .../ThirdParty/web-streams-polyfill/LICENSE | 22 +++ .../web-streams-polyfill/ponyfill.es5.js | 8 + Tests/UnitTests/CMakeLists.txt | 1 + Tests/UnitTests/Scripts/tests.ts | 156 ++++++++++++++++++ Tests/UnitTests/Shared/Shared.cpp | 26 +++ 12 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 Polyfills/Streams/CMakeLists.txt create mode 100644 Polyfills/Streams/Include/Babylon/Polyfills/Streams.h create mode 100644 Polyfills/Streams/README.md create mode 100644 Polyfills/Streams/Source/Streams.cpp create mode 100644 Polyfills/Streams/Source/StreamsScripts.h.in create mode 100644 Polyfills/Streams/ThirdParty/web-streams-polyfill/LICENSE create mode 100644 Polyfills/Streams/ThirdParty/web-streams-polyfill/ponyfill.es5.js diff --git a/CMakeLists.txt b/CMakeLists.txt index 481e848f..0d27daf7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,6 +102,7 @@ option(JSRUNTIMEHOST_POLYFILL_FILE "Include JsRuntimeHost Polyfill File and File option(JSRUNTIMEHOST_POLYFILL_PERFORMANCE "Include JsRuntimeHost Polyfill Performance." ON) option(JSRUNTIMEHOST_POLYFILL_TEXTDECODER "Include JsRuntimeHost Polyfill TextDecoder." ON) option(JSRUNTIMEHOST_POLYFILL_TEXTENCODER "Include JsRuntimeHost Polyfill TextEncoder." ON) +option(JSRUNTIMEHOST_POLYFILL_STREAMS "Include JsRuntimeHost Polyfill Web Streams." ON) # Sanitizers option(ENABLE_SANITIZERS "Enable AddressSanitizer and UBSan" OFF) diff --git a/Polyfills/CMakeLists.txt b/Polyfills/CMakeLists.txt index a44765fb..c952cf27 100644 --- a/Polyfills/CMakeLists.txt +++ b/Polyfills/CMakeLists.txt @@ -44,4 +44,8 @@ endif() if(JSRUNTIMEHOST_POLYFILL_TEXTENCODER) add_subdirectory(TextEncoder) -endif() \ No newline at end of file +endif() + +if(JSRUNTIMEHOST_POLYFILL_STREAMS) + add_subdirectory(Streams) +endif() diff --git a/Polyfills/Streams/CMakeLists.txt b/Polyfills/Streams/CMakeLists.txt new file mode 100644 index 00000000..6c00ed81 --- /dev/null +++ b/Polyfills/Streams/CMakeLists.txt @@ -0,0 +1,30 @@ +set(WEB_STREAMS_POLYFILL_FILE + "${CMAKE_CURRENT_SOURCE_DIR}/ThirdParty/web-streams-polyfill/ponyfill.es5.js") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${WEB_STREAMS_POLYFILL_FILE}") +file(READ "${WEB_STREAMS_POLYFILL_FILE}" WEB_STREAMS_POLYFILL_SOURCE) +string(SUBSTRING "${WEB_STREAMS_POLYFILL_SOURCE}" 0 60000 WEB_STREAMS_POLYFILL_SOURCE_1) +string(SUBSTRING "${WEB_STREAMS_POLYFILL_SOURCE}" 60000 -1 WEB_STREAMS_POLYFILL_SOURCE_2) +configure_file( + "Source/StreamsScripts.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/Generated/StreamsScripts.h" + @ONLY) + +set(SOURCES + "Include/Babylon/Polyfills/Streams.h" + "Source/Streams.cpp" + "Source/StreamsScripts.h.in" + "ThirdParty/web-streams-polyfill/LICENSE" + "ThirdParty/web-streams-polyfill/ponyfill.es5.js") + +add_library(Streams ${SOURCES}) +warnings_as_errors(Streams) + +target_include_directories(Streams + PUBLIC "Include" + PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/Generated") + +target_link_libraries(Streams PUBLIC JsRuntime) + +set_property(TARGET Streams PROPERTY FOLDER Polyfills) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Polyfills/Streams/Include/Babylon/Polyfills/Streams.h b/Polyfills/Streams/Include/Babylon/Polyfills/Streams.h new file mode 100644 index 00000000..01e5a15c --- /dev/null +++ b/Polyfills/Streams/Include/Babylon/Polyfills/Streams.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +namespace Babylon::Polyfills::Streams +{ + // Installs the WHATWG Streams constructors that are not already supplied + // by the JavaScript engine. + void BABYLON_API Initialize(Napi::Env env); +} diff --git a/Polyfills/Streams/README.md b/Polyfills/Streams/README.md new file mode 100644 index 00000000..c84a38e3 --- /dev/null +++ b/Polyfills/Streams/README.md @@ -0,0 +1,14 @@ +# Web Streams + +Installs the standard `ReadableStream`, `WritableStream`, `TransformStream`, +reader, writer, controller, and queuing-strategy globals when the selected +JavaScript engine does not provide them. + +The implementation is the ES5 ponyfill bundle from +[`web-streams-polyfill` 4.3.0](https://github.com/MattiasBuelens/web-streams-polyfill/tree/add69059ed08eae6a18559aba49575a280d1529e), +which is based on the WHATWG reference implementation. The vendored project +tests this release against the Streams portion of WPT at revision +[`c05b4473`](https://github.com/web-platform-tests/wpt/tree/c05b447326585237713013c66341eab2cdf967b6/streams). + +`Initialize` preserves any Streams constructors already supplied by the host +engine and fills only missing globals. diff --git a/Polyfills/Streams/Source/Streams.cpp b/Polyfills/Streams/Source/Streams.cpp new file mode 100644 index 00000000..4552ed07 --- /dev/null +++ b/Polyfills/Streams/Source/Streams.cpp @@ -0,0 +1,55 @@ +#include + +#include "StreamsScripts.h" + +#include +#include + +namespace Babylon::Polyfills::Streams +{ + void BABYLON_API Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + auto global = env.Global(); + + static constexpr std::array constructorNames{ + "ReadableStream", + "ReadableStreamDefaultController", + "ReadableByteStreamController", + "ReadableStreamBYOBRequest", + "ReadableStreamDefaultReader", + "ReadableStreamBYOBReader", + "WritableStream", + "WritableStreamDefaultController", + "WritableStreamDefaultWriter", + "ByteLengthQueuingStrategy", + "CountQueuingStrategy", + "TransformStream", + "TransformStreamDefaultController", + }; + + bool needsPonyfill{}; + for (const auto name : constructorNames) + { + if (global.Get(name.data()).IsUndefined()) + { + needsPonyfill = true; + break; + } + } + + if (!needsPonyfill) + { + return; + } + + const auto exports = Napi::Eval(env, Internal::StreamsScripts::Ponyfill.data(), "jsruntimehost://web-streams-polyfill.js").As(); + for (const auto name : constructorNames) + { + if (global.Get(name.data()).IsUndefined()) + { + global.Set(name.data(), exports.Get(name.data())); + } + } + } +} diff --git a/Polyfills/Streams/Source/StreamsScripts.h.in b/Polyfills/Streams/Source/StreamsScripts.h.in new file mode 100644 index 00000000..9bf7f5bf --- /dev/null +++ b/Polyfills/Streams/Source/StreamsScripts.h.in @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +namespace Babylon::Polyfills::Internal::StreamsScripts +{ + inline constexpr char PonyfillPart1[] = R"JSRHSTREAM( +(function() { + var exports = {}; + var module = { exports: exports }; +@WEB_STREAMS_POLYFILL_SOURCE_1@ +)JSRHSTREAM"; + + inline constexpr char PonyfillPart2[] = R"JSRHSTREAM(@WEB_STREAMS_POLYFILL_SOURCE_2@ + return module.exports; +})() +)JSRHSTREAM"; + + template + consteval auto Join(const char (&part1)[Part1Size], const char (&part2)[Part2Size]) + { + std::array result{}; + for (std::size_t index{}; index < Part1Size - 1; ++index) + { + result[index] = part1[index]; + } + for (std::size_t index{}; index < Part2Size; ++index) + { + result[Part1Size - 1 + index] = part2[index]; + } + return result; + } + + inline constexpr auto Ponyfill = Join(PonyfillPart1, PonyfillPart2); +} diff --git a/Polyfills/Streams/ThirdParty/web-streams-polyfill/LICENSE b/Polyfills/Streams/ThirdParty/web-streams-polyfill/LICENSE new file mode 100644 index 00000000..7adbcf57 --- /dev/null +++ b/Polyfills/Streams/ThirdParty/web-streams-polyfill/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2026 Mattias Buelens +Copyright (c) 2016 Diwank Singh Tomer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Polyfills/Streams/ThirdParty/web-streams-polyfill/ponyfill.es5.js b/Polyfills/Streams/ThirdParty/web-streams-polyfill/ponyfill.es5.js new file mode 100644 index 00000000..18d63683 --- /dev/null +++ b/Polyfills/Streams/ThirdParty/web-streams-polyfill/ponyfill.es5.js @@ -0,0 +1,8 @@ +/** + * @license + * web-streams-polyfill v4.3.0 + * Copyright 2026 Mattias Buelens, Diwank Singh Tomer and other contributors. + * This code is released under the MIT license. + * SPDX-License-Identifier: MIT + */ +!function(e,r){"object"==typeof exports&&"undefined"!=typeof module?r(exports):"function"==typeof define&&define.amd?define(["exports"],r):r((e="undefined"!=typeof globalThis?globalThis:e||self).WebStreamsPolyfill={})}(this,function(e){"use strict";var r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?Symbol:function(e){return"Symbol(".concat(e,")")};function t(){}function o(e){return"object"==typeof e&&null!==e||"function"==typeof e}"function"==typeof SuppressedError&&SuppressedError;var n=t;function i(e,r){try{Object.defineProperty(e,"name",{value:r,configurable:!0})}catch(e){}}var a=Promise,u=Promise.resolve.bind(a),l=Promise.prototype.then,s=Promise.reject.bind(a),c=u;function f(e){return new a(e)}function d(e){return f(function(r){return r(e)})}function p(e){return s(e)}function b(e,r,t){return l.call(e,r,t)}function h(e,r,t){b(b(e,r,t),void 0,n)}function _(e,r){h(e,r)}function m(e,r){h(e,void 0,r)}function v(e,r,t){return b(e,r,t)}function y(e){b(e,void 0,n)}var g=function(e){if("function"==typeof queueMicrotask)g=queueMicrotask;else{var r=d(void 0);g=function(e){return b(r,e)}}return g(e)};function S(e,r,t){if("function"!=typeof e)throw new TypeError("Argument is not a function");return Function.prototype.apply.call(e,r,t)}function w(e,r,t){try{return d(S(e,r,t))}catch(e){return p(e)}}var R=function(){function e(){this._cursor=0,this._size=0,this._front={_elements:[],_next:void 0},this._back=this._front,this._cursor=0,this._size=0}return Object.defineProperty(e.prototype,"length",{get:function(){return this._size},enumerable:!1,configurable:!0}),e.prototype.push=function(e){var r=this._back,t=r;16383===r._elements.length&&(t={_elements:[],_next:void 0}),r._elements.push(e),t!==r&&(this._back=t,r._next=t),++this._size},e.prototype.shift=function(){var e=this._front,r=e,t=this._cursor,o=t+1,n=e._elements,i=n[t];return 16384===o&&(r=e._next,o=0),--this._size,this._cursor=o,e!==r&&(this._front=r),n[t]=void 0,i},e.prototype.forEach=function(e){for(var r=this._cursor,t=this._front,o=t._elements;!(r===o.length&&void 0===t._next||r===o.length&&(r=0,0===(o=(t=t._next)._elements).length));)e(o[r]),++r},e.prototype.peek=function(){var e=this._front,r=this._cursor;return e._elements[r]},e}(),T=r("[[AbortSteps]]"),P=r("[[ErrorSteps]]"),C=r("[[CancelSteps]]"),q=r("[[PullSteps]]"),E=r("[[CanPullSyncSteps]]"),W=r("[[ReleaseSteps]]");function O(e,r){e._ownerReadableStream=r,r._reader=e,"readable"===r._state?A(e):"closed"===r._state?function(e){A(e),F(e)}(e):z(e,r._storedError)}function j(e,r){return eo(e._ownerReadableStream,r)}function B(e){var r=e._ownerReadableStream;"readable"===r._state?D(e,new TypeError("Reader was released and can no longer be used to monitor the stream's closedness")):function(e,r){z(e,r)}(e,new TypeError("Reader was released and can no longer be used to monitor the stream's closedness")),r._readableStreamController[W](),r._reader=void 0,e._ownerReadableStream=void 0}function k(e){return new TypeError("Cannot "+e+" a stream using a released reader")}function A(e){e._closedPromise=f(function(r,t){e._closedPromise_resolve=r,e._closedPromise_reject=t})}function z(e,r){A(e),D(e,r)}function D(e,r){void 0!==e._closedPromise_reject&&(y(e._closedPromise),e._closedPromise_reject(r),e._closedPromise_resolve=void 0,e._closedPromise_reject=void 0)}function F(e){void 0!==e._closedPromise_resolve&&(e._closedPromise_resolve(void 0),e._closedPromise_resolve=void 0,e._closedPromise_reject=void 0)}var L=Number.isFinite||function(e){return"number"==typeof e&&isFinite(e)},I=Math.trunc||function(e){return e<0?Math.ceil(e):Math.floor(e)};function M(e,r){if(void 0!==e&&("object"!=typeof(t=e)&&"function"!=typeof t))throw new TypeError("".concat(r," is not an object."));var t}function x(e,r){if("function"!=typeof e)throw new TypeError("".concat(r," is not a function."))}function Y(e,r){if(!function(e){return"object"==typeof e&&null!==e||"function"==typeof e}(e))throw new TypeError("".concat(r," is not an object."))}function Q(e,r,t){if(void 0===e)throw new TypeError("Parameter ".concat(r," is required in '").concat(t,"'."))}function N(e,r,t){if(void 0===e)throw new TypeError("".concat(r," is required in '").concat(t,"'."))}function H(e){return Number(e)}function V(e){return 0===e?0:e}function U(e,r){var t=Number.MAX_SAFE_INTEGER,o=Number(e);if(o=V(o),!L(o))throw new TypeError("".concat(r," is not a finite number"));if((o=function(e){return V(I(e))}(o))<0||o>t)throw new TypeError("".concat(r," is outside the accepted range of ").concat(0," to ").concat(t,", inclusive"));return L(o)&&0!==o?o:0}function G(e,r){if(!Zt(e))throw new TypeError("".concat(r," is not a ReadableStream."))}function X(e){return new ee(e)}function J(e,r){e._reader._readRequests.push(r)}function K(e,r,t){var o=e._reader._readRequests.shift();t?o._closeSteps():o._chunkSteps(r)}function Z(e){return e._reader._readRequests.length}function $(e){var r=e._reader;return void 0!==r&&!!ae(r)}var ee=function(){function ReadableStreamDefaultReader(e){if(Q(e,1,"ReadableStreamDefaultReader"),G(e,"First parameter"),$t(e))throw new TypeError("This stream has already been locked for exclusive reading by another reader");O(this,e),this._readRequests=new R}return Object.defineProperty(ReadableStreamDefaultReader.prototype,"closed",{get:function(){return ae(this)?this._closedPromise:p(ce("closed"))},enumerable:!1,configurable:!0}),ReadableStreamDefaultReader.prototype.cancel=function(e){return void 0===e&&(e=void 0),ae(this)?void 0===this._ownerReadableStream?p(k("cancel")):j(this,e):p(ce("cancel"))},ReadableStreamDefaultReader.prototype.read=function(){if(!ae(this))return p(ce("read"));if(void 0===this._ownerReadableStream)return p(k("read from"));var e=le(this)?new ie:new ne;return ue(this,e),e._promise},ReadableStreamDefaultReader.prototype.releaseLock=function(){if(!ae(this))throw ce("releaseLock");void 0!==this._ownerReadableStream&&function(e){B(e);var r=new TypeError("Reader was released");se(e,r)}(this)},ReadableStreamDefaultReader}();Object.defineProperties(ee.prototype,{cancel:{enumerable:!0},read:{enumerable:!0},releaseLock:{enumerable:!0},closed:{enumerable:!0}}),i(ee.prototype.cancel,"cancel"),i(ee.prototype.read,"read"),i(ee.prototype.releaseLock,"releaseLock"),"symbol"==typeof r.toStringTag&&Object.defineProperty(ee.prototype,r.toStringTag,{value:"ReadableStreamDefaultReader",configurable:!0});var re,te,oe,ne=function(){function e(){var e=this;this._promise=f(function(r,t){e._resolvePromise=r,e._rejectPromise=t})}return e.prototype._chunkSteps=function(e){this._resolvePromise({value:e,done:!1})},e.prototype._closeSteps=function(){this._resolvePromise({value:void 0,done:!0})},e.prototype._errorSteps=function(e){this._rejectPromise(e)},e}(),ie=function(){function e(){this._promise=void 0}return e.prototype._chunkSteps=function(e){this._promise=c({value:e,done:!1})},e.prototype._closeSteps=function(){this._promise=c({value:void 0,done:!0})},e.prototype._errorSteps=function(e){this._promise=p(e)},e}();function ae(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_readRequests")&&e instanceof ee)}function ue(e,r){var t=e._ownerReadableStream;t._disturbed=!0,"closed"===t._state?r._closeSteps():"errored"===t._state?r._errorSteps(t._storedError):t._readableStreamController[q](r)}function le(e){var r=e._ownerReadableStream;return"closed"===r._state||("errored"===r._state||r._readableStreamController[E]())}function se(e,r){var t=e._readRequests;e._readRequests=new R,t.forEach(function(e){e._errorSteps(r)})}function ce(e){return new TypeError("ReadableStreamDefaultReader.prototype.".concat(e," can only be used on a ReadableStreamDefaultReader"))}function fe(e){return e.slice()}function de(e,r,t,o,n){new Uint8Array(e).set(new Uint8Array(t,o,n),r)}var pe=function(e){return(pe="function"==typeof e.transfer?function(e){return e.transfer()}:"function"==typeof structuredClone?function(e){return structuredClone(e,{transfer:[e]})}:function(e){return e})(e)},be=function(e){return(be="boolean"==typeof e.detached?function(e){return e.detached}:function(e){return 0===e.byteLength})(e)};function he(e,r,t){if(e.slice)return e.slice(r,t);var o=t-r,n=new ArrayBuffer(o);return de(n,0,e,r,o),n}function _e(e,r){var t=e[r];if(null!=t){if("function"!=typeof t)throw new TypeError("".concat(String(r)," is not a function"));return t}}function me(e){try{var r=e.done,t=e.value;return b(c(t),function(e){return{done:r,value:e}})}catch(e){return p(e)}}var ve,ye=null!==(oe=null!==(re=r.asyncIterator)&&void 0!==re?re:null===(te=r.for)||void 0===te?void 0:te.call(r,"Symbol.asyncIterator"))&&void 0!==oe?oe:"@@asyncIterator";function ge(e,t,n){if(void 0===t&&(t="sync"),void 0===n)if("async"===t){if(void 0===(n=_e(e,ye)))return function(e){var r={next:function(){var r;try{r=Se(e)}catch(e){return p(e)}return me(r)},return:function(r){var t;try{var n=_e(e.iterator,"return");if(void 0===n)return d({done:!0,value:r});t=S(n,e.iterator,[r])}catch(e){return p(e)}return o(t)?me(t):p(new TypeError("The iterator.return() method must return an object"))}};return{iterator:r,nextMethod:r.next,done:!1}}(ge(e,"sync",_e(e,r.iterator)))}else n=_e(e,r.iterator);if(void 0===n)throw new TypeError("The object is not iterable");var i=S(n,e,[]);if(!o(i))throw new TypeError("The iterator method must return an object");return{iterator:i,nextMethod:i.next,done:!1}}function Se(e){var r=S(e.nextMethod,e.iterator,[]);if(!o(r))throw new TypeError("The iterator.next() method must return an object");return r}var we=function(){function e(e,r){this._ongoingPromise=void 0,this._isFinished=!1,this._reader=e,this._preventCancel=r}return e.prototype.next=function(){var e=this,r=function(){return e._nextSteps()};return this._ongoingPromise=this._ongoingPromise?v(this._ongoingPromise,r,r):r(),this._ongoingPromise},e.prototype.return=function(e){var r=this,t=function(){return r._returnSteps(e)};return this._ongoingPromise=this._ongoingPromise?v(this._ongoingPromise,t,t):t(),this._ongoingPromise},e.prototype._nextSteps=function(){if(this._isFinished)return Promise.resolve({value:void 0,done:!0});var e=this._reader,r=new Re(this);return ue(e,r),r._promise},e.prototype._returnSteps=function(e){if(this._isFinished)return Promise.resolve({value:e,done:!0});this._isFinished=!0;var r=this._reader;if(!this._preventCancel){var t=j(r,e);return B(r),v(t,function(){return{value:e,done:!0}})}return B(r),d({value:e,done:!0})},e}(),Re=function(){function e(e){var r=this;this._iterator=e,this._promise=f(function(e,t){r._resolvePromise=e,r._rejectPromise=t})}return e.prototype._chunkSteps=function(e){var r=this;this._iterator._ongoingPromise=void 0,g(function(){return r._resolvePromise({value:e,done:!1})})},e.prototype._closeSteps=function(){var e=this._iterator;e._ongoingPromise=void 0,e._isFinished=!0,B(e._reader),this._resolvePromise({value:void 0,done:!0})},e.prototype._errorSteps=function(e){var r=this._iterator;r._ongoingPromise=void 0,r._isFinished=!0,B(r._reader),this._rejectPromise(e)},e}(),Te=((ve={next:function(){return Pe(this)?this._asyncIteratorImpl.next():p(Ce("next"))},return:function(e){return Pe(this)?this._asyncIteratorImpl.return(e):p(Ce("return"))}})[ye]=function(){return this},ve);function Pe(e){if(!o(e))return!1;if(!Object.prototype.hasOwnProperty.call(e,"_asyncIteratorImpl"))return!1;try{return e._asyncIteratorImpl instanceof we}catch(e){return!1}}function Ce(e){return new TypeError("ReadableStreamAsyncIterator.".concat(e," can only be used on a ReadableSteamAsyncIterator"))}Object.defineProperty(Te,ye,{enumerable:!1});var qe=Number.isNaN||function(e){return e!=e};function Ee(e){var r=he(e.buffer,e.byteOffset,e.byteOffset+e.byteLength);return new Uint8Array(r)}function We(e){var r=e._queue.shift();return e._queueTotalSize-=r.size,e._queueTotalSize<0&&(e._queueTotalSize=0),r.value}function Oe(e,r,t){if("number"!=typeof(o=t)||qe(o)||o<0||t===1/0)throw new RangeError("Size must be a finite, non-NaN, non-negative number.");var o;e._queue.push({value:r,size:t}),e._queueTotalSize+=t}function je(e){e._queue=new R,e._queueTotalSize=0}function Be(e){return e===DataView}function ke(e){return Be(e)?1:e.BYTES_PER_ELEMENT}var Ae=function(){function ReadableStreamBYOBRequest(){throw new TypeError("Illegal constructor")}return Object.defineProperty(ReadableStreamBYOBRequest.prototype,"view",{get:function(){if(!Fe(this))throw sr("view");return this._view},enumerable:!1,configurable:!0}),ReadableStreamBYOBRequest.prototype.respond=function(e){if(!Fe(this))throw sr("respond");if(Q(e,1,"respond"),e=U(e,"First parameter"),void 0===this._associatedReadableByteStreamController)throw new TypeError("This BYOB request has been invalidated");if(be(this._view.buffer))throw new TypeError("The BYOB request's buffer has been detached and so cannot be used as a response");ar(this._associatedReadableByteStreamController,e)},ReadableStreamBYOBRequest.prototype.respondWithNewView=function(e){if(!Fe(this))throw sr("respondWithNewView");if(Q(e,1,"respondWithNewView"),!ArrayBuffer.isView(e))throw new TypeError("You can only respond with array buffer views");if(void 0===this._associatedReadableByteStreamController)throw new TypeError("This BYOB request has been invalidated");if(be(e.buffer))throw new TypeError("The given view's buffer has been detached and so cannot be used as a response");ur(this._associatedReadableByteStreamController,e)},ReadableStreamBYOBRequest}();Object.defineProperties(Ae.prototype,{respond:{enumerable:!0},respondWithNewView:{enumerable:!0},view:{enumerable:!0}}),i(Ae.prototype.respond,"respond"),i(Ae.prototype.respondWithNewView,"respondWithNewView"),"symbol"==typeof r.toStringTag&&Object.defineProperty(Ae.prototype,r.toStringTag,{value:"ReadableStreamBYOBRequest",configurable:!0});var ze=function(){function ReadableByteStreamController(){throw new TypeError("Illegal constructor")}return Object.defineProperty(ReadableByteStreamController.prototype,"byobRequest",{get:function(){if(!De(this))throw cr("byobRequest");return nr(this)},enumerable:!1,configurable:!0}),Object.defineProperty(ReadableByteStreamController.prototype,"desiredSize",{get:function(){if(!De(this))throw cr("desiredSize");return ir(this)},enumerable:!1,configurable:!0}),ReadableByteStreamController.prototype.close=function(){if(!De(this))throw cr("close");if(this._closeRequested)throw new TypeError("The stream has already been closed; do not close it again!");var e=this._controlledReadableByteStream._state;if("readable"!==e)throw new TypeError("The stream (in ".concat(e," state) is not in the readable state and cannot be closed"));er(this)},ReadableByteStreamController.prototype.enqueue=function(e){if(!De(this))throw cr("enqueue");if(Q(e,1,"enqueue"),!ArrayBuffer.isView(e))throw new TypeError("chunk must be an array buffer view");if(0===e.byteLength)throw new TypeError("chunk must have non-zero byteLength");if(0===e.buffer.byteLength)throw new TypeError("chunk's buffer must have non-zero byteLength");if(this._closeRequested)throw new TypeError("stream is closed or draining");var r=this._controlledReadableByteStream._state;if("readable"!==r)throw new TypeError("The stream (in ".concat(r," state) is not in the readable state and cannot be enqueued to"));rr(this,e)},ReadableByteStreamController.prototype.error=function(e){if(void 0===e&&(e=void 0),!De(this))throw cr("error");tr(this,e)},ReadableByteStreamController.prototype[C]=function(e){Ie(this),je(this);var r=this._cancelAlgorithm(e);return $e(this),r},ReadableByteStreamController.prototype[q]=function(e){var r=this._controlledReadableByteStream;if(this._queueTotalSize>0)or(this,e);else{var t=this._autoAllocateChunkSize;if(void 0!==t){var o=void 0;try{o=new ArrayBuffer(t)}catch(r){return void e._errorSteps(r)}var n={buffer:o,bufferByteLength:t,byteOffset:0,byteLength:t,bytesFilled:0,minimumFill:1,elementSize:1,viewConstructor:Uint8Array,readerType:"default"};this._pendingPullIntos.push(n)}J(r,e),Le(this)}},ReadableByteStreamController.prototype[E]=function(){return this._queueTotalSize>0},ReadableByteStreamController.prototype[W]=function(){if(this._pendingPullIntos.length>0){var e=this._pendingPullIntos.peek();e.readerType="none",this._pendingPullIntos=new R,this._pendingPullIntos.push(e)}},ReadableByteStreamController}();function De(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_controlledReadableByteStream")&&e instanceof ze)}function Fe(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_associatedReadableByteStreamController")&&e instanceof Ae)}function Le(e){var r=function(e){var r=e._controlledReadableByteStream;if("readable"!==r._state)return!1;if(e._closeRequested)return!1;if(!e._started)return!1;if($(r)&&Z(r)>0)return!0;if(hr(r)&&br(r)>0)return!0;var t=ir(e);if(t>0)return!0;return!1}(e);r&&(e._pulling?e._pullAgain=!0:(e._pulling=!0,h(e._pullAlgorithm(),function(){return e._pulling=!1,e._pullAgain&&(e._pullAgain=!1,Le(e)),null},function(r){return tr(e,r),null})))}function Ie(e){Xe(e),e._pendingPullIntos=new R}function Me(e,r){var t=!1;"closed"===e._state&&(t=!0);var o=Ye(r);"default"===r.readerType?K(e,o,t):function(e,r,t){var o=e._reader,n=o._readIntoRequests.shift();t?n._closeSteps(r):n._chunkSteps(r)}(e,o,t)}function xe(e,r){for(var t=0;t0&&Ne(e,r.buffer,r.byteOffset,r.bytesFilled),Ze(e)}function Ve(e,r){var t=Math.min(e._queueTotalSize,r.byteLength-r.bytesFilled),o=r.bytesFilled+t,n=t,i=!1,a=o-o%r.elementSize;a>=r.minimumFill&&(n=a-r.bytesFilled,i=!0);for(var u=e._queue;n>0;){var l=u.peek(),s=Math.min(n,l.byteLength),c=r.byteOffset+r.bytesFilled;de(r.buffer,c,l.buffer,l.byteOffset,s),l.byteLength===s?u.shift():(l.byteOffset+=s,l.byteLength-=s),e._queueTotalSize-=s,Ue(e,s,r),n-=s}return i}function Ue(e,r,t){t.bytesFilled+=r}function Ge(e){0===e._queueTotalSize&&e._closeRequested?($e(e),ro(e._controlledReadableByteStream)):Le(e)}function Xe(e){null!==e._byobRequest&&(e._byobRequest._associatedReadableByteStreamController=void 0,e._byobRequest._view=null,e._byobRequest=null)}function Je(e){for(var r=[];e._pendingPullIntos.length>0&&0!==e._queueTotalSize;){var t=e._pendingPullIntos.peek();Ve(e,t)&&(Ze(e),r.push(t))}return r}function Ke(e,r){var t=e._pendingPullIntos.peek();Xe(e),"closed"===e._controlledReadableByteStream._state?function(e,r){"none"===r.readerType&&Ze(e);var t=e._controlledReadableByteStream;if(hr(t)){for(var o=[];o.length0){var n=t.byteOffset+t.bytesFilled;Ne(e,t.buffer,n-o,o)}t.bytesFilled-=o;var i=Je(e);Me(e._controlledReadableByteStream,t),xe(e._controlledReadableByteStream,i)}}else{He(e,t);var a=Je(e);xe(e._controlledReadableByteStream,a)}}(e,r,t),Le(e)}function Ze(e){return e._pendingPullIntos.shift()}function $e(e){e._pullAlgorithm=void 0,e._cancelAlgorithm=void 0}function er(e){var r=e._controlledReadableByteStream;if(!e._closeRequested&&"readable"===r._state)if(e._queueTotalSize>0)e._closeRequested=!0;else{if(e._pendingPullIntos.length>0){var t=e._pendingPullIntos.peek();if(t.bytesFilled%t.elementSize!==0){var o=new TypeError("Insufficient bytes to fill elements in the given buffer");throw tr(e,o),o}}$e(e),ro(r)}}function rr(e,r){var t=e._controlledReadableByteStream;if(!e._closeRequested&&"readable"===t._state){var o=r.buffer,n=r.byteOffset,i=r.byteLength;if(be(o))throw new TypeError("chunk's buffer is detached and so cannot be enqueued");var a=pe(o);if(e._pendingPullIntos.length>0){var u=e._pendingPullIntos.peek();if(be(u.buffer))throw new TypeError("The BYOB request's buffer has been detached and so cannot be filled with an enqueued chunk");Xe(e),u.buffer=pe(u.buffer),"none"===u.readerType&&He(e,u)}if($(t))if(function(e){for(var r=e._controlledReadableByteStream._reader;r._readRequests.length>0;){if(0===e._queueTotalSize)return;or(e,r._readRequests.shift())}}(e),0===Z(t))Qe(e,a,n,i);else e._pendingPullIntos.length>0&&Ze(e),K(t,new Uint8Array(a,n,i),!1);else if(hr(t)){Qe(e,a,n,i),xe(t,Je(e))}else Qe(e,a,n,i);Le(e)}}function tr(e,r){var t=e._controlledReadableByteStream;"readable"===t._state&&(Ie(e),je(e),$e(e),to(t,r))}function or(e,r){var t=e._queue.shift();e._queueTotalSize-=t.byteLength,Ge(e);var o=new Uint8Array(t.buffer,t.byteOffset,t.byteLength);r._chunkSteps(o)}function nr(e){if(null===e._byobRequest&&e._pendingPullIntos.length>0){var r=e._pendingPullIntos.peek(),t=new Uint8Array(r.buffer,r.byteOffset+r.bytesFilled,r.byteLength-r.bytesFilled),o=Object.create(Ae.prototype);!function(e,r,t){e._associatedReadableByteStreamController=r,e._view=t}(o,e,t),e._byobRequest=o}return e._byobRequest}function ir(e){var r=e._controlledReadableByteStream._state;return"errored"===r?null:"closed"===r?0:e._strategyHWM-e._queueTotalSize}function ar(e,r){var t=e._pendingPullIntos.peek();if("closed"===e._controlledReadableByteStream._state){if(0!==r)throw new TypeError("bytesWritten must be 0 when calling respond() on a closed stream")}else{if(0===r)throw new TypeError("bytesWritten must be greater than 0 when calling respond() on a readable stream");if(t.bytesFilled+r>t.byteLength)throw new RangeError("bytesWritten out of range")}t.buffer=pe(t.buffer),Ke(e,r)}function ur(e,r){var t=e._pendingPullIntos.peek();if("closed"===e._controlledReadableByteStream._state){if(0!==r.byteLength)throw new TypeError("The view's length must be 0 when calling respondWithNewView() on a closed stream")}else if(0===r.byteLength)throw new TypeError("The view's length must be greater than 0 when calling respondWithNewView() on a readable stream");if(t.byteOffset+t.bytesFilled!==r.byteOffset)throw new RangeError("The region specified by view does not match byobRequest");if(t.bufferByteLength!==r.buffer.byteLength)throw new RangeError("The buffer of view has different capacity than byobRequest");if(t.bytesFilled+r.byteLength>t.byteLength)throw new RangeError("The region specified by view is larger than byobRequest");var o=r.byteLength;t.buffer=pe(r.buffer),Ke(e,o)}function lr(e,r,t,o,n,i,a){r._controlledReadableByteStream=e,r._pullAgain=!1,r._pulling=!1,r._byobRequest=null,r._queue=r._queueTotalSize=void 0,je(r),r._closeRequested=!1,r._started=!1,r._strategyHWM=i,r._pullAlgorithm=o,r._cancelAlgorithm=n,r._autoAllocateChunkSize=a,r._pendingPullIntos=new R,e._readableStreamController=r,h(d(t()),function(){return r._started=!0,Le(r),null},function(e){return tr(r,e),null})}function sr(e){return new TypeError("ReadableStreamBYOBRequest.prototype.".concat(e," can only be used on a ReadableStreamBYOBRequest"))}function cr(e){return new TypeError("ReadableByteStreamController.prototype.".concat(e," can only be used on a ReadableByteStreamController"))}function fr(e,r){if("byob"!==(e="".concat(e)))throw new TypeError("".concat(r," '").concat(e,"' is not a valid enumeration value for ReadableStreamReaderMode"));return e}function dr(e){return new _r(e)}function pr(e,r){e._reader._readIntoRequests.push(r)}function br(e){return e._reader._readIntoRequests.length}function hr(e){var r=e._reader;return void 0!==r&&!!yr(r)}Object.defineProperties(ze.prototype,{close:{enumerable:!0},enqueue:{enumerable:!0},error:{enumerable:!0},byobRequest:{enumerable:!0},desiredSize:{enumerable:!0}}),i(ze.prototype.close,"close"),i(ze.prototype.enqueue,"enqueue"),i(ze.prototype.error,"error"),"symbol"==typeof r.toStringTag&&Object.defineProperty(ze.prototype,r.toStringTag,{value:"ReadableByteStreamController",configurable:!0});var _r=function(){function ReadableStreamBYOBReader(e){if(Q(e,1,"ReadableStreamBYOBReader"),G(e,"First parameter"),$t(e))throw new TypeError("This stream has already been locked for exclusive reading by another reader");if(!De(e._readableStreamController))throw new TypeError("Cannot construct a ReadableStreamBYOBReader for a stream not constructed with a byte source");O(this,e),this._readIntoRequests=new R}return Object.defineProperty(ReadableStreamBYOBReader.prototype,"closed",{get:function(){return yr(this)?this._closedPromise:p(wr("closed"))},enumerable:!1,configurable:!0}),ReadableStreamBYOBReader.prototype.cancel=function(e){return void 0===e&&(e=void 0),yr(this)?void 0===this._ownerReadableStream?p(k("cancel")):j(this,e):p(wr("cancel"))},ReadableStreamBYOBReader.prototype.read=function(e,r){if(void 0===r&&(r={}),!yr(this))return p(wr("read"));if(!ArrayBuffer.isView(e))return p(new TypeError("view must be an array buffer view"));if(0===e.byteLength)return p(new TypeError("view must have non-zero byteLength"));if(0===e.buffer.byteLength)return p(new TypeError("view's buffer must have non-zero byteLength"));if(be(e.buffer))return p(new TypeError("view's buffer has been detached"));var t;try{t=function(e,r){var t;return M(e,r),{min:U(null!==(t=null==e?void 0:e.min)&&void 0!==t?t:1,"".concat(r," has member 'min' that"))}}(r,"options")}catch(e){return p(e)}var o=t.min;if(0===o)return p(new TypeError("options.min must be greater than 0"));if(function(e){return Be(e.constructor)}(e)){if(o>e.byteLength)return p(new RangeError("options.min must be less than or equal to view's byteLength"))}else if(o>e.length)return p(new RangeError("options.min must be less than or equal to view's length"));if(void 0===this._ownerReadableStream)return p(k("read from"));var n=function(e,r,t){var o=e._ownerReadableStream;return"errored"===o._state||function(e,r,t){var o=e._controlledReadableByteStream,n=ke(r.constructor);r.byteLength;var i=t*n;return!(e._pendingPullIntos.length>0)&&("closed"===o._state||e._queueTotalSize>=i)}(o._readableStreamController,r,t)}(this,e,o)?new vr:new mr;return gr(this,e,o,n),n._promise},ReadableStreamBYOBReader.prototype.releaseLock=function(){if(!yr(this))throw wr("releaseLock");void 0!==this._ownerReadableStream&&function(e){B(e);var r=new TypeError("Reader was released");Sr(e,r)}(this)},ReadableStreamBYOBReader}();Object.defineProperties(_r.prototype,{cancel:{enumerable:!0},read:{enumerable:!0},releaseLock:{enumerable:!0},closed:{enumerable:!0}}),i(_r.prototype.cancel,"cancel"),i(_r.prototype.read,"read"),i(_r.prototype.releaseLock,"releaseLock"),"symbol"==typeof r.toStringTag&&Object.defineProperty(_r.prototype,r.toStringTag,{value:"ReadableStreamBYOBReader",configurable:!0});var mr=function(){function e(){var e=this;this._promise=f(function(r,t){e._resolvePromise=r,e._rejectPromise=t})}return e.prototype._chunkSteps=function(e){this._resolvePromise({value:e,done:!1})},e.prototype._closeSteps=function(e){this._resolvePromise({value:e,done:!0})},e.prototype._errorSteps=function(e){this._rejectPromise(e)},e}(),vr=function(){function e(){this._promise=void 0}return e.prototype._chunkSteps=function(e){this._promise=c({value:e,done:!1})},e.prototype._closeSteps=function(e){this._promise=c({value:e,done:!0})},e.prototype._errorSteps=function(e){this._promise=p(e)},e}();function yr(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_readIntoRequests")&&e instanceof _r)}function gr(e,r,t,o){var n=e._ownerReadableStream;n._disturbed=!0,"errored"===n._state?o._errorSteps(n._storedError):function(e,r,t,o){var n,i=e._controlledReadableByteStream,a=r.constructor,u=ke(a),l=r.byteOffset,s=r.byteLength,c=t*u;try{n=pe(r.buffer)}catch(p){return void o._errorSteps(p)}var f={buffer:n,bufferByteLength:n.byteLength,byteOffset:l,byteLength:s,bytesFilled:0,minimumFill:c,elementSize:u,viewConstructor:a,readerType:"byob"};if(e._pendingPullIntos.length>0)return e._pendingPullIntos.push(f),void pr(i,o);if("closed"!==i._state){if(e._queueTotalSize>0){if(Ve(e,f)){var d=Ye(f);return Ge(e),void o._chunkSteps(d)}if(e._closeRequested){var p=new TypeError("Insufficient bytes to fill elements in the given buffer");return tr(e,p),void o._errorSteps(p)}}e._pendingPullIntos.push(f),pr(i,o),Le(e)}else{var b=new a(f.buffer,f.byteOffset,0);o._closeSteps(b)}}(n._readableStreamController,r,t,o)}function Sr(e,r){var t=e._readIntoRequests;e._readIntoRequests=new R,t.forEach(function(e){e._errorSteps(r)})}function wr(e){return new TypeError("ReadableStreamBYOBReader.prototype.".concat(e," can only be used on a ReadableStreamBYOBReader"))}function Rr(e,r){var t=e.highWaterMark;if(void 0===t)return r;if(qe(t)||t<0)throw new RangeError("Invalid highWaterMark");return t}function Tr(e){var r=e.size;return r||function(){return 1}}function Pr(e,r){M(e,r);var t=null==e?void 0:e.highWaterMark,o=null==e?void 0:e.size;return{highWaterMark:void 0===t?void 0:H(t),size:void 0===o?void 0:Cr(o,"".concat(r," has member 'size' that"))}}function Cr(e,r){return x(e,r),function(r){return H(e(r))}}function qr(e,r,t){return x(e,t),function(t){return w(e,r,[t])}}function Er(e,r,t){return x(e,t),function(){return w(e,r,[])}}function Wr(e,r,t){return x(e,t),function(t){return S(e,r,[t])}}function Or(e,r,t){return x(e,t),function(t,o){return w(e,r,[t,o])}}function jr(e,r){if(!zr(e))throw new TypeError("".concat(r," is not a WritableStream."))}var Br=function(){function WritableStream(e,r){void 0===e&&(e={}),void 0===r&&(r={}),void 0===e?e=null:Y(e,"First parameter");var t=Pr(r,"Second parameter"),o=function(e,r){M(e,r);var t=null==e?void 0:e.abort,o=null==e?void 0:e.close,n=null==e?void 0:e.start,i=null==e?void 0:e.type,a=null==e?void 0:e.write;return{abort:void 0===t?void 0:qr(t,e,"".concat(r," has member 'abort' that")),close:void 0===o?void 0:Er(o,e,"".concat(r," has member 'close' that")),start:void 0===n?void 0:Wr(n,e,"".concat(r," has member 'start' that")),write:void 0===a?void 0:Or(a,e,"".concat(r," has member 'write' that")),type:i}}(e,"First parameter");if(Ar(this),void 0!==o.type)throw new RangeError("Invalid type is specified");var n=Tr(t);!function(e,r,t,o){var n,i,a,u,l=Object.create($r.prototype);n=void 0!==r.start?function(){return r.start(l)}:function(){};i=void 0!==r.write?function(e){return r.write(e,l)}:function(){return d(void 0)};a=void 0!==r.close?function(){return r.close()}:function(){return d(void 0)};u=void 0!==r.abort?function(e){return r.abort(e)}:function(){return d(void 0)};rt(e,l,n,i,a,u,t,o)}(this,o,Rr(t,1),n)}return Object.defineProperty(WritableStream.prototype,"locked",{get:function(){if(!zr(this))throw lt("locked");return Dr(this)},enumerable:!1,configurable:!0}),WritableStream.prototype.abort=function(e){return void 0===e&&(e=void 0),zr(this)?Dr(this)?p(new TypeError("Cannot abort a stream that already has a writer")):Fr(this,e):p(lt("abort"))},WritableStream.prototype.close=function(){return zr(this)?Dr(this)?p(new TypeError("Cannot close a stream that already has a writer")):Yr(this)?p(new TypeError("Cannot close an already-closing stream")):Lr(this):p(lt("close"))},WritableStream.prototype.getWriter=function(){if(!zr(this))throw lt("getWriter");return kr(this)},WritableStream}();function kr(e){return new Hr(e)}function Ar(e){e._state="writable",e._storedError=void 0,e._writer=void 0,e._writableStreamController=void 0,e._writeRequests=new R,e._inFlightWriteRequest=void 0,e._closeRequest=void 0,e._inFlightCloseRequest=void 0,e._pendingAbortRequest=void 0,e._backpressure=!1}function zr(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_writableStreamController")&&e instanceof Br)}function Dr(e){return void 0!==e._writer}function Fr(e,r){var t;if("closed"===e._state||"errored"===e._state)return d(void 0);e._writableStreamController._abortReason=r,null===(t=e._writableStreamController._abortController)||void 0===t||t.abort(r);var o=e._state;if("closed"===o||"errored"===o)return d(void 0);if(void 0!==e._pendingAbortRequest)return e._pendingAbortRequest._promise;var n=!1;"erroring"===o&&(n=!0,r=void 0);var i=f(function(t,o){e._pendingAbortRequest={_promise:void 0,_resolve:t,_reject:o,_reason:r,_wasAlreadyErroring:n}});return e._pendingAbortRequest._promise=i,n||Mr(e,r),i}function Lr(e){var r=e._state;if("closed"===r||"errored"===r)return p(new TypeError("The stream (in ".concat(r," state) is not in the writable state and cannot be closed")));var t,o=f(function(r,t){var o={_resolve:r,_reject:t};e._closeRequest=o}),n=e._writer;return void 0!==n&&e._backpressure&&"writable"===r&>(n),Oe(t=e._writableStreamController,Zr,0),nt(t),o}function Ir(e,r){"writable"!==e._state?xr(e):Mr(e,r)}function Mr(e,r){var t=e._writableStreamController;e._state="erroring",e._storedError=r;var o=e._writer;void 0!==o&&Xr(o,r),!function(e){if(void 0===e._inFlightWriteRequest&&void 0===e._inFlightCloseRequest)return!1;return!0}(e)&&t._started&&xr(e)}function xr(e){e._state="errored",e._writableStreamController[P]();var r=e._storedError;if(e._writeRequests.forEach(function(e){e._reject(r)}),e._writeRequests=new R,void 0!==e._pendingAbortRequest){var t=e._pendingAbortRequest;if(e._pendingAbortRequest=void 0,t._wasAlreadyErroring)return t._reject(r),void Qr(e);h(e._writableStreamController[T](t._reason),function(){return t._resolve(),Qr(e),null},function(r){return t._reject(r),Qr(e),null})}else Qr(e)}function Yr(e){return void 0!==e._closeRequest||void 0!==e._inFlightCloseRequest}function Qr(e){void 0!==e._closeRequest&&(e._closeRequest._reject(e._storedError),e._closeRequest=void 0);var r=e._writer;void 0!==r&&bt(r,e._storedError)}function Nr(e,r){var t=e._writer;void 0!==t&&r!==e._backpressure&&(r?function(e){_t(e)}(t):gt(t)),e._backpressure=r}Object.defineProperties(Br.prototype,{abort:{enumerable:!0},close:{enumerable:!0},getWriter:{enumerable:!0},locked:{enumerable:!0}}),i(Br.prototype.abort,"abort"),i(Br.prototype.close,"close"),i(Br.prototype.getWriter,"getWriter"),"symbol"==typeof r.toStringTag&&Object.defineProperty(Br.prototype,r.toStringTag,{value:"WritableStream",configurable:!0});var Hr=function(){function WritableStreamDefaultWriter(e){if(Q(e,1,"WritableStreamDefaultWriter"),jr(e,"First parameter"),Dr(e))throw new TypeError("This stream has already been locked for exclusive writing by another writer");this._ownerWritableStream=e,e._writer=this;var r,t=e._state;if("writable"===t)!Yr(e)&&e._backpressure?_t(this):vt(this),dt(this);else if("erroring"===t)mt(this,e._storedError),dt(this);else if("closed"===t)vt(this),dt(r=this),ht(r);else{var o=e._storedError;mt(this,o),pt(this,o)}}return Object.defineProperty(WritableStreamDefaultWriter.prototype,"closed",{get:function(){return Vr(this)?this._closedPromise:p(ct("closed"))},enumerable:!1,configurable:!0}),Object.defineProperty(WritableStreamDefaultWriter.prototype,"desiredSize",{get:function(){if(!Vr(this))throw ct("desiredSize");if(void 0===this._ownerWritableStream)throw ft("desiredSize");return function(e){var r=e._ownerWritableStream,t=r._state;if("errored"===t||"erroring"===t)return null;if("closed"===t)return 0;return ot(r._writableStreamController)}(this)},enumerable:!1,configurable:!0}),Object.defineProperty(WritableStreamDefaultWriter.prototype,"ready",{get:function(){return Vr(this)?this._readyPromise:p(ct("ready"))},enumerable:!1,configurable:!0}),WritableStreamDefaultWriter.prototype.abort=function(e){return void 0===e&&(e=void 0),Vr(this)?void 0===this._ownerWritableStream?p(ft("abort")):function(e,r){return Fr(e._ownerWritableStream,r)}(this,e):p(ct("abort"))},WritableStreamDefaultWriter.prototype.close=function(){if(!Vr(this))return p(ct("close"));var e=this._ownerWritableStream;return void 0===e?p(ft("close")):Yr(e)?p(new TypeError("Cannot close an already-closing stream")):Ur(this)},WritableStreamDefaultWriter.prototype.releaseLock=function(){if(!Vr(this))throw ct("releaseLock");void 0!==this._ownerWritableStream&&Jr(this)},WritableStreamDefaultWriter.prototype.write=function(e){return void 0===e&&(e=void 0),Vr(this)?void 0===this._ownerWritableStream?p(ft("write to")):Kr(this,e):p(ct("write"))},WritableStreamDefaultWriter}();function Vr(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_ownerWritableStream")&&e instanceof Hr)}function Ur(e){return Lr(e._ownerWritableStream)}function Gr(e,r){"pending"===e._closedPromiseState?bt(e,r):function(e,r){pt(e,r)}(e,r)}function Xr(e,r){"pending"===e._readyPromiseState?yt(e,r):function(e,r){mt(e,r)}(e,r)}function Jr(e){var r=e._ownerWritableStream,t=new TypeError("Writer was released and can no longer be used to monitor the stream's closedness");Xr(e,t),Gr(e,t),r._writer=void 0,e._ownerWritableStream=void 0}function Kr(e,r){var t=e._ownerWritableStream,o=t._writableStreamController,n=function(e,r){if(void 0===e._strategySizeAlgorithm)return 1;try{return e._strategySizeAlgorithm(r)}catch(r){return it(e,r),1}}(o,r);if(t!==e._ownerWritableStream)return p(ft("write to"));var i=t._state;if("errored"===i)return p(t._storedError);if(Yr(t)||"closed"===i)return p(new TypeError("The stream is closing or closed and cannot be written to"));if("erroring"===i)return p(t._storedError);var a=function(e){return f(function(r,t){var o={_resolve:r,_reject:t};e._writeRequests.push(o)})}(t);return function(e,r,t){try{Oe(e,r,t)}catch(r){return void it(e,r)}var o=e._controlledWritableStream;if(!Yr(o)&&"writable"===o._state){Nr(o,at(e))}nt(e)}(o,r,n),a}Object.defineProperties(Hr.prototype,{abort:{enumerable:!0},close:{enumerable:!0},releaseLock:{enumerable:!0},write:{enumerable:!0},closed:{enumerable:!0},desiredSize:{enumerable:!0},ready:{enumerable:!0}}),i(Hr.prototype.abort,"abort"),i(Hr.prototype.close,"close"),i(Hr.prototype.releaseLock,"releaseLock"),i(Hr.prototype.write,"write"),"symbol"==typeof r.toStringTag&&Object.defineProperty(Hr.prototype,r.toStringTag,{value:"WritableStreamDefaultWriter",configurable:!0});var Zr={},$r=function(){function WritableStreamDefaultController(){throw new TypeError("Illegal constructor")}return Object.defineProperty(WritableStreamDefaultController.prototype,"abortReason",{get:function(){if(!et(this))throw st("abortReason");return this._abortReason},enumerable:!1,configurable:!0}),Object.defineProperty(WritableStreamDefaultController.prototype,"signal",{get:function(){if(!et(this))throw st("signal");if(void 0===this._abortController)throw new TypeError("WritableStreamDefaultController.prototype.signal is not supported");return this._abortController.signal},enumerable:!1,configurable:!0}),WritableStreamDefaultController.prototype.error=function(e){if(void 0===e&&(e=void 0),!et(this))throw st("error");"writable"===this._controlledWritableStream._state&&ut(this,e)},WritableStreamDefaultController.prototype[T]=function(e){var r=this._abortAlgorithm(e);return tt(this),r},WritableStreamDefaultController.prototype[P]=function(){je(this)},WritableStreamDefaultController}();function et(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_controlledWritableStream")&&e instanceof $r)}function rt(e,r,t,o,n,i,a,u){r._controlledWritableStream=e,e._writableStreamController=r,r._queue=void 0,r._queueTotalSize=void 0,je(r),r._abortReason=void 0,r._abortController=function(){if("function"==typeof AbortController)return new AbortController}(),r._started=!1,r._strategySizeAlgorithm=u,r._strategyHWM=a,r._writeAlgorithm=o,r._closeAlgorithm=n,r._abortAlgorithm=i;var l=at(r);Nr(e,l),h(d(t()),function(){return r._started=!0,nt(r),null},function(t){return r._started=!0,Ir(e,t),null})}function tt(e){e._writeAlgorithm=void 0,e._closeAlgorithm=void 0,e._abortAlgorithm=void 0,e._strategySizeAlgorithm=void 0}function ot(e){return e._strategyHWM-e._queueTotalSize}function nt(e){var r=e._controlledWritableStream;if(e._started&&void 0===r._inFlightWriteRequest)if("erroring"!==r._state){if(0!==e._queue.length){var t=e._queue.peek().value;t===Zr?function(e){var r=e._controlledWritableStream;(function(e){e._inFlightCloseRequest=e._closeRequest,e._closeRequest=void 0})(r),We(e);var t=e._closeAlgorithm();tt(e),h(t,function(){return function(e){e._inFlightCloseRequest._resolve(void 0),e._inFlightCloseRequest=void 0,"erroring"===e._state&&(e._storedError=void 0,void 0!==e._pendingAbortRequest&&(e._pendingAbortRequest._resolve(),e._pendingAbortRequest=void 0)),e._state="closed";var r=e._writer;void 0!==r&&ht(r)}(r),null},function(e){return function(e,r){e._inFlightCloseRequest._reject(r),e._inFlightCloseRequest=void 0,void 0!==e._pendingAbortRequest&&(e._pendingAbortRequest._reject(r),e._pendingAbortRequest=void 0),Ir(e,r)}(r,e),null})}(e):function(e,r){var t=e._controlledWritableStream;!function(e){e._inFlightWriteRequest=e._writeRequests.shift()}(t);var o=e._writeAlgorithm(r);h(o,function(){!function(e){e._inFlightWriteRequest._resolve(void 0),e._inFlightWriteRequest=void 0}(t);var r=t._state;if(We(e),!Yr(t)&&"writable"===r){var o=at(e);Nr(t,o)}return nt(e),null},function(r){return"writable"===t._state&&tt(e),function(e,r){e._inFlightWriteRequest._reject(r),e._inFlightWriteRequest=void 0,Ir(e,r)}(t,r),null})}(e,t)}}else xr(r)}function it(e,r){"writable"===e._controlledWritableStream._state&&ut(e,r)}function at(e){return ot(e)<=0}function ut(e,r){var t=e._controlledWritableStream;tt(e),Mr(t,r)}function lt(e){return new TypeError("WritableStream.prototype.".concat(e," can only be used on a WritableStream"))}function st(e){return new TypeError("WritableStreamDefaultController.prototype.".concat(e," can only be used on a WritableStreamDefaultController"))}function ct(e){return new TypeError("WritableStreamDefaultWriter.prototype.".concat(e," can only be used on a WritableStreamDefaultWriter"))}function ft(e){return new TypeError("Cannot "+e+" a stream using a released writer")}function dt(e){e._closedPromise=f(function(r,t){e._closedPromise_resolve=r,e._closedPromise_reject=t,e._closedPromiseState="pending"})}function pt(e,r){dt(e),bt(e,r)}function bt(e,r){void 0!==e._closedPromise_reject&&(y(e._closedPromise),e._closedPromise_reject(r),e._closedPromise_resolve=void 0,e._closedPromise_reject=void 0,e._closedPromiseState="rejected")}function ht(e){void 0!==e._closedPromise_resolve&&(e._closedPromise_resolve(void 0),e._closedPromise_resolve=void 0,e._closedPromise_reject=void 0,e._closedPromiseState="resolved")}function _t(e){e._readyPromise=f(function(r,t){e._readyPromise_resolve=r,e._readyPromise_reject=t}),e._readyPromiseState="pending"}function mt(e,r){_t(e),yt(e,r)}function vt(e){_t(e),gt(e)}function yt(e,r){void 0!==e._readyPromise_reject&&(y(e._readyPromise),e._readyPromise_reject(r),e._readyPromise_resolve=void 0,e._readyPromise_reject=void 0,e._readyPromiseState="rejected")}function gt(e){void 0!==e._readyPromise_resolve&&(e._readyPromise_resolve(void 0),e._readyPromise_resolve=void 0,e._readyPromise_reject=void 0,e._readyPromiseState="fulfilled")}Object.defineProperties($r.prototype,{abortReason:{enumerable:!0},signal:{enumerable:!0},error:{enumerable:!0}}),"symbol"==typeof r.toStringTag&&Object.defineProperty($r.prototype,r.toStringTag,{value:"WritableStreamDefaultController",configurable:!0});var St="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof global?global:void 0;var wt,Rt=(function(e){if("function"!=typeof e&&"object"!=typeof e)return!1;if("DOMException"!==e.name)return!1;try{return new e,!0}catch(e){return!1}}(wt=null==St?void 0:St.DOMException)?wt:void 0)||function(){var e=function(e,r){this.message=e||"",this.name=r||"Error",Error.captureStackTrace&&Error.captureStackTrace(this,this.constructor)};return i(e,"DOMException"),e.prototype=Object.create(Error.prototype),Object.defineProperty(e.prototype,"constructor",{value:e,writable:!0,configurable:!0}),e}();function Tt(e,r,t,o,n,i){var a=X(e),u=kr(r);e._disturbed=!0;var l=new Pt(u),s=new qt(l);return f(function(c,m){var v,g,S,w;if(void 0!==i){if(v=function(){var t=void 0!==i.reason?i.reason:new Rt("Aborted","AbortError"),a=[];o||a.push(function(){return"writable"===r._state?Fr(r,t):d(void 0)}),n||a.push(function(){return"readable"===e._state?eo(e,t):d(void 0)}),P(function(){return Promise.all(a.map(function(e){return e()}))},!0,t)},i.aborted)return void v();i.addEventListener("abort",v)}function R(){for(;!l._shuttingDown&&!r._backpressure&&"writable"===r._state&&!Yr(r)&&"readable"===e._state&&le(a);)ue(a,s);if(l._shuttingDown)return d(!0);if(r._backpressure)return b(u._readyPromise,R);var t=new Ct(l);return ue(a,t),t._promise}if(Et(e,a._closedPromise,function(e){return o?C(!0,e):P(function(){return Fr(r,e)},!0,e),null}),Et(r,u._closedPromise,function(r){return n?C(!0,r):P(function(){return eo(e,r)},!0,r),null}),g=e,S=a._closedPromise,w=function(){return t?C():P(function(){return function(e){var r=e._ownerWritableStream,t=r._state;return Yr(r)||"closed"===t?d(void 0):"errored"===t?p(r._storedError):Ur(e)}(u)}),null},"closed"===g._state?w():_(S,w),Yr(r)||"closed"===r._state){var T=new TypeError("the destination writable stream closed before all data could be piped to it");n?C(!0,T):P(function(){return eo(e,T)},!0,T)}function P(e,t,o){function n(){return h(e(),function(){return q(t,o)},function(e){return q(!0,e)}),null}l._shuttingDown||(l._shuttingDown=!0,"writable"!==r._state||Yr(r)?n():_(l._waitForWritesToFinish(),n))}function C(e,t){l._shuttingDown||(l._shuttingDown=!0,"writable"!==r._state||Yr(r)?q(e,t):_(l._waitForWritesToFinish(),function(){return q(e,t)}))}function q(e,r){return Jr(u),B(a),void 0!==i&&i.removeEventListener("abort",v),e?m(r):c(void 0),null}y(f(function(e,r){!function t(o){o?e():b(R(),t,r)}(!1)}))})}var Pt=function(){function e(e){this._writer=e,this._shuttingDown=!1,this._currentWrite=d(void 0)}return e.prototype._waitForWritesToFinish=function(){var e=this,r=this._currentWrite;return b(this._currentWrite,function(){return r!==e._currentWrite?e._waitForWritesToFinish():void 0})},e}(),Ct=function(){function e(e){var r=this;this._state=e,this._promise=f(function(e,t){r._resolvePromise=e,r._rejectPromise=t})}return e.prototype._chunkSteps=function(e){this._state._currentWrite=b(Kr(this._state._writer,e),void 0,t),this._resolvePromise(!1)},e.prototype._closeSteps=function(){this._resolvePromise(!0)},e.prototype._errorSteps=function(e){this._rejectPromise(e)},e}(),qt=function(){function e(e){this._state=e}return e.prototype._chunkSteps=function(e){this._state._currentWrite=b(Kr(this._state._writer,e),void 0,t)},e.prototype._closeSteps=function(){},e.prototype._errorSteps=function(e){},e}();function Et(e,r,t){"errored"===e._state?t(e._storedError):m(r,t)}var Wt=function(){function ReadableStreamDefaultController(){throw new TypeError("Illegal constructor")}return Object.defineProperty(ReadableStreamDefaultController.prototype,"desiredSize",{get:function(){if(!Ot(this))throw Mt("desiredSize");return Ft(this)},enumerable:!1,configurable:!0}),ReadableStreamDefaultController.prototype.close=function(){if(!Ot(this))throw Mt("close");if(!Lt(this))throw new TypeError("The stream is not in a state that permits close");At(this)},ReadableStreamDefaultController.prototype.enqueue=function(e){if(void 0===e&&(e=void 0),!Ot(this))throw Mt("enqueue");if(!Lt(this))throw new TypeError("The stream is not in a state that permits enqueue");return zt(this,e)},ReadableStreamDefaultController.prototype.error=function(e){if(void 0===e&&(e=void 0),!Ot(this))throw Mt("error");Dt(this,e)},ReadableStreamDefaultController.prototype[C]=function(e){je(this);var r=this._cancelAlgorithm(e);return kt(this),r},ReadableStreamDefaultController.prototype[q]=function(e){var r=this._controlledReadableStream;if(this._queue.length>0){var t=We(this);this._closeRequested&&0===this._queue.length?(kt(this),ro(r)):jt(this),e._chunkSteps(t)}else J(r,e),jt(this)},ReadableStreamDefaultController.prototype[E]=function(){return this._queue.length>0},ReadableStreamDefaultController.prototype[W]=function(){},ReadableStreamDefaultController}();function Ot(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_controlledReadableStream")&&e instanceof Wt)}function jt(e){Bt(e)&&(e._pulling?e._pullAgain=!0:(e._pulling=!0,h(e._pullAlgorithm(),function(){return e._pulling=!1,e._pullAgain&&(e._pullAgain=!1,jt(e)),null},function(r){return Dt(e,r),null})))}function Bt(e){var r=e._controlledReadableStream;return!!Lt(e)&&(!!e._started&&(!!($t(r)&&Z(r)>0)||Ft(e)>0))}function kt(e){e._pullAlgorithm=void 0,e._cancelAlgorithm=void 0,e._strategySizeAlgorithm=void 0}function At(e){if(Lt(e)){var r=e._controlledReadableStream;e._closeRequested=!0,0===e._queue.length&&(kt(e),ro(r))}}function zt(e,r){if(Lt(e)){var t=e._controlledReadableStream;if($t(t)&&Z(t)>0)K(t,r,!1);else{var o=void 0;try{o=e._strategySizeAlgorithm(r)}catch(r){throw Dt(e,r),r}try{Oe(e,r,o)}catch(r){throw Dt(e,r),r}}jt(e)}}function Dt(e,r){var t=e._controlledReadableStream;"readable"===t._state&&(je(e),kt(e),to(t,r))}function Ft(e){var r=e._controlledReadableStream._state;return"errored"===r?null:"closed"===r?0:e._strategyHWM-e._queueTotalSize}function Lt(e){var r=e._controlledReadableStream._state;return!e._closeRequested&&"readable"===r}function It(e,r,t,o,n,i,a){r._controlledReadableStream=e,r._queue=void 0,r._queueTotalSize=void 0,je(r),r._started=!1,r._closeRequested=!1,r._pullAgain=!1,r._pulling=!1,r._strategySizeAlgorithm=a,r._strategyHWM=i,r._pullAlgorithm=o,r._cancelAlgorithm=n,e._readableStreamController=r,h(d(t()),function(){return r._started=!0,jt(r),null},function(e){return Dt(r,e),null})}function Mt(e){return new TypeError("ReadableStreamDefaultController.prototype.".concat(e," can only be used on a ReadableStreamDefaultController"))}function xt(e,r){return De(e._readableStreamController)?function(e){var r,t,o,n,i,a=X(e),u=!1,l=!1,s=!1,c=!1,p=!1,b=f(function(e){i=e});function h(e){m(e._closedPromise,function(r){return e!==a||(tr(o._readableStreamController,r),tr(n._readableStreamController,r),c&&p||i(void 0)),null})}function _(){yr(a)&&(B(a),h(a=X(e))),ue(a,{_chunkSteps:function(r){g(function(){l=!1,s=!1;var t=r,a=r;if(!c&&!p)try{a=Ee(r)}catch(r){return tr(o._readableStreamController,r),tr(n._readableStreamController,r),void i(eo(e,r))}c||rr(o._readableStreamController,t),p||rr(n._readableStreamController,a),u=!1,l?y():s&&S()})},_closeSteps:function(){u=!1,c||er(o._readableStreamController),p||er(n._readableStreamController),o._readableStreamController._pendingPullIntos.length>0&&ar(o._readableStreamController,0),n._readableStreamController._pendingPullIntos.length>0&&ar(n._readableStreamController,0),c&&p||i(void 0)},_errorSteps:function(){u=!1}})}function v(r,t){ae(a)&&(B(a),h(a=dr(e)));var f=t?n:o,d=t?o:n;gr(a,r,1,{_chunkSteps:function(r){g(function(){l=!1,s=!1;var o=t?p:c;if(t?c:p)o||ur(f._readableStreamController,r);else{var n=void 0;try{n=Ee(r)}catch(r){return tr(f._readableStreamController,r),tr(d._readableStreamController,r),void i(eo(e,r))}o||ur(f._readableStreamController,r),rr(d._readableStreamController,n)}u=!1,l?y():s&&S()})},_closeSteps:function(e){u=!1;var r=t?p:c,o=t?c:p;r||er(f._readableStreamController),o||er(d._readableStreamController),void 0!==e&&(r||ur(f._readableStreamController,e),!o&&d._readableStreamController._pendingPullIntos.length>0&&ar(d._readableStreamController,0)),r&&o||i(void 0)},_errorSteps:function(){u=!1}})}function y(){if(u)return l=!0,d(void 0);u=!0;var e=nr(o._readableStreamController);return null===e?_():v(e._view,!1),d(void 0)}function S(){if(u)return s=!0,d(void 0);u=!0;var e=nr(n._readableStreamController);return null===e?_():v(e._view,!0),d(void 0)}function w(o){if(c=!0,r=o,p){var n=fe([r,t]),a=eo(e,n);i(a)}return b}function R(o){if(p=!0,t=o,c){var n=fe([r,t]),a=eo(e,n);i(a)}return b}function T(){}return o=Jt(T,y,w),n=Jt(T,S,R),h(a),[o,n]}(e):function(e){var r,t,o,n,i,a=X(e),u=!1,l=!1,s=!1,c=!1,p=f(function(e){i=e});function b(){return u?(l=!0,d(void 0)):(u=!0,ue(a,{_chunkSteps:function(e){g(function(){l=!1;var r=e,t=e;s||zt(o._readableStreamController,r),c||zt(n._readableStreamController,t),u=!1,l&&b()})},_closeSteps:function(){u=!1,s||At(o._readableStreamController),c||At(n._readableStreamController),s&&c||i(void 0)},_errorSteps:function(){u=!1}}),d(void 0))}function h(o){if(s=!0,r=o,c){var n=fe([r,t]),a=eo(e,n);i(a)}return p}function _(o){if(c=!0,t=o,s){var n=fe([r,t]),a=eo(e,n);i(a)}return p}function v(){}return o=Xt(v,b,h),n=Xt(v,b,_),m(a._closedPromise,function(e){return Dt(o._readableStreamController,e),Dt(n._readableStreamController,e),s&&c||i(void 0),null}),[o,n]}(e)}function Yt(e){return o(r=e)&&void 0!==r.getReader?function(e){var r;function n(){var t;try{t=e.read()}catch(e){return p(e)}return v(t,function(e){if(!o(e))throw new TypeError("The promise returned by the reader.read() method must fulfill with an object");if(e.done)At(r._readableStreamController);else{var t=e.value;zt(r._readableStreamController,t)}})}function i(r){try{return d(e.cancel(r))}catch(e){return p(e)}}return r=Xt(t,n,i,0),r}(e.getReader()):function(e){var r,n=ge(e,"async");function i(){var e;try{e=Se(n)}catch(e){return p(e)}return v(d(e),function(e){if(!o(e))throw new TypeError("The promise returned by the iterator.next() method must fulfill with an object");if(e.done)At(r._readableStreamController);else{var t=e.value;zt(r._readableStreamController,t)}})}function a(e){var r,t=n.iterator;try{r=_e(t,"return")}catch(e){return p(e)}return void 0===r?d(void 0):v(w(r,t,[e]),function(e){if(!o(e))throw new TypeError("The promise returned by the iterator.return() method must fulfill with an object")})}return r=Xt(t,i,a,0),r}(e);var r}function Qt(e,r,t){return x(e,t),function(t){return w(e,r,[t])}}function Nt(e,r,t){return x(e,t),function(t){return w(e,r,[t])}}function Ht(e,r,t){return x(e,t),function(t){return S(e,r,[t])}}function Vt(e,r){if("bytes"!==(e="".concat(e)))throw new TypeError("".concat(r," '").concat(e,"' is not a valid enumeration value for ReadableStreamType"));return e}function Ut(e,r){M(e,r);var t=null==e?void 0:e.preventAbort,o=null==e?void 0:e.preventCancel,n=null==e?void 0:e.preventClose,i=null==e?void 0:e.signal;return void 0!==i&&function(e,r){if(!function(e){if("object"!=typeof e||null===e)return!1;try{return"boolean"==typeof e.aborted}catch(e){return!1}}(e))throw new TypeError("".concat(r," is not an AbortSignal."))}(i,"".concat(r," has member 'signal' that")),{preventAbort:Boolean(t),preventCancel:Boolean(o),preventClose:Boolean(n),signal:i}}Object.defineProperties(Wt.prototype,{close:{enumerable:!0},enqueue:{enumerable:!0},error:{enumerable:!0},desiredSize:{enumerable:!0}}),i(Wt.prototype.close,"close"),i(Wt.prototype.enqueue,"enqueue"),i(Wt.prototype.error,"error"),"symbol"==typeof r.toStringTag&&Object.defineProperty(Wt.prototype,r.toStringTag,{value:"ReadableStreamDefaultController",configurable:!0});var Gt=function(){function ReadableStream(e,r){void 0===e&&(e={}),void 0===r&&(r={}),void 0===e?e=null:Y(e,"First parameter");var t=Pr(r,"Second parameter"),o=function(e,r){M(e,r);var t=e,o=null==t?void 0:t.autoAllocateChunkSize,n=null==t?void 0:t.cancel,i=null==t?void 0:t.pull,a=null==t?void 0:t.start,u=null==t?void 0:t.type;return{autoAllocateChunkSize:void 0===o?void 0:U(o,"".concat(r," has member 'autoAllocateChunkSize' that")),cancel:void 0===n?void 0:Qt(n,t,"".concat(r," has member 'cancel' that")),pull:void 0===i?void 0:Nt(i,t,"".concat(r," has member 'pull' that")),start:void 0===a?void 0:Ht(a,t,"".concat(r," has member 'start' that")),type:void 0===u?void 0:Vt(u,"".concat(r," has member 'type' that"))}}(e,"First parameter");if(Kt(this),"bytes"===o.type){if(void 0!==t.size)throw new RangeError("The strategy for a byte stream cannot have a size function");!function(e,r,t){var o,n,i,a=Object.create(ze.prototype);o=void 0!==r.start?function(){return r.start(a)}:function(){},n=void 0!==r.pull?function(){return r.pull(a)}:function(){return d(void 0)},i=void 0!==r.cancel?function(e){return r.cancel(e)}:function(){return d(void 0)};var u=r.autoAllocateChunkSize;if(0===u)throw new TypeError("autoAllocateChunkSize must be greater than 0");lr(e,a,o,n,i,t,u)}(this,o,Rr(t,0))}else{var n=Tr(t);!function(e,r,t,o){var n,i,a,u=Object.create(Wt.prototype);n=void 0!==r.start?function(){return r.start(u)}:function(){},i=void 0!==r.pull?function(){return r.pull(u)}:function(){return d(void 0)},a=void 0!==r.cancel?function(e){return r.cancel(e)}:function(){return d(void 0)},It(e,u,n,i,a,t,o)}(this,o,Rr(t,1),n)}}return Object.defineProperty(ReadableStream.prototype,"locked",{get:function(){if(!Zt(this))throw oo("locked");return $t(this)},enumerable:!1,configurable:!0}),ReadableStream.prototype.cancel=function(e){return void 0===e&&(e=void 0),Zt(this)?$t(this)?p(new TypeError("Cannot cancel a stream that already has a reader")):eo(this,e):p(oo("cancel"))},ReadableStream.prototype.getReader=function(e){if(void 0===e&&(e=void 0),!Zt(this))throw oo("getReader");return void 0===function(e,r){M(e,r);var t=null==e?void 0:e.mode;return{mode:void 0===t?void 0:fr(t,"".concat(r," has member 'mode' that"))}}(e,"First parameter").mode?X(this):dr(this)},ReadableStream.prototype.pipeThrough=function(e,r){if(void 0===r&&(r={}),!Zt(this))throw oo("pipeThrough");Q(e,1,"pipeThrough");var t=function(e,r){M(e,r);var t=null==e?void 0:e.readable;N(t,"readable","ReadableWritablePair"),G(t,"".concat(r," has member 'readable' that"));var o=null==e?void 0:e.writable;return N(o,"writable","ReadableWritablePair"),jr(o,"".concat(r," has member 'writable' that")),{readable:t,writable:o}}(e,"First parameter"),o=Ut(r,"Second parameter");if($t(this))throw new TypeError("ReadableStream.prototype.pipeThrough cannot be used on a locked ReadableStream");if(Dr(t.writable))throw new TypeError("ReadableStream.prototype.pipeThrough cannot be used on a locked WritableStream");return y(Tt(this,t.writable,o.preventClose,o.preventAbort,o.preventCancel,o.signal)),t.readable},ReadableStream.prototype.pipeTo=function(e,r){if(void 0===r&&(r={}),!Zt(this))return p(oo("pipeTo"));if(void 0===e)return p("Parameter 1 is required in 'pipeTo'.");if(!zr(e))return p(new TypeError("ReadableStream.prototype.pipeTo's first argument must be a WritableStream"));var t;try{t=Ut(r,"Second parameter")}catch(e){return p(e)}return $t(this)?p(new TypeError("ReadableStream.prototype.pipeTo cannot be used on a locked ReadableStream")):Dr(e)?p(new TypeError("ReadableStream.prototype.pipeTo cannot be used on a locked WritableStream")):Tt(this,e,t.preventClose,t.preventAbort,t.preventCancel,t.signal)},ReadableStream.prototype.tee=function(){if(!Zt(this))throw oo("tee");return fe(xt(this))},ReadableStream.prototype.values=function(e){if(void 0===e&&(e=void 0),!Zt(this))throw oo("values");var r,t,o,n,i,a=function(e,r){M(e,r);var t=null==e?void 0:e.preventCancel;return{preventCancel:Boolean(t)}}(e,"First parameter");return r=this,t=a.preventCancel,o=X(r),n=new we(o,t),(i=Object.create(Te))._asyncIteratorImpl=n,i},ReadableStream.prototype[ye]=function(e){return this.values(e)},ReadableStream.from=function(e){return Yt(e)},ReadableStream}();function Xt(e,r,t,o,n){void 0===o&&(o=1),void 0===n&&(n=function(){return 1});var i=Object.create(Gt.prototype);return Kt(i),It(i,Object.create(Wt.prototype),e,r,t,o,n),i}function Jt(e,r,t){var o=Object.create(Gt.prototype);return Kt(o),lr(o,Object.create(ze.prototype),e,r,t,0,void 0),o}function Kt(e){e._state="readable",e._reader=void 0,e._storedError=void 0,e._disturbed=!1}function Zt(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_readableStreamController")&&e instanceof Gt)}function $t(e){return void 0!==e._reader}function eo(e,r){if(e._disturbed=!0,"closed"===e._state)return d(void 0);if("errored"===e._state)return p(e._storedError);ro(e);var o=e._reader;if(void 0!==o&&yr(o)){var n=o._readIntoRequests;o._readIntoRequests=new R,n.forEach(function(e){e._closeSteps(void 0)})}return v(e._readableStreamController[C](r),t)}function ro(e){e._state="closed";var r=e._reader;if(void 0!==r&&(F(r),ae(r))){var t=r._readRequests;r._readRequests=new R,t.forEach(function(e){e._closeSteps()})}}function to(e,r){e._state="errored",e._storedError=r;var t=e._reader;void 0!==t&&(D(t,r),ae(t)?se(t,r):Sr(t,r))}function oo(e){return new TypeError("ReadableStream.prototype.".concat(e," can only be used on a ReadableStream"))}function no(e,r){M(e,r);var t=null==e?void 0:e.highWaterMark;return N(t,"highWaterMark","QueuingStrategyInit"),{highWaterMark:H(t)}}Object.defineProperties(Gt,{from:{enumerable:!0}}),Object.defineProperties(Gt.prototype,{cancel:{enumerable:!0},getReader:{enumerable:!0},pipeThrough:{enumerable:!0},pipeTo:{enumerable:!0},tee:{enumerable:!0},values:{enumerable:!0},locked:{enumerable:!0}}),i(Gt.from,"from"),i(Gt.prototype.cancel,"cancel"),i(Gt.prototype.getReader,"getReader"),i(Gt.prototype.pipeThrough,"pipeThrough"),i(Gt.prototype.pipeTo,"pipeTo"),i(Gt.prototype.tee,"tee"),i(Gt.prototype.values,"values"),"symbol"==typeof r.toStringTag&&Object.defineProperty(Gt.prototype,r.toStringTag,{value:"ReadableStream",configurable:!0}),Object.defineProperty(Gt.prototype,ye,{value:Gt.prototype.values,writable:!0,configurable:!0});var io=function(e){return e.byteLength};i(io,"size");var ao=function(){function ByteLengthQueuingStrategy(e){Q(e,1,"ByteLengthQueuingStrategy"),e=no(e,"First parameter"),this._byteLengthQueuingStrategyHighWaterMark=e.highWaterMark}return Object.defineProperty(ByteLengthQueuingStrategy.prototype,"highWaterMark",{get:function(){if(!lo(this))throw uo("highWaterMark");return this._byteLengthQueuingStrategyHighWaterMark},enumerable:!1,configurable:!0}),Object.defineProperty(ByteLengthQueuingStrategy.prototype,"size",{get:function(){if(!lo(this))throw uo("size");return io},enumerable:!1,configurable:!0}),ByteLengthQueuingStrategy}();function uo(e){return new TypeError("ByteLengthQueuingStrategy.prototype.".concat(e," can only be used on a ByteLengthQueuingStrategy"))}function lo(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_byteLengthQueuingStrategyHighWaterMark")&&e instanceof ao)}Object.defineProperties(ao.prototype,{highWaterMark:{enumerable:!0},size:{enumerable:!0}}),"symbol"==typeof r.toStringTag&&Object.defineProperty(ao.prototype,r.toStringTag,{value:"ByteLengthQueuingStrategy",configurable:!0});var so=function(){return 1};i(so,"size");var co=function(){function CountQueuingStrategy(e){Q(e,1,"CountQueuingStrategy"),e=no(e,"First parameter"),this._countQueuingStrategyHighWaterMark=e.highWaterMark}return Object.defineProperty(CountQueuingStrategy.prototype,"highWaterMark",{get:function(){if(!po(this))throw fo("highWaterMark");return this._countQueuingStrategyHighWaterMark},enumerable:!1,configurable:!0}),Object.defineProperty(CountQueuingStrategy.prototype,"size",{get:function(){if(!po(this))throw fo("size");return so},enumerable:!1,configurable:!0}),CountQueuingStrategy}();function fo(e){return new TypeError("CountQueuingStrategy.prototype.".concat(e," can only be used on a CountQueuingStrategy"))}function po(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_countQueuingStrategyHighWaterMark")&&e instanceof co)}function bo(e,r,t){return x(e,t),function(t){return w(e,r,[t])}}function ho(e,r,t){return x(e,t),function(t){return S(e,r,[t])}}function _o(e,r,t){return x(e,t),function(t,o){return w(e,r,[t,o])}}function mo(e,r,t){return x(e,t),function(t){return w(e,r,[t])}}Object.defineProperties(co.prototype,{highWaterMark:{enumerable:!0},size:{enumerable:!0}}),"symbol"==typeof r.toStringTag&&Object.defineProperty(co.prototype,r.toStringTag,{value:"CountQueuingStrategy",configurable:!0});var vo=function(){function TransformStream(e,r,t){void 0===e&&(e={}),void 0===r&&(r={}),void 0===t&&(t={}),void 0===e&&(e=null);var o=Pr(r,"Second parameter"),n=Pr(t,"Third parameter"),i=function(e,r){M(e,r);var t=null==e?void 0:e.cancel,o=null==e?void 0:e.flush,n=null==e?void 0:e.readableType,i=null==e?void 0:e.start,a=null==e?void 0:e.transform,u=null==e?void 0:e.writableType;return{cancel:void 0===t?void 0:mo(t,e,"".concat(r," has member 'cancel' that")),flush:void 0===o?void 0:bo(o,e,"".concat(r," has member 'flush' that")),readableType:n,start:void 0===i?void 0:ho(i,e,"".concat(r," has member 'start' that")),transform:void 0===a?void 0:_o(a,e,"".concat(r," has member 'transform' that")),writableType:u}}(e,"First parameter");if(void 0!==i.readableType)throw new RangeError("Invalid readableType specified");if(void 0!==i.writableType)throw new RangeError("Invalid writableType specified");var a,u=Rr(n,0),l=Tr(n),s=Rr(o,1),c=Tr(o);!function(e,r,t,o,n,i){function a(){return r}function u(r){return function(e,r){var t=e._transformStreamController;if(e._backpressure){return v(e._backpressureChangePromise,function(){var o=e._writable;if("erroring"===o._state)throw o._storedError;return Eo(t,r)})}return Eo(t,r)}(e,r)}function l(r){return function(e,r){var t=e._transformStreamController;if(void 0!==t._finishPromise)return t._finishPromise;var o=e._readable;t._finishPromise=f(function(e,r){t._finishPromise_resolve=e,t._finishPromise_reject=r});var n=t._cancelAlgorithm(r);return Co(t),h(n,function(){return"errored"===o._state?jo(t,o._storedError):(Dt(o._readableStreamController,r),Oo(t)),null},function(e){return Dt(o._readableStreamController,e),jo(t,e),null}),t._finishPromise}(e,r)}function s(){return function(e){var r=e._transformStreamController;if(void 0!==r._finishPromise)return r._finishPromise;var t=e._readable;r._finishPromise=f(function(e,t){r._finishPromise_resolve=e,r._finishPromise_reject=t});var o=r._flushAlgorithm();return Co(r),h(o,function(){return"errored"===t._state?jo(r,t._storedError):(At(t._readableStreamController),Oo(r)),null},function(e){return Dt(t._readableStreamController,e),jo(r,e),null}),r._finishPromise}(e)}function c(){return function(e){return Ro(e,!1),e._backpressureChangePromise}(e)}function d(r){return function(e,r){var t=e._transformStreamController;if(void 0!==t._finishPromise)return t._finishPromise;var o=e._writable;t._finishPromise=f(function(e,r){t._finishPromise_resolve=e,t._finishPromise_reject=r});var n=t._cancelAlgorithm(r);return Co(t),h(n,function(){return"errored"===o._state?jo(t,o._storedError):(it(o._writableStreamController,r),wo(e),Oo(t)),null},function(r){return it(o._writableStreamController,r),wo(e),jo(t,r),null}),t._finishPromise}(e,r)}e._writable=function(e,r,t,o,n,i){void 0===n&&(n=1),void 0===i&&(i=function(){return 1});var a=Object.create(Br.prototype);return Ar(a),rt(a,Object.create($r.prototype),e,r,t,o,n,i),a}(a,u,s,l,t,o),e._readable=Xt(a,c,d,n,i),e._backpressure=void 0,e._backpressureChangePromise=void 0,e._backpressureChangePromise_resolve=void 0,Ro(e,!0),e._transformStreamController=void 0}(this,f(function(e){a=e}),s,c,u,l),function(e,r){var t,o,n,i=Object.create(To.prototype);t=void 0!==r.transform?function(e){return r.transform(e,i)}:function(e){try{return qo(i,e),d(void 0)}catch(e){return p(e)}};o=void 0!==r.flush?function(){return r.flush(i)}:function(){return d(void 0)};n=void 0!==r.cancel?function(e){return r.cancel(e)}:function(){return d(void 0)};!function(e,r,t,o,n){r._controlledTransformStream=e,e._transformStreamController=r,r._transformAlgorithm=t,r._flushAlgorithm=o,r._cancelAlgorithm=n,r._finishPromise=void 0,r._finishPromise_resolve=void 0,r._finishPromise_reject=void 0}(e,i,t,o,n)}(this,i),void 0!==i.start?a(i.start(this._transformStreamController)):a(void 0)}return Object.defineProperty(TransformStream.prototype,"readable",{get:function(){if(!yo(this))throw Bo("readable");return this._readable},enumerable:!1,configurable:!0}),Object.defineProperty(TransformStream.prototype,"writable",{get:function(){if(!yo(this))throw Bo("writable");return this._writable},enumerable:!1,configurable:!0}),TransformStream}();function yo(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_transformStreamController")&&e instanceof vo)}function go(e,r){Dt(e._readable._readableStreamController,r),So(e,r)}function So(e,r){Co(e._transformStreamController),it(e._writable._writableStreamController,r),wo(e)}function wo(e){e._backpressure&&Ro(e,!1)}function Ro(e,r){void 0!==e._backpressureChangePromise&&e._backpressureChangePromise_resolve(),e._backpressureChangePromise=f(function(r){e._backpressureChangePromise_resolve=r}),e._backpressure=r}Object.defineProperties(vo.prototype,{readable:{enumerable:!0},writable:{enumerable:!0}}),"symbol"==typeof r.toStringTag&&Object.defineProperty(vo.prototype,r.toStringTag,{value:"TransformStream",configurable:!0});var To=function(){function TransformStreamDefaultController(){throw new TypeError("Illegal constructor")}return Object.defineProperty(TransformStreamDefaultController.prototype,"desiredSize",{get:function(){if(!Po(this))throw Wo("desiredSize");return Ft(this._controlledTransformStream._readable._readableStreamController)},enumerable:!1,configurable:!0}),TransformStreamDefaultController.prototype.enqueue=function(e){if(void 0===e&&(e=void 0),!Po(this))throw Wo("enqueue");qo(this,e)},TransformStreamDefaultController.prototype.error=function(e){if(void 0===e&&(e=void 0),!Po(this))throw Wo("error");var r;r=e,go(this._controlledTransformStream,r)},TransformStreamDefaultController.prototype.terminate=function(){if(!Po(this))throw Wo("terminate");!function(e){var r=e._controlledTransformStream;At(r._readable._readableStreamController);var t=new TypeError("TransformStream terminated");So(r,t)}(this)},TransformStreamDefaultController}();function Po(e){return!!o(e)&&(!!Object.prototype.hasOwnProperty.call(e,"_controlledTransformStream")&&e instanceof To)}function Co(e){e._transformAlgorithm=void 0,e._flushAlgorithm=void 0,e._cancelAlgorithm=void 0}function qo(e,r){var t=e._controlledTransformStream,o=t._readable._readableStreamController;if(!Lt(o))throw new TypeError("Readable side is not in a state that permits enqueue");try{zt(o,r)}catch(e){throw So(t,e),t._readable._storedError}var n=function(e){return!Bt(e)}(o);n!==t._backpressure&&Ro(t,!0)}function Eo(e,r){return v(e._transformAlgorithm(r),void 0,function(r){throw go(e._controlledTransformStream,r),r})}function Wo(e){return new TypeError("TransformStreamDefaultController.prototype.".concat(e," can only be used on a TransformStreamDefaultController"))}function Oo(e){void 0!==e._finishPromise_resolve&&(e._finishPromise_resolve(),e._finishPromise_resolve=void 0,e._finishPromise_reject=void 0)}function jo(e,r){void 0!==e._finishPromise_reject&&(y(e._finishPromise),e._finishPromise_reject(r),e._finishPromise_resolve=void 0,e._finishPromise_reject=void 0)}function Bo(e){return new TypeError("TransformStream.prototype.".concat(e," can only be used on a TransformStream"))}Object.defineProperties(To.prototype,{enqueue:{enumerable:!0},error:{enumerable:!0},terminate:{enumerable:!0},desiredSize:{enumerable:!0}}),i(To.prototype.enqueue,"enqueue"),i(To.prototype.error,"error"),i(To.prototype.terminate,"terminate"),"symbol"==typeof r.toStringTag&&Object.defineProperty(To.prototype,r.toStringTag,{value:"TransformStreamDefaultController",configurable:!0}),e.ByteLengthQueuingStrategy=ao,e.CountQueuingStrategy=co,e.ReadableByteStreamController=ze,e.ReadableStream=Gt,e.ReadableStreamBYOBReader=_r,e.ReadableStreamBYOBRequest=Ae,e.ReadableStreamDefaultController=Wt,e.ReadableStreamDefaultReader=ee,e.TransformStream=vo,e.TransformStreamDefaultController=To,e.WritableStream=Br,e.WritableStreamDefaultController=$r,e.WritableStreamDefaultWriter=Hr}); diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index 2dbc7619..686b79a5 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -70,6 +70,7 @@ target_link_libraries(UnitTests PRIVATE Performance PRIVATE TextDecoder PRIVATE TextEncoder + PRIVATE Streams ${ADDITIONAL_LIBRARIES}) # See https://gitlab.kitware.com/cmake/cmake/-/issues/23543 diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index b286edf8..973c6420 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1371,6 +1371,162 @@ describe("Console", function () { }); }); +describe("Web Streams", function () { + // Focused ports from the WHATWG Streams WPT suites at c05b4473: + // readable-streams/general.any.js and tee.any.js, + // writable-streams/write.any.js, and transform-streams/general.any.js. + it("installs the standard stream constructors", function () { + expect(ReadableStream).to.be.a("function"); + expect(WritableStream).to.be.a("function"); + expect(TransformStream).to.be.a("function"); + expect(ByteLengthQueuingStrategy).to.be.a("function"); + expect(CountQueuingStrategy).to.be.a("function"); + }); + + it("delivers queued chunks in order and closes the reader", async function () { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue("a"); + controller.enqueue("b"); + controller.close(); + } + }); + const reader = stream.getReader(); + + expect(await reader.read()).to.deep.equal({ value: "a", done: false }); + expect(await reader.read()).to.deep.equal({ value: "b", done: false }); + expect(await reader.read()).to.deep.equal({ value: undefined, done: true }); + await reader.closed; + }); + + it("propagates a rejected pull to read and closed", async function () { + const failure = new Error("pull failed"); + const reader = new ReadableStream({ + pull() { + return Promise.reject(failure); + } + }).getReader(); + + let readFailure: unknown; + let closedFailure: unknown; + try { await reader.read(); } catch (error) { readFailure = error; } + try { await reader.closed; } catch (error) { closedFailure = error; } + expect(readFailure).to.equal(failure); + expect(closedFailure).to.equal(failure); + }); + + it("tees without one branch consuming the other", async function () { + const [first, second] = new ReadableStream({ + start(controller) { + controller.enqueue("a"); + controller.enqueue("b"); + controller.close(); + } + }).tee(); + const firstReader = first.getReader(); + const secondReader = second.getReader(); + + expect(await firstReader.read()).to.deep.equal({ value: "a", done: false }); + expect(await firstReader.read()).to.deep.equal({ value: "b", done: false }); + expect(await firstReader.read()).to.deep.equal({ value: undefined, done: true }); + expect(await secondReader.read()).to.deep.equal({ value: "a", done: false }); + }); + + it("waits for asynchronous writes before closing", async function () { + const stored: number[] = []; + const writable = new WritableStream({ + write(chunk) { + return Promise.resolve().then(() => stored.push(chunk)); + } + }); + const writer = writable.getWriter(); + + writer.write(1); + writer.write(2); + await writer.close(); + expect(stored).to.deep.equal([1, 2]); + }); + + it("applies transform output and backpressure", async function () { + const transform = new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk.toUpperCase()); + } + }); + const writer = transform.writable.getWriter(); + const reader = transform.readable.getReader(); + const write = writer.write("native"); + + expect(await reader.read()).to.deep.equal({ value: "NATIVE", done: false }); + await write; + await writer.close(); + expect(await reader.read()).to.deep.equal({ value: undefined, done: true }); + }); + + it("supports BYOB reads from byte streams", async function () { + let sent = false; + const stream = new ReadableStream({ + type: "bytes", + pull(controller) { + if (!sent) { + sent = true; + controller.enqueue(new Uint8Array([8, 241, 48, 123, 151])); + controller.close(); + } + } + } as any); + const reader = stream.getReader({ mode: "byob" }); + const result = await reader.read(new Uint8Array(8)); + + expect(result.done).to.equal(false); + expect(Array.from(result.value!)).to.deep.equal([8, 241, 48, 123, 151]); + expect((await reader.read(new Uint8Array(8))).done).to.equal(true); + }); + + // Ported from Firefox's dom/streams/test/xpcshell/subclassing.js. + it("supports subclassed streams, readers, and queuing strategies", async function () { + class SubclassedStream extends ReadableStream {} + class SubclassedStrategy extends CountQueuingStrategy {} + const stream = new SubclassedStream({ + start(controller) { + controller.enqueue("first"); + controller.close(); + } + }); + const Reader = stream.getReader().constructor as typeof ReadableStreamDefaultReader; + class SubclassedReader extends Reader {} + + expect(stream).to.be.instanceOf(ReadableStream); + expect(new SubclassedStrategy({ highWaterMark: 4 }).highWaterMark).to.equal(4); + + const secondStream = new ReadableStream({ + start(controller) { + controller.enqueue("second"); + controller.close(); + } + }); + const reader = new SubclassedReader(secondStream); + expect(await reader.read()).to.deep.equal({ value: "second", done: false }); + }); + + // Ported from Chromium's http/tests/streams/chromium/transform-stream-enqueue.html. + it("rejects enqueues after a transform is terminated or errored", function () { + expect(() => new TransformStream({ + start(controller) { + controller.terminate(); + controller.enqueue("late"); + } + })).to.throw(TypeError); + + expect(() => new TransformStream({ + start(controller) { + controller.error(new Error("failed")); + controller.enqueue("late"); + } + })).to.throw(TypeError); + }); +}); + describe("Blob", function () { let emptyBlobs: Blob[], helloBlobs: Blob[], stringBlob: Blob, typedArrayBlob: Blob, arrayBufferBlob: Blob, blobBlob: Blob; diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index a920fa1f..e08d1f4d 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -90,6 +91,7 @@ TEST(JavaScript, All) Babylon::Polyfills::File::Initialize(env); Babylon::Polyfills::TextDecoder::Initialize(env); Babylon::Polyfills::TextEncoder::Initialize(env); + Babylon::Polyfills::Streams::Initialize(env); auto setExitCodeCallback = Napi::Function::New( env, [&exitCodePromise](const Napi::CallbackInfo& info) { @@ -111,6 +113,30 @@ TEST(JavaScript, All) EXPECT_EQ(exitCode, 0); } +TEST(Streams, PreservesHostConstructorsAndIsIdempotent) +{ + Babylon::AppRuntime runtime{}; + std::promise done; + + runtime.Dispatch([&done](Napi::Env env) { + auto global = env.Global(); + const auto hostReadableStream = Napi::Function::New(env, [](const Napi::CallbackInfo&) {}, "HostReadableStream"); + global.Set("ReadableStream", hostReadableStream); + + Babylon::Polyfills::Streams::Initialize(env); + EXPECT_TRUE(global.Get("ReadableStream").StrictEquals(hostReadableStream)); + EXPECT_TRUE(global.Get("TransformStream").IsFunction()); + + const auto installedTransformStream = global.Get("TransformStream"); + Babylon::Polyfills::Streams::Initialize(env); + EXPECT_TRUE(global.Get("ReadableStream").StrictEquals(hostReadableStream)); + EXPECT_TRUE(global.Get("TransformStream").StrictEquals(installedTransformStream)); + done.set_value(); + }); + + done.get_future().get(); +} + TEST(Console, Log) { Babylon::AppRuntime runtime{}; From 4529fc45e9fddfefb68583761fcf8bddd87e561e Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Tue, 21 Jul 2026 22:19:21 -0700 Subject: [PATCH 02/10] Complete Blob streaming and slicing support Implement iterable BlobPart conversion, MIME and endings normalization, zero-copy Blob composition and slicing, and lazy 64 KiB byte streams. Delegate File streaming and slicing to its backing Blob while preserving browser class identity.\n\nUse immutable shared segments so nested Blob and slice construction do not duplicate payload bytes. Add focused WPT, WebKit, and Firefox regression coverage for constructor ordering, iterator closure, UTF-8 decoding, BYOB reads, cancellation, lifetime safety, and large nested streams. --- Polyfills/Blob/CMakeLists.txt | 1 + Polyfills/Blob/README.md | 12 + Polyfills/Blob/Source/Blob.cpp | 484 ++++++++++++++++++++++++++++--- Polyfills/Blob/Source/Blob.h | 20 +- Polyfills/File/Source/File.cpp | 47 ++- Polyfills/File/Source/File.h | 2 + Tests/UnitTests/Scripts/tests.ts | 227 +++++++++++++++ 7 files changed, 734 insertions(+), 59 deletions(-) create mode 100644 Polyfills/Blob/README.md diff --git a/Polyfills/Blob/CMakeLists.txt b/Polyfills/Blob/CMakeLists.txt index 1b77ef6d..7577e668 100644 --- a/Polyfills/Blob/CMakeLists.txt +++ b/Polyfills/Blob/CMakeLists.txt @@ -1,5 +1,6 @@ set(SOURCES "Include/Babylon/Polyfills/Blob.h" + "README.md" "Source/Blob.cpp" "Source/Blob.h") diff --git a/Polyfills/Blob/README.md b/Polyfills/Blob/README.md new file mode 100644 index 00000000..2206f9c4 --- /dev/null +++ b/Polyfills/Blob/README.md @@ -0,0 +1,12 @@ +# Blob + +Provides the browser `Blob` constructor, byte/text readers, zero-copy Blob +composition and slicing, and a lazily pulled byte `ReadableStream`. + +Call `Babylon::Polyfills::Streams::Initialize` before using `Blob.stream()` on +engines that do not provide Web Streams. Stream reads copy only the requested +chunk into JavaScript-owned memory; composing Blobs and slicing share immutable +native byte segments. + +The focused tests are adapted from WPT `FileAPI/blob`, WebKit's Blob stream +chunk/crash regressions, and Firefox's large Blob `pipeTo` regression. diff --git a/Polyfills/Blob/Source/Blob.cpp b/Polyfills/Blob/Source/Blob.cpp index 789c346c..2c7ccc0a 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -2,8 +2,63 @@ #include #include +#include +#include +#include + namespace Babylon::Polyfills::Internal { + struct Blob::Segment + { + std::shared_ptr> Bytes; + size_t Offset{}; + size_t Length{}; + }; + + struct Blob::Data + { + std::vector Segments; + size_t Size{}; + + void Append(const Segment& segment) + { + if (segment.Length == 0) + { + return; + } + + Segments.emplace_back(segment); + Size += segment.Length; + } + + void CopyTo(size_t position, std::byte* destination, size_t length) const + { + size_t segmentStart{}; + for (const auto& segment : Segments) + { + const auto segmentEnd = segmentStart + segment.Length; + if (position < segmentEnd && length > 0) + { + const auto localOffset = position > segmentStart ? position - segmentStart : 0; + const auto copyLength = std::min(segment.Length - localOffset, length); + std::memcpy(destination, segment.Bytes->data() + segment.Offset + localOffset, copyLength); + destination += copyLength; + position += copyLength; + length -= copyLength; + } + segmentStart = segmentEnd; + } + } + }; + + struct Blob::StreamState + { + std::shared_ptr BlobData; + size_t Position{}; + size_t SegmentIndex{}; + size_t SegmentOffset{}; + }; + void Blob::Initialize(Napi::Env env) { static constexpr auto JS_BLOB_CONSTRUCTOR_NAME = "Blob"; @@ -18,8 +73,14 @@ namespace Babylon::Polyfills::Internal InstanceMethod("text", &Blob::Text), InstanceMethod("arrayBuffer", &Blob::ArrayBuffer), InstanceMethod("bytes", &Blob::Bytes), + InstanceMethod("slice", &Blob::Slice), + InstanceMethod("stream", &Blob::Stream), }); + auto descriptor = Napi::Object::New(env); + descriptor.Set("configurable", true); + descriptor.Set("value", JS_BLOB_CONSTRUCTOR_NAME); + env.Global().Get("Object").As().Get("defineProperty").As().Call(env.Global().Get("Object"), {func.Get("prototype"), Napi::Symbol::WellKnown(env, "toStringTag"), descriptor}); env.Global().Set(JS_BLOB_CONSTRUCTOR_NAME, func); } } @@ -27,36 +88,122 @@ namespace Babylon::Polyfills::Internal Blob::Blob(const Napi::CallbackInfo& info) : Napi::ObjectWrap(info) { - if (info.Length() > 0) + auto env = Env(); + auto data = std::make_shared(); + std::vector stringSegmentIndices; + bool convertLineEndings{}; + + if (info.Length() > 0 && !info[0].IsUndefined()) { - const auto blobParts = info[0].As(); + if (!info[0].IsObject() && !info[0].IsFunction()) + { + throw Napi::TypeError::New(env, "Blob parts must be an iterable object."); + } + + const auto blobParts = info[0].As(); + if (blobParts.IsArray()) + { + data->Segments.reserve(blobParts.As().Length()); + } - if (blobParts.Length() > 0) + const auto reflect = env.Global().Get("Reflect").As(); + const auto iteratorMethod = reflect.Get("get").As().Call( + reflect, + {blobParts, Napi::Symbol::WellKnown(env, "iterator")}); + if (!iteratorMethod.IsFunction()) { - const auto firstPart = blobParts.Get(uint32_t{0}); - ProcessBlobPart(firstPart); + throw Napi::TypeError::New(env, "Blob parts must be iterable."); + } + + const auto iterator = iteratorMethod.As().Call(blobParts, {}).As(); + const auto next = iterator.Get("next").As(); + try + { + while (true) + { + const auto result = next.Call(iterator, {}).As(); + if (result.Get("done").ToBoolean().Value()) + { + break; + } - if (blobParts.Length() > 1) + if (AppendBlobPart(*data, result.Get("value"))) + { + stringSegmentIndices.emplace_back(data->Segments.size() - 1); + } + } + } + catch (...) + { + try + { + const auto returnMethod = iterator.Get("return"); + if (returnMethod.IsFunction()) + { + returnMethod.As().Call(iterator, {}); + } + } + catch (...) { - throw Napi::Error::New(Env(), "Using multiple BlobParts in Blob constructor is not implemented."); + // Preserve the exception that interrupted part conversion. } + throw; } } - if (info.Length() > 1) + if (info.Length() > 1 && !info[1].IsUndefined() && !info[1].IsNull()) { + if (!info[1].IsObject() && !info[1].IsFunction()) + { + throw Napi::TypeError::New(env, "Blob options must be an object."); + } + const auto options = info[1].As(); + const auto endings = options.Get("endings"); + if (!endings.IsUndefined()) + { + const auto value = endings.ToString().Utf8Value(); + if (value != "transparent" && value != "native") + { + throw Napi::TypeError::New(env, "Blob endings must be 'transparent' or 'native'."); + } + convertLineEndings = value == "native"; + } + + const auto type = options.Get("type"); + if (!type.IsUndefined()) + { + m_type = NormalizeType(type.ToString().Utf8Value()); + } + } - if (options.Has("type")) + if (convertLineEndings) + { + for (const auto segmentIndex : stringSegmentIndices) { - m_type = options.Get("type").As().Utf8Value(); + auto& segment = data->Segments[segmentIndex]; + auto value = std::string{ + reinterpret_cast(segment.Bytes->data() + segment.Offset), + segment.Length}; + value = NormalizeLineEndings(std::move(value)); + + auto bytes = std::make_shared>(value.size()); + if (!value.empty()) + { + std::memcpy(bytes->data(), value.data(), value.size()); + } + data->Size -= segment.Length; + segment = {std::move(bytes), 0, value.size()}; + data->Size += segment.Length; } } + + m_data = std::move(data); } Napi::Value Blob::GetSize(const Napi::CallbackInfo&) { - return Napi::Value::From(Env(), m_data.size()); + return Napi::Value::From(Env(), m_data->Size); } Napi::Value Blob::GetType(const Napi::CallbackInfo&) @@ -67,8 +214,11 @@ namespace Babylon::Polyfills::Internal Napi::Value Blob::Text(const Napi::CallbackInfo&) { // NOTE: This will not check for UTF-8 validity - const auto begin = reinterpret_cast(m_data.data()); - std::string text(begin, m_data.size()); + std::string text(m_data->Size, '\0'); + if (!text.empty()) + { + m_data->CopyTo(0, reinterpret_cast(text.data()), text.size()); + } const auto deferred = Napi::Promise::Deferred::New(Env()); deferred.Resolve(Napi::String::New(Env(), text)); @@ -77,59 +227,311 @@ namespace Babylon::Polyfills::Internal Napi::Value Blob::ArrayBuffer(const Napi::CallbackInfo&) { - const auto arrayBuffer = Napi::ArrayBuffer::New(Env(), m_data.size()); - if (m_data.data()) - { - std::memcpy(arrayBuffer.Data(), m_data.data(), m_data.size()); - } - const auto deferred = Napi::Promise::Deferred::New(Env()); - deferred.Resolve(arrayBuffer); + deferred.Resolve(CreateArrayBuffer()); return deferred.Promise(); } Napi::Value Blob::Bytes(const Napi::CallbackInfo&) { - const auto arrayBuffer = Napi::ArrayBuffer::New(Env(), m_data.size()); - if (m_data.data()) - { - std::memcpy(arrayBuffer.Data(), m_data.data(), m_data.size()); - } - const auto uint8Array = Napi::Uint8Array::New(Env(), m_data.size(), arrayBuffer, 0); + const auto arrayBuffer = CreateArrayBuffer(); + const auto uint8Array = Napi::Uint8Array::New(Env(), m_data->Size, arrayBuffer, 0); const auto deferred = Napi::Promise::Deferred::New(Env()); deferred.Resolve(uint8Array); return deferred.Promise(); } - void Blob::ProcessBlobPart(const Napi::Value& blobPart) + Napi::Value Blob::Slice(const Napi::CallbackInfo& info) + { + const auto start = NormalizeSliceIndex( + info.Length() > 0 && !info[0].IsUndefined() ? info[0].ToNumber().DoubleValue() : 0, + m_data->Size); + const auto end = NormalizeSliceIndex( + info.Length() > 1 && !info[1].IsUndefined() ? info[1].ToNumber().DoubleValue() : static_cast(m_data->Size), + m_data->Size); + const auto sliceEnd = std::max(start, end); + + auto sliceData = std::make_shared(); + size_t segmentStart{}; + for (const auto& segment : m_data->Segments) + { + const auto segmentEnd = segmentStart + segment.Length; + const auto overlapStart = std::max(start, segmentStart); + const auto overlapEnd = std::min(sliceEnd, segmentEnd); + if (overlapStart < overlapEnd) + { + sliceData->Append({segment.Bytes, segment.Offset + overlapStart - segmentStart, overlapEnd - overlapStart}); + } + segmentStart = segmentEnd; + if (segmentStart >= sliceEnd) + { + break; + } + } + + std::string contentType; + if (info.Length() > 2 && !info[2].IsUndefined()) + { + contentType = NormalizeType(info[2].ToString().Utf8Value()); + } + + const auto blobConstructor = Env().Global().Get("Blob").As(); + const auto result = blobConstructor.New({Napi::Array::New(Env())}); + auto resultBlob = Napi::ObjectWrap::Unwrap(result); + resultBlob->m_data = std::move(sliceData); + resultBlob->m_type = std::move(contentType); + return result; + } + + Napi::Value Blob::Stream(const Napi::CallbackInfo& info) { + auto env = info.Env(); + const auto readableStreamValue = env.Global().Get("ReadableStream"); + if (!readableStreamValue.IsFunction()) + { + throw Napi::TypeError::New(env, "Blob.stream() requires ReadableStream to be installed."); + } + + auto state = std::make_shared(); + state->BlobData = m_data; + + auto source = Napi::Object::New(env); + source.Set("type", "bytes"); + source.Set("pull", Napi::Function::New(env, [state](const Napi::CallbackInfo& callbackInfo) -> Napi::Value { + auto callbackEnv = callbackInfo.Env(); + auto controller = callbackInfo[0].As(); + if (!state->BlobData || state->Position >= state->BlobData->Size) + { + controller.Get("close").As().Call(controller, {}); + state->BlobData.reset(); + return callbackEnv.Undefined(); + } + + constexpr size_t CHUNK_SIZE = 64 * 1024; + const auto outputLength = std::min(CHUNK_SIZE, state->BlobData->Size - state->Position); + auto outputBuffer = Napi::ArrayBuffer::New(callbackEnv, outputLength); + auto destination = static_cast(outputBuffer.Data()); + auto segmentIndex = state->SegmentIndex; + auto segmentOffset = state->SegmentOffset; + size_t remaining = outputLength; + + while (remaining > 0) + { + const auto& segment = state->BlobData->Segments[segmentIndex]; + const auto copyLength = std::min(segment.Length - segmentOffset, remaining); + std::memcpy(destination, segment.Bytes->data() + segment.Offset + segmentOffset, copyLength); + destination += copyLength; + remaining -= copyLength; + segmentOffset += copyLength; + if (segmentOffset == segment.Length) + { + ++segmentIndex; + segmentOffset = 0; + } + } + + auto output = Napi::Uint8Array::New(callbackEnv, outputLength, outputBuffer, 0); + controller.Get("enqueue").As().Call(controller, {output}); + state->Position += outputLength; + state->SegmentIndex = segmentIndex; + state->SegmentOffset = segmentOffset; + + if (state->Position == state->BlobData->Size) + { + controller.Get("close").As().Call(controller, {}); + state->BlobData.reset(); + } + + return callbackEnv.Undefined(); + })); + source.Set("cancel", Napi::Function::New(env, [state](const Napi::CallbackInfo& callbackInfo) -> Napi::Value { + state->BlobData.reset(); + return callbackInfo.Env().Undefined(); + })); + + return readableStreamValue.As().New({source}); + } + + bool Blob::AppendBlobPart(Data& data, const Napi::Value& blobPart) + { + const std::byte* source{}; + size_t length{}; + bool isBufferSource{}; + if (blobPart.IsArrayBuffer()) { const auto buffer = blobPart.As(); - const auto begin = static_cast(buffer.Data()); - m_data.assign(begin, begin + buffer.ByteLength()); + source = static_cast(buffer.Data()); + length = buffer.ByteLength(); + isBufferSource = true; } - else if (blobPart.IsTypedArray() || blobPart.IsDataView()) + else if (blobPart.IsTypedArray()) { const auto array = blobPart.As(); const auto buffer = array.ArrayBuffer(); - const auto begin = static_cast(buffer.Data()) + array.ByteOffset(); - m_data.assign(begin, begin + array.ByteLength()); + const auto bufferData = static_cast(buffer.Data()); + source = bufferData == nullptr ? nullptr : bufferData + array.ByteOffset(); + length = array.ByteLength(); + isBufferSource = true; + } + else if (blobPart.IsDataView()) + { + const auto view = blobPart.As(); + const auto buffer = view.ArrayBuffer(); + const auto bufferData = static_cast(buffer.Data()); + source = bufferData == nullptr ? nullptr : bufferData + view.ByteOffset(); + length = view.ByteLength(); + isBufferSource = true; + } + else if (blobPart.IsObject()) + { + const auto object = blobPart.As(); + const auto blobConstructor = Env().Global().Get("Blob").As(); + if (object.InstanceOf(blobConstructor)) + { + auto nativeBlob = object; + if (!object.Get("constructor").StrictEquals(blobConstructor)) + { + nativeBlob = object.Get("slice").As().Call(object, {Napi::Number::New(Env(), 0), object.Get("size")}).As(); + } + + const auto blob = Napi::ObjectWrap::Unwrap(nativeBlob); + data.Segments.reserve(data.Segments.size() + blob->m_data->Segments.size()); + for (const auto& segment : blob->m_data->Segments) + { + data.Append(segment); + } + return false; + } + } + + if (isBufferSource) + { + auto bytes = std::make_shared>(length); + if (length > 0) + { + std::memcpy(bytes->data(), source, length); + } + data.Append({std::move(bytes), 0, length}); + return false; } - else if (blobPart.IsString()) + + auto value = blobPart.ToString().Utf8Value(); + auto bytes = std::make_shared>(value.size()); + if (!value.empty()) { - const auto str = blobPart.As().Utf8Value(); - const auto begin = reinterpret_cast(str.data()); - m_data.assign(begin, begin + str.length()); + std::memcpy(bytes->data(), value.data(), value.size()); } - else + data.Append({std::move(bytes), 0, value.size()}); + return !value.empty(); + } + + Napi::ArrayBuffer Blob::CreateArrayBuffer() const + { + auto arrayBuffer = Napi::ArrayBuffer::New(Env(), m_data->Size); + if (m_data->Size > 0) { - // Assume it's another Blob object - const auto obj = blobPart.As(); - const auto blobObj = Napi::ObjectWrap::Unwrap(obj); - m_data.assign(blobObj->m_data.begin(), blobObj->m_data.end()); + m_data->CopyTo(0, static_cast(arrayBuffer.Data()), m_data->Size); } + return arrayBuffer; + } + + std::string Blob::NormalizeType(std::string type) + { + for (auto& character : type) + { + const auto value = static_cast(character); + if (value < 0x20 || value > 0x7E) + { + return {}; + } + if (character >= 'A' && character <= 'Z') + { + character = static_cast(character - 'A' + 'a'); + } + } + return type; + } + + std::string Blob::NormalizeLineEndings(std::string value) + { +#ifdef _WIN32 + static constexpr auto nativeNewline = "\r\n"; +#else + static constexpr auto nativeNewline = "\n"; +#endif + size_t outputLength{}; + for (size_t index{}; index < value.size(); ++index) + { + if (value[index] == '\r') + { + if (index + 1 < value.size() && value[index + 1] == '\n') + { + ++index; + } + outputLength += std::char_traits::length(nativeNewline); + } + else if (value[index] == '\n') + { + outputLength += std::char_traits::length(nativeNewline); + } + else + { + ++outputLength; + } + } + + std::string result; + result.reserve(outputLength); + for (size_t index{}; index < value.size(); ++index) + { + if (value[index] == '\r') + { + if (index + 1 < value.size() && value[index + 1] == '\n') + { + ++index; + } + result.append(nativeNewline); + } + else if (value[index] == '\n') + { + result.append(nativeNewline); + } + else + { + result.push_back(value[index]); + } + } + return result; + } + + size_t Blob::NormalizeSliceIndex(double value, size_t size) + { + if (std::isnan(value)) + { + return 0; + } + + const auto magnitude = std::abs(value); + const auto lower = std::floor(magnitude); + const auto fraction = magnitude - lower; + auto rounded = lower; + if (fraction > 0.5 || (fraction == 0.5 && std::fmod(lower, 2.0) != 0.0)) + { + rounded += 1.0; + } + + if (value < 0) + { + return !std::isfinite(rounded) || rounded >= static_cast(size) + ? 0 + : size - static_cast(rounded); + } + + return !std::isfinite(rounded) || rounded >= static_cast(size) + ? size + : static_cast(rounded); } } diff --git a/Polyfills/Blob/Source/Blob.h b/Polyfills/Blob/Source/Blob.h index df04e834..11979e1c 100644 --- a/Polyfills/Blob/Source/Blob.h +++ b/Polyfills/Blob/Source/Blob.h @@ -2,8 +2,9 @@ #include -#include +#include #include +#include namespace Babylon::Polyfills::Internal { @@ -20,10 +21,21 @@ namespace Babylon::Polyfills::Internal Napi::Value Text(const Napi::CallbackInfo& info); Napi::Value ArrayBuffer(const Napi::CallbackInfo& info); Napi::Value Bytes(const Napi::CallbackInfo& info); + Napi::Value Slice(const Napi::CallbackInfo& info); + Napi::Value Stream(const Napi::CallbackInfo& info); + + struct Segment; + struct Data; + struct StreamState; + + bool AppendBlobPart(Data& data, const Napi::Value& blobPart); + Napi::ArrayBuffer CreateArrayBuffer() const; - void ProcessBlobPart(const Napi::Value& blobPart); + static std::string NormalizeType(std::string type); + static std::string NormalizeLineEndings(std::string value); + static size_t NormalizeSliceIndex(double value, size_t size); - std::vector m_data; + std::shared_ptr m_data; std::string m_type; }; -} \ No newline at end of file +} diff --git a/Polyfills/File/Source/File.cpp b/Polyfills/File/Source/File.cpp index 5cc3de02..2bc19e62 100644 --- a/Polyfills/File/Source/File.cpp +++ b/Polyfills/File/Source/File.cpp @@ -50,6 +50,8 @@ namespace Babylon::Polyfills::Internal InstanceMethod("arrayBuffer", &File::ArrayBuffer), InstanceMethod("text", &File::Text), InstanceMethod("bytes", &File::Bytes), + InstanceMethod("slice", &File::Slice), + InstanceMethod("stream", &File::Stream), }); global.Set(JS_FILE_CONSTRUCTOR_NAME, func); @@ -59,12 +61,16 @@ namespace Babylon::Polyfills::Internal // a Blob subtype; BJS core (fileTools, Offline/database, // abstractEngine, thinNativeEngine) branches on `instanceof Blob` // and needs File inputs to satisfy that check. - auto setPrototypeOf = env.Global().Get("Object").As() - .Get("setPrototypeOf").As(); + auto setPrototypeOf = env.Global().Get("Object").As().Get("setPrototypeOf").As(); setPrototypeOf.Call({ func.Get("prototype"), blob.As().Get("prototype"), }); + + auto descriptor = Napi::Object::New(env); + descriptor.Set("configurable", true); + descriptor.Set("value", JS_FILE_CONSTRUCTOR_NAME); + global.Get("Object").As().Get("defineProperty").As().Call(global.Get("Object"), {func.Get("prototype"), Napi::Symbol::WellKnown(env, "toStringTag"), descriptor}); } File::File(const Napi::CallbackInfo& info) @@ -79,7 +85,7 @@ namespace Babylon::Polyfills::Internal { throw Napi::TypeError::New(env, "Failed to construct 'File': 2 arguments required, but only " + - std::to_string(info.Length()) + " present."); + std::to_string(info.Length()) + " present."); } Napi::Value parts = info[0]; @@ -118,21 +124,11 @@ namespace Babylon::Polyfills::Internal } } - Napi::Value partsArray; - if (parts.IsArray()) - { - partsArray = parts; - } - else - { - partsArray = Napi::Array::New(env, 0); - } - // Delegate byte-buffer construction to the native Blob polyfill so // we benefit from its existing BlobPart handling (ArrayBuffer, // typed array, string, Blob). auto blobCtor = env.Global().Get(JS_BLOB_CONSTRUCTOR_NAME).As(); - auto blobInstance = blobCtor.New({partsArray, blobOptions}); + auto blobInstance = blobCtor.New({parts, blobOptions}); m_blob = Napi::Persistent(blobInstance); } @@ -173,6 +169,29 @@ namespace Babylon::Polyfills::Internal auto blob = m_blob.Value(); return blob.Get("bytes").As().Call(blob, {}); } + + Napi::Value File::Slice(const Napi::CallbackInfo& info) + { + auto blob = m_blob.Value(); + const auto slice = blob.Get("slice").As(); + switch (info.Length()) + { + case 0: + return slice.Call(blob, {}); + case 1: + return slice.Call(blob, {info[0]}); + case 2: + return slice.Call(blob, {info[0], info[1]}); + default: + return slice.Call(blob, {info[0], info[1], info[2]}); + } + } + + Napi::Value File::Stream(const Napi::CallbackInfo&) + { + auto blob = m_blob.Value(); + return blob.Get("stream").As().Call(blob, {}); + } } namespace Babylon::Polyfills::File diff --git a/Polyfills/File/Source/File.h b/Polyfills/File/Source/File.h index 9d73edb2..49473666 100644 --- a/Polyfills/File/Source/File.h +++ b/Polyfills/File/Source/File.h @@ -22,6 +22,8 @@ namespace Babylon::Polyfills::Internal Napi::Value ArrayBuffer(const Napi::CallbackInfo& info); Napi::Value Text(const Napi::CallbackInfo& info); Napi::Value Bytes(const Napi::CallbackInfo& info); + Napi::Value Slice(const Napi::CallbackInfo& info); + Napi::Value Stream(const Napi::CallbackInfo& info); Napi::ObjectReference m_blob; std::string m_name; diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 973c6420..0df6e9de 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1528,8 +1528,24 @@ describe("Web Streams", function () { }); describe("Blob", function () { + this.timeout(10000); + let emptyBlobs: Blob[], helloBlobs: Blob[], stringBlob: Blob, typedArrayBlob: Blob, arrayBufferBlob: Blob, blobBlob: Blob; + async function readStream(stream: ReadableStream, mode?: "byob"): Promise { + const reader: any = stream.getReader(mode === undefined ? undefined : { mode }); + const bytes: number[] = []; + while (true) { + const result = mode === "byob" + ? await reader.read(new Uint8Array(64)) + : await reader.read(); + if (result.done) { + return bytes; + } + bytes.push(...Array.from(result.value as Uint8Array)); + } + } + before(function () { emptyBlobs = [new Blob([]), new Blob([])]; stringBlob = new Blob(["Hello"]); @@ -1572,6 +1588,120 @@ describe("Blob", function () { expect(modelGltfJson.type).to.equal("model/gltf+json"); }); + // Focused ports from WPT FileAPI/blob/Blob-constructor.any.js. + it("accepts iterable parts and preserves their order", async function () { + const parts = { + *[Symbol.iterator]() { + yield "foo"; + yield new Uint8Array([98, 97, 114]); + yield new Blob(["baz"]); + } + }; + const blob = new Blob(parts as any); + expect(blob.size).to.equal(9); + expect(await blob.text()).to.equal("foobarbaz"); + }); + + it("uses an Array's overridden iterator", async function () { + const parts = ["ignored"]; + parts[Symbol.iterator] = function* () { + yield "custom"; + }; + + expect(await new Blob(parts).text()).to.equal("custom"); + }); + + it("closes an iterator when BlobPart conversion throws", function () { + let closed = false; + const badPart = { + toString() { + throw new Error("part conversion failed"); + } + }; + const parts = (function* () { + try { + yield badPart; + } finally { + closed = true; + } + })(); + + // QuickJS currently wraps an exception rethrown through a native + // constructor as its generic JS error type, so assert the observable + // iterator-close behavior separately from the adapter's error text. + expect(() => new Blob(parts as any)).to.throw(); + expect(closed).to.equal(true); + }); + + it("observes BlobPart array mutations during iteration", async function () { + const parts: any[] = [ + { + toString() { + parts.pop(); + return "PASS"; + } + }, + { + toString() { + throw new Error("removed part was converted"); + } + } + ]; + + expect(await new Blob(parts).text()).to.equal("PASS"); + }); + + it("converts parts before reading options in WebIDL order", function () { + const accesses: string[] = []; + const part = { + toString() { + accesses.push("part"); + return "data"; + } + }; + new Blob([part], { + get type() { + accesses.push("type"); + return "TEXT/PLAIN"; + }, + get endings() { + accesses.push("endings"); + return "transparent" as EndingType; + } + }); + + expect(accesses).to.deep.equal(["part", "endings", "type"]); + }); + + it("validates the endings enum and options dictionary", function () { + expect(() => new Blob([], { endings: "NATIVE" as EndingType })).to.throw(); + expect(() => new Blob([], { endings: "invalid" as EndingType })).to.throw(); + for (const value of [123, true, "abc"]) { + expect(() => new Blob([], value as any)).to.throw(); + } + expect(() => new Blob([], null as any)).not.to.throw(); + expect(() => new Blob([], undefined)).not.to.throw(); + }); + + it("exposes browser-compatible class tags", function () { + expect(String(new Blob())).to.equal("[object Blob]"); + expect(String(new File([], "empty.txt"))).to.equal("[object File]"); + }); + + it("rejects primitive parts containers", function () { + for (const value of [null, true, 7, "not a sequence"]) { + // Some Node-API adapters currently wrap a Napi::TypeError thrown + // by a constructor as their generic JS error type. + expect(() => new Blob(value as any)).to.throw(); + } + }); + + it("normalizes valid MIME types and clears invalid types", function () { + expect(new Blob([], { type: "TEXT/PLAIN" }).type).to.equal("text/plain"); + expect(new Blob([], { type: "te\x09xt/plain" }).type).to.equal(""); + expect(new Blob([], { type: "text/\x7fplain" }).type).to.equal(""); + }); + // -------------------------------- Blob.text() -------------------------------- it("returns empty string for empty blobs", async function () { for (const blob of emptyBlobs) { @@ -1593,6 +1723,11 @@ describe("Blob", function () { expect(text).to.equal("你好, 世界"); }); + it("replaces invalid UTF-8 bytes", async function () { + const invalid = new Uint8Array([192, 193, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]); + expect(await new Blob([invalid]).text()).to.equal("\ufffd".repeat(invalid.length)); + }); + it("preserves line endings like default transparent mode", async function () { const lineEndingsBlob = new Blob(["Hello\nWorld"]); const text = await lineEndingsBlob.text(); @@ -1639,6 +1774,82 @@ describe("Blob", function () { } }); + + // Focused ports from WPT FileAPI/blob/Blob-slice.any.js. + it("slices across part boundaries and applies clamp rounding", async function () { + const blob = new Blob(["foo", new Blob(["bar"]), "baz"]); + expect(await blob.slice(2, 7).text()).to.equal("obarb"); + expect(await new Blob(["abcd"]).slice(1.5).text()).to.equal("cd"); + expect(await new Blob(["abcd"]).slice(2.5).text()).to.equal("cd"); + expect(await blob.slice(-3, undefined, "TEXT/PLAIN").text()).to.equal("baz"); + expect(blob.slice(-3, undefined, "TEXT/PLAIN").type).to.equal("text/plain"); + }); + + // Focused ports from WPT FileAPI/blob/Blob-stream.any.js. + it("streams binary data through default and BYOB readers", async function () { + const input = [8, 241, 48, 123, 151]; + const blob = new Blob([new Uint8Array(input)]); + expect(await readStream(blob.stream())).to.deep.equal(input); + expect(await readStream(blob.stream(), "byob")).to.deep.equal(input); + expect(await readStream(new Blob().stream())).to.deep.equal([]); + }); + + it("keeps independent stream cursors after the Blob reference is dropped", async function () { + let blob: Blob | null = new Blob(["PASS"]); + const first = blob.stream(); + const second = blob.stream(); + blob = null; + expect(await readStream(first)).to.deep.equal([80, 65, 83, 83]); + expect(await readStream(second)).to.deep.equal([80, 65, 83, 83]); + }); + + // Adapted from WebKit's fast/files/blob-stream-chunks.html. + it("chunks a large Blob and supports cancellation without retaining work", async function () { + const blob = new Blob([new Uint8Array(5 * 1024 * 1024)]); + const reader = blob.stream().getReader(); + const first = await reader.read(); + expect(first.done).to.equal(false); + expect(first.value!.byteLength).to.be.at.most(64 * 1024); + await reader.cancel(); + await reader.closed; + }); + + // Adapted from WebKit's blob-stream crash regression and exercises + // teardown of the C++ pull-state closures under repeated construction. + it("constructs and cancels many empty streams without crashing", async function () { + for (let index = 0; index < 1000; ++index) { + await new Blob().stream().cancel(); + } + }); + + // Scaled port of Firefox's dom/streams/test/xpcshell/large-pipeto.js. + it("pipes nested shared Blob parts without corrupting chunk boundaries", async function () { + const pattern = new Uint8Array(256 * 1024); + for (let index = 0; index < pattern.length; ++index) { + pattern[index] = index % 256; + } + const pair = new Blob([pattern, pattern]); + const nested = new Blob([pair, pair, pair, pair, pair, pair]); + let position = 0; + + await nested.stream().pipeTo(new WritableStream({ + write(chunk: Uint8Array) { + for (const value of chunk) { + const expected = position % pattern.length % 256; + if (value !== expected) { + throw new Error(`Blob stream byte ${position}: expected ${expected}, received ${value}`); + } + ++position; + } + } + })); + expect(position).to.equal(pattern.length * 12); + }); + + it("uses a File's Blob bytes when composing parts", async function () { + const file = new File(["a", "b"], "letters.txt"); + expect(await new Blob(["<", file, ">"]).text()).to.equal(""); + }); }); describe("napi class prototype isolation (#172)", function () { @@ -1960,6 +2171,22 @@ describe("File", function () { expect(text).to.equal("你好, 世界"); }); + // Adapted from WebKit's fast/files/blob-stream-crash-2.html. + it("streams and slices multiple File parts through the Blob API", async function () { + const file = new File(["a", new Blob(), "b", new Blob(), "c", new Blob(), "d"], "letters.txt"); + const reader = file.stream().getReader(); + const bytes: number[] = []; + while (true) { + const result = await reader.read(); + if (result.done) { + break; + } + bytes.push(...Array.from(result.value as Uint8Array)); + } + expect(bytes).to.deep.equal([97, 98, 99, 100]); + expect(await file.slice(1, 3).text()).to.equal("bc"); + }); + // -------------------------------- Blob inheritance -------------------------------- it("is an instance of Blob (prototype chain wired up)", function () { // BJS core (fileTools, Offline/database, abstractEngine, From 60b9101a6ed8d715e7899db18af5b99a9df23463 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Tue, 21 Jul 2026 22:44:34 -0700 Subject: [PATCH 03/10] Add browser-compatible Headers and Response Replace the ad hoc buffered fetch response object with standard-shaped Headers and Response classes backed by ReadableStream bodies. Preserve host constructors, normalize and validate header values, enforce single-use bodies, and expose native responses with one transport-to-JavaScript copy. Reuse normalized header views between mutations, compact header storage in place, retain stream chunks only until consumption, and allocate a contiguous body result at most once when required. Add focused WPT, WebKit, Firefox, and Chromium regression coverage plus JavaScriptCore/QuickJS and initialization tests. --- Polyfills/Fetch/CMakeLists.txt | 17 +- Polyfills/Fetch/Readme.md | 29 +- Polyfills/Fetch/Source/Fetch.cpp | 244 +++------ Polyfills/Fetch/Source/FetchPolyfill.js | 604 +++++++++++++++++++++++ Polyfills/Fetch/Source/FetchScripts.h.in | 8 + Tests/UnitTests/Scripts/tests.ts | 242 +++++++++ Tests/UnitTests/Shared/Shared.cpp | 31 +- 7 files changed, 973 insertions(+), 202 deletions(-) create mode 100644 Polyfills/Fetch/Source/FetchPolyfill.js create mode 100644 Polyfills/Fetch/Source/FetchScripts.h.in diff --git a/Polyfills/Fetch/CMakeLists.txt b/Polyfills/Fetch/CMakeLists.txt index 62b46175..24346db0 100644 --- a/Polyfills/Fetch/CMakeLists.txt +++ b/Polyfills/Fetch/CMakeLists.txt @@ -1,12 +1,25 @@ +set(FETCH_POLYFILL_FILE "${CMAKE_CURRENT_SOURCE_DIR}/Source/FetchPolyfill.js") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${FETCH_POLYFILL_FILE}") +file(READ "${FETCH_POLYFILL_FILE}" FETCH_POLYFILL_SOURCE) +configure_file( + "Source/FetchScripts.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/Generated/FetchScripts.h" + @ONLY) + set(SOURCES "Include/Babylon/Polyfills/Fetch.h" + "Readme.md" "Source/Fetch.h" - "Source/Fetch.cpp") + "Source/Fetch.cpp" + "Source/FetchPolyfill.js" + "Source/FetchScripts.h.in") add_library(Fetch ${SOURCES}) warnings_as_errors(Fetch) -target_include_directories(Fetch PUBLIC "Include") +target_include_directories(Fetch + PUBLIC "Include" + PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/Generated") target_link_libraries(Fetch PUBLIC JsRuntime diff --git a/Polyfills/Fetch/Readme.md b/Polyfills/Fetch/Readme.md index 00c41cfc..b3d21a4e 100644 --- a/Polyfills/Fetch/Readme.md +++ b/Polyfills/Fetch/Readme.md @@ -1,5 +1,7 @@ # Fetch -Minimal implementation of the [WHATWG `fetch()`](https://fetch.spec.whatwg.org/) API. Like `XMLHttpRequest`, it is implemented on top of the platform-specific transports in the `UrlLib` dependency, so network behavior (libcurl / WinHTTP / etc.) is identical between the two polyfills. +Implementation of the [WHATWG `fetch()`](https://fetch.spec.whatwg.org/) API. +Like `XMLHttpRequest`, it uses the platform-specific transport from `UrlLib`, so +network behavior is shared between the two polyfills. ```js const response = await fetch("https://example.com/data.json"); @@ -8,16 +10,24 @@ if (response.ok) { } ``` -## Response -`fetch()` returns a `Promise` that resolves to a `Response`-like object exposing: -* `ok`, `status`, `statusText`, `url`, `redirected`, `type`, `bodyUsed` -* `headers` with `get(name)`, `has(name)`, and `forEach(callback)` (header names are matched case-insensitively) -* `text()`, `arrayBuffer()`, `json()`, `blob()` (each returns a `Promise`) -* `clone()` +## Headers and Response +The module installs `Headers` and `Response` when the host does not already +provide them. Response bodies use a `ReadableStream` and follow the browser's +single-consumption behavior through `bodyUsed`. The supported body readers are +`arrayBuffer()`, `blob()`, `bytes()`, `json()`, and `text()`. -The response body is fully buffered before the promise resolves. The body accessors may therefore be called more than once (`bodyUsed` is always reported as `false`), which is a deliberate, lenient deviation from the spec's single-use semantics. +Initialize the Streams, Blob, TextEncoder, TextDecoder, and URL polyfills before +using body-bearing responses on engines that do not provide those globals. -`blob()` requires the `Blob` polyfill to be initialized; otherwise the returned promise rejects. +A completed native response is copied once into JavaScript-owned memory and +exposed as a byte stream. Body readers retain chunk references while consuming +a stream and allocate a contiguous result at most once when that result requires +one. Repeated header iteration reuses a mutation-versioned normalized view, and +header deletion and replacement compact the field list in place. + +The focused conformance tests are adapted from WPT `fetch/api/headers` and +`fetch/api/response`, plus WebKit, Firefox, and Chromium response-body +regressions. ## Local files Like `XMLHttpRequest`, `fetch()` supports loading local resources: @@ -60,4 +70,3 @@ The rejection's `stack` is captured synchronously at the `fetch()` call site (be is handed to a worker thread), so crash reports can attribute the failing call rather than an empty scheduler tick. (Engines that only materialize `.stack` when an error is thrown may omit the frames.) - diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp index 7a500df7..73dde50f 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -1,4 +1,5 @@ #include "Fetch.h" +#include "FetchScripts.h" #include #include @@ -14,22 +15,11 @@ #include #include #include -#include namespace Babylon::Polyfills::Internal { namespace { - // Buffered response payload shared between the Response object and any clones it produces. - struct ResponseData - { - int statusCode{}; - std::string statusText; - std::string url; - std::vector> headers; - std::vector body; - }; - // Shared state for honoring an AbortSignal passed via init.signal. Co-owned by the "abort" // listener (which sets the flag, captures the reason, and cancels the transport) and the // completion continuation (which reports the AbortError and tears the listener down). @@ -157,18 +147,6 @@ namespace Babylon::Polyfills::Internal throw std::runtime_error{"Unsupported fetch method: " + method + " (only GET and POST are supported)"}; } - std::optional FindHeader(const ResponseData& data, std::string_view name) - { - for (const auto& header : data.headers) - { - if (EqualsIgnoreCase(header.first, name)) - { - return header.second; - } - } - return std::nullopt; - } - void ApplyRequestHeaders(UrlLib::UrlRequest& request, const Napi::Value& headers) { if (headers.IsUndefined() || headers.IsNull()) @@ -176,169 +154,36 @@ namespace Babylon::Polyfills::Internal return; } - Napi::Env env = headers.Env(); - - // Array of [name, value] pairs. - if (headers.IsArray()) + const auto env = headers.Env(); + const auto headersConstructor = env.Global().Get("Headers"); + if (headersConstructor.IsUndefined() || headersConstructor.IsNull()) { - const auto array = headers.As(); - for (uint32_t i = 0; i < array.Length(); ++i) - { - const auto pair = array.Get(i); - if (pair.IsArray()) - { - const auto entry = pair.As(); - request.SetRequestHeader(entry.Get(0u).ToString().Utf8Value(), entry.Get(1u).ToString().Utf8Value()); - } - } - return; + throw Napi::TypeError::New(env, "fetch requires Headers to be installed."); } - if (headers.IsObject()) - { - const auto object = headers.As(); - - // Headers / Map instances expose forEach((value, key) => ...). - const auto forEach = object.Get("forEach"); - if (forEach.IsFunction()) + auto normalizedHeaders = headersConstructor.As().New({headers}); + const auto callback = Napi::Function::New(env, [&request](const Napi::CallbackInfo& info) { + if (info.Length() >= 2) { - const auto callback = Napi::Function::New(env, [&request](const Napi::CallbackInfo& info) { - if (info.Length() >= 2) - { - request.SetRequestHeader(info[1].ToString().Utf8Value(), info[0].ToString().Utf8Value()); - } - }); - forEach.As().Call(object, {callback}); - return; - } - - // Plain object of name/value properties. - const auto names = object.GetPropertyNames(); - for (uint32_t i = 0; i < names.Length(); ++i) - { - const auto key = names.Get(i); - request.SetRequestHeader(key.ToString().Utf8Value(), object.Get(key).ToString().Utf8Value()); - } - } - } - - Napi::Object BuildHeaders(Napi::Env env, const std::shared_ptr& data) - { - Napi::Object headers = Napi::Object::New(env); - - headers.Set("get", Napi::Function::New(env, [data](const Napi::CallbackInfo& info) -> Napi::Value { - Napi::Env env = info.Env(); - const auto value = FindHeader(*data, info[0].ToString().Utf8Value()); - return value ? Napi::Value{Napi::String::New(env, *value)} : Napi::Value{env.Null()}; - }, "get")); - - headers.Set("has", Napi::Function::New(env, [data](const Napi::CallbackInfo& info) -> Napi::Value { - return Napi::Boolean::New(info.Env(), FindHeader(*data, info[0].ToString().Utf8Value()).has_value()); - }, "has")); - - headers.Set("forEach", Napi::Function::New(env, [data](const Napi::CallbackInfo& info) -> Napi::Value { - Napi::Env env = info.Env(); - const auto callback = info[0].As(); - const auto thisArg = info.Length() > 1 ? info[1] : env.Undefined(); - for (const auto& header : data->headers) - { - callback.Call(thisArg, {Napi::String::New(env, header.second), Napi::String::New(env, header.first)}); + request.SetRequestHeader(info[1].ToString().Utf8Value(), info[0].ToString().Utf8Value()); } - return env.Undefined(); - }, "forEach")); - - return headers; + }); + normalizedHeaders.Get("forEach").As().Call(normalizedHeaders, {callback}); } - Napi::Object BuildResponse(Napi::Env env, const std::shared_ptr& data) + void InitializeFetchClasses(Napi::Env env) { - Napi::Object response = Napi::Object::New(env); - - const bool ok = data->statusCode >= 200 && data->statusCode < 300; - response.Set("ok", Napi::Boolean::New(env, ok)); - response.Set("status", Napi::Number::New(env, data->statusCode)); - response.Set("statusText", Napi::String::New(env, data->statusText)); - response.Set("url", Napi::String::New(env, data->url)); - response.Set("redirected", Napi::Boolean::New(env, false)); - response.Set("type", Napi::String::New(env, "basic")); - response.Set("bodyUsed", Napi::Boolean::New(env, false)); - response.Set("headers", BuildHeaders(env, data)); - - response.Set("text", Napi::Function::New(env, [data](const Napi::CallbackInfo& info) -> Napi::Value { - Napi::Env env = info.Env(); - const auto deferred = Napi::Promise::Deferred::New(env); - std::string text{reinterpret_cast(data->body.data()), data->body.size()}; - deferred.Resolve(Napi::String::New(env, text)); - return deferred.Promise(); - }, "text")); - - response.Set("arrayBuffer", Napi::Function::New(env, [data](const Napi::CallbackInfo& info) -> Napi::Value { - Napi::Env env = info.Env(); - const auto deferred = Napi::Promise::Deferred::New(env); - const auto arrayBuffer = Napi::ArrayBuffer::New(env, data->body.size()); - if (!data->body.empty()) - { - std::memcpy(arrayBuffer.Data(), data->body.data(), data->body.size()); - } - deferred.Resolve(arrayBuffer); - return deferred.Promise(); - }, "arrayBuffer")); - - response.Set("json", Napi::Function::New(env, [data](const Napi::CallbackInfo& info) -> Napi::Value { - Napi::Env env = info.Env(); - const auto deferred = Napi::Promise::Deferred::New(env); - std::string text{reinterpret_cast(data->body.data()), data->body.size()}; - const auto json = env.Global().Get("JSON").As(); - const auto parse = json.Get("parse").As(); - try - { - deferred.Resolve(parse.Call(json, {Napi::String::New(env, text)})); - } - catch (const Napi::Error& error) - { - deferred.Reject(error.Value()); - } - return deferred.Promise(); - }, "json")); - - response.Set("blob", Napi::Function::New(env, [data](const Napi::CallbackInfo& info) -> Napi::Value { - Napi::Env env = info.Env(); - const auto deferred = Napi::Promise::Deferred::New(env); - - // Use IsUndefined()/IsNull() rather than IsFunction() to detect the Blob - // polyfill: some JavaScriptCore/JSI builds classify constructor functions as - // typeof 'object', so napi_typeof reports napi_object and IsFunction() would - // incorrectly reject even when the Blob polyfill is installed. - const auto blobConstructor = env.Global().Get("Blob"); - if (blobConstructor.IsUndefined() || blobConstructor.IsNull()) - { - deferred.Reject(Napi::Error::New(env, "fetch: Blob is not available in this environment").Value()); - return deferred.Promise(); - } - - const auto arrayBuffer = Napi::ArrayBuffer::New(env, data->body.size()); - if (!data->body.empty()) - { - std::memcpy(arrayBuffer.Data(), data->body.data(), data->body.size()); - } - const auto bytes = Napi::Uint8Array::New(env, data->body.size(), arrayBuffer, 0); - - Napi::Array parts = Napi::Array::New(env, 1); - parts.Set(0u, bytes); - - Napi::Object options = Napi::Object::New(env); - const auto contentType = FindHeader(*data, "content-type"); - options.Set("type", Napi::String::New(env, contentType.value_or(""))); - - deferred.Resolve(blobConstructor.As().New({parts, options})); - return deferred.Promise(); - }, "blob")); - - response.Set("clone", Napi::Function::New(env, [data](const Napi::CallbackInfo& info) -> Napi::Value { - return BuildResponse(info.Env(), data); - }, "clone")); - - return response; + auto global = env.Global(); + const auto exports = Napi::Eval(env, FetchScripts::Polyfill, "jsruntimehost://fetch-polyfill.js").As(); + if (global.Get("Headers").IsUndefined()) + { + global.Set("Headers", exports.Get("Headers")); + } + if (global.Get("Response").IsUndefined()) + { + global.Set("Response", exports.Get("Response")); + } + JsRuntime::NativeObject::GetFromJavaScript(env).Set("createFetchResponse", exports.Get("createFetchResponse")); } } @@ -347,6 +192,7 @@ namespace Babylon::Polyfills::Internal void Initialize(Napi::Env env) { static constexpr auto JS_FETCH_NAME = "fetch"; + InitializeFetchClasses(env); auto fetchFunction = Napi::Function::New(env, [](const Napi::CallbackInfo& info) -> Napi::Value { Napi::Env env = info.Env(); @@ -494,18 +340,41 @@ namespace Babylon::Polyfills::Internal return; } - auto data = std::make_shared(); - data->statusCode = status; - data->statusText = std::string{request->StatusText()}; - data->url = std::string{request->ResponseUrl()}; + const auto responseBuffer = request->ResponseBuffer(); + auto arrayBuffer = Napi::ArrayBuffer::New(env, responseBuffer.size()); + if (!responseBuffer.empty()) + { + std::memcpy(arrayBuffer.Data(), responseBuffer.data(), responseBuffer.size()); + } + const auto bytes = Napi::Uint8Array::New(env, responseBuffer.size(), arrayBuffer, 0); + + auto responseHeaders = Napi::Array::New(env, request->GetAllResponseHeaders().size()); + uint32_t headerIndex{}; for (const auto& header : request->GetAllResponseHeaders()) { - data->headers.emplace_back(header.first, header.second); + auto pair = Napi::Array::New(env, 2); + pair.Set(uint32_t{0}, Napi::String::New(env, header.first)); + pair.Set(uint32_t{1}, Napi::String::New(env, header.second)); + responseHeaders.Set(headerIndex++, pair); } - const auto responseBuffer = request->ResponseBuffer(); - data->body.assign(responseBuffer.begin(), responseBuffer.end()); - deferred.Resolve(BuildResponse(env, data)); + auto init = Napi::Object::New(env); + init.Set("headers", responseHeaders); + init.Set("status", Napi::Number::New(env, status)); + const auto statusText = request->StatusText(); + init.Set("statusText", Napi::String::New(env, statusText.data(), statusText.size())); + + const std::string responseUrl{request->ResponseUrl()}; + auto metadata = Napi::Object::New(env); + metadata.Set("redirected", Napi::Boolean::New(env, !responseUrl.empty() && responseUrl != url)); + metadata.Set("type", Napi::String::New(env, "basic")); + metadata.Set("url", Napi::String::New(env, responseUrl.empty() ? url : responseUrl)); + + const auto nativeObject = JsRuntime::NativeObject::GetFromJavaScript(env); + const auto createResponse = nativeObject.Get("createFetchResponse").As(); + deferred.Resolve(createResponse.Call( + nativeObject, + {env.Global().Get("Response"), bytes, init, metadata})); }) .then(*scheduler, arcana::cancellation::none(), [deferred, env, scheduler](const arcana::expected& result) { @@ -525,8 +394,7 @@ namespace Babylon::Polyfills::Internal deferred.Reject(Napi::Error::New(env, std::current_exception()).Value()); } - return deferred.Promise(); - }, JS_FETCH_NAME); + return deferred.Promise(); }, JS_FETCH_NAME); if (env.Global().Get(JS_FETCH_NAME).IsUndefined()) { diff --git a/Polyfills/Fetch/Source/FetchPolyfill.js b/Polyfills/Fetch/Source/FetchPolyfill.js new file mode 100644 index 00000000..93f6811a --- /dev/null +++ b/Polyfills/Fetch/Source/FetchPolyfill.js @@ -0,0 +1,604 @@ +(function(global) { + "use strict"; + + var headerStates = new WeakMap(); + var responseStates = new WeakMap(); + var headerNamePattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + var nullBodyStatuses = [204, 205, 304]; + + function requireHeaderState(value) { + var state = headerStates.get(value); + if (!state) { + throw new TypeError("Illegal invocation"); + } + return state; + } + + function requireResponseState(value) { + var state = responseStates.get(value); + if (!state) { + throw new TypeError("Illegal invocation"); + } + return state; + } + + function toByteString(value, description) { + var string = String(value); + for (var index = 0; index < string.length; ++index) { + if (string.charCodeAt(index) > 255) { + throw new TypeError(description + " is not a valid ByteString"); + } + } + return string; + } + + function normalizeHeaderName(value) { + var name = toByteString(value, "Header name"); + if (!headerNamePattern.test(name)) { + throw new TypeError("Invalid header name"); + } + return name.toLowerCase(); + } + + function normalizeHeaderValue(value) { + var normalized = toByteString(value, "Header value") + .replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, ""); + if (/[\0\r\n]/.test(normalized)) { + throw new TypeError("Invalid header value"); + } + return normalized; + } + + function isForbiddenResponseHeader(name) { + return name === "set-cookie" || name === "set-cookie2"; + } + + function appendHeader(state, name, value) { + if (state.guard === "response" && isForbiddenResponseHeader(name)) { + return; + } + state.list.push([name, value]); + state.version++; + } + + function removeHeader(state, name) { + var writeIndex = 0; + var removed = false; + for (var readIndex = 0; readIndex < state.list.length; ++readIndex) { + var entry = state.list[readIndex]; + if (entry[0] === name) { + removed = true; + } else { + state.list[writeIndex++] = entry; + } + } + state.list.length = writeIndex; + return removed; + } + + function fillHeaders(target, init) { + var state = requireHeaderState(target); + if (init === undefined) { + return; + } + if ((typeof init !== "object" && typeof init !== "function") || init === null) { + throw new TypeError("Headers initializer must be an object"); + } + + var iteratorMethod = init[Symbol.iterator]; + if (iteratorMethod !== undefined) { + if (typeof iteratorMethod !== "function") { + throw new TypeError("Headers initializer is not iterable"); + } + for (var entry of init) { + if ((typeof entry !== "object" && typeof entry !== "function") || entry === null) { + throw new TypeError("Each header pair must be iterable"); + } + var pair = Array.from(entry); + if (pair.length !== 2) { + throw new TypeError("Each header pair must contain exactly two items"); + } + appendHeader(state, normalizeHeaderName(pair[0]), normalizeHeaderValue(pair[1])); + } + return; + } + + for (var name of Object.keys(init)) { + appendHeader(state, normalizeHeaderName(name), normalizeHeaderValue(init[name])); + } + } + + function combinedHeaderEntries(state) { + if (state.cacheVersion === state.version) { + return state.cache; + } + var valuesByName = new Map(); + for (var entry of state.list) { + var values = valuesByName.get(entry[0]); + if (!values) { + values = []; + valuesByName.set(entry[0], values); + } + values.push(entry[1]); + } + + var result = []; + var names = Array.from(valuesByName.keys()).sort(); + for (var name of names) { + var values = valuesByName.get(name); + if (name === "set-cookie") { + for (var value of values) { + result.push([name, value]); + } + } else { + result.push([name, values.join(", ")]); + } + } + state.cache = result; + state.cacheVersion = state.version; + return state.cache; + } + + function* iterateHeaders(headers, kind) { + var index = 0; + while (true) { + var entries = combinedHeaderEntries(requireHeaderState(headers)); + if (index >= entries.length) { + return; + } + var entry = entries[index++]; + if (kind === "key") { + yield entry[0]; + } else if (kind === "value") { + yield entry[1]; + } else { + yield [entry[0], entry[1]]; + } + } + } + + class Headers { + constructor(init) { + headerStates.set(this, { + cache: [], + cacheVersion: -1, + guard: "none", + list: [], + version: 0 + }); + fillHeaders(this, init); + } + + append(name, value) { + var state = requireHeaderState(this); + appendHeader(state, normalizeHeaderName(name), normalizeHeaderValue(value)); + } + + delete(name) { + var state = requireHeaderState(this); + name = normalizeHeaderName(name); + if (state.guard === "response" && isForbiddenResponseHeader(name)) { + return; + } + if (removeHeader(state, name)) { + state.version++; + } + } + + get(name) { + var state = requireHeaderState(this); + name = normalizeHeaderName(name); + var value = null; + for (var entry of state.list) { + if (entry[0] === name) { + value = value === null ? entry[1] : value + ", " + entry[1]; + } + } + return value; + } + + getSetCookie() { + var state = requireHeaderState(this); + var values = []; + for (var entry of state.list) { + if (entry[0] === "set-cookie") { + values.push(entry[1]); + } + } + return values; + } + + has(name) { + var state = requireHeaderState(this); + name = normalizeHeaderName(name); + return state.list.some(function(entry) { return entry[0] === name; }); + } + + set(name, value) { + var state = requireHeaderState(this); + name = normalizeHeaderName(name); + value = normalizeHeaderValue(value); + if (state.guard === "response" && isForbiddenResponseHeader(name)) { + return; + } + removeHeader(state, name); + state.list.push([name, value]); + state.version++; + } + + entries() { + requireHeaderState(this); + return iterateHeaders(this, "entry"); + } + + keys() { + requireHeaderState(this); + return iterateHeaders(this, "key"); + } + + values() { + requireHeaderState(this); + return iterateHeaders(this, "value"); + } + + forEach(callback, thisArg) { + requireHeaderState(this); + if (typeof callback !== "function") { + throw new TypeError("Headers.forEach callback must be a function"); + } + for (var entry of this) { + callback.call(thisArg, entry[1], entry[0], this); + } + } + + [Symbol.iterator]() { + return this.entries(); + } + } + + Object.defineProperty(Headers.prototype, Symbol.toStringTag, { + configurable: true, + value: "Headers" + }); + + function createResponseHeaders(init) { + var headers = new Headers(init); + var state = requireHeaderState(headers); + state.guard = "response"; + var removedForbidden = removeHeader(state, "set-cookie"); + removedForbidden = removeHeader(state, "set-cookie2") || removedForbidden; + if (removedForbidden) { + state.version++; + } + return headers; + } + + function isReadableStream(value) { + return global.ReadableStream !== undefined && global.ReadableStream !== null && + value instanceof global.ReadableStream; + } + + function streamFromBytes(bytes) { + var chunk = bytes; + return new global.ReadableStream({ + type: "bytes", + pull: function(controller) { + if (chunk !== null) { + var value = chunk; + chunk = null; + controller.enqueue(value); + } + controller.close(); + }, + cancel: function() { + chunk = null; + } + }); + } + + function copyBufferSource(value) { + var source; + if (value instanceof ArrayBuffer) { + source = new Uint8Array(value); + } else { + source = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + var copy = new Uint8Array(source.byteLength); + copy.set(source); + return copy; + } + + function extractBody(body) { + if (body === null || body === undefined) { + return { stream: null, contentType: null }; + } + if (isReadableStream(body)) { + if (body.locked || body._disturbed) { + throw new TypeError("Response body stream is already disturbed or locked"); + } + return { stream: body, contentType: null }; + } + if (global.Blob !== undefined && global.Blob !== null && body instanceof global.Blob) { + return { stream: body.stream(), contentType: body.type || null }; + } + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + return { stream: streamFromBytes(copyBufferSource(body)), contentType: null }; + } + if (global.URLSearchParams !== undefined && global.URLSearchParams !== null && body instanceof global.URLSearchParams) { + return { + stream: streamFromBytes(new global.TextEncoder().encode(body.toString())), + contentType: "application/x-www-form-urlencoded;charset=UTF-8" + }; + } + if (global.FormData !== undefined && global.FormData !== null && body instanceof global.FormData) { + throw new TypeError("FormData response bodies are not supported by this runtime"); + } + + return { + stream: streamFromBytes(new global.TextEncoder().encode(String(body))), + contentType: "text/plain;charset=UTF-8" + }; + } + + function bodyIsUnusable(state) { + return state.used || (state.body !== null && (state.body.locked || state.body._disturbed)); + } + + async function readBodyChunks(state) { + if (state.body === null) { + return [[], 0]; + } + if (bodyIsUnusable(state)) { + throw new TypeError("Response body is already used"); + } + + state.used = true; + var chunks = []; + var total = 0; + var reader = state.body.getReader(); + while (true) { + var result = await reader.read(); + if (result.done) { + return [chunks, total]; + } + if (!(result.value instanceof Uint8Array)) { + throw new TypeError("Response body stream yielded a non-Uint8Array chunk"); + } + if (result.value.byteLength === 0) { + continue; + } + if (total > Number.MAX_SAFE_INTEGER - result.value.byteLength) { + throw new RangeError("Response body is too large"); + } + chunks.push(result.value); + total += result.value.byteLength; + } + } + + function joinChunks(chunksAndTotal) { + var chunks = chunksAndTotal[0]; + var total = chunksAndTotal[1]; + if (chunks.length === 0) { + return new Uint8Array(0); + } + if (chunks.length === 1) { + return chunks[0]; + } + + var bytes = new Uint8Array(total); + var offset = 0; + for (var chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; + } + + async function consumeText(state) { + if (state.body === null) { + return ""; + } + if (bodyIsUnusable(state)) { + throw new TypeError("Response body is already used"); + } + + state.used = true; + var decoder = new global.TextDecoder(); + var pieces = []; + var reader = state.body.getReader(); + while (true) { + var result = await reader.read(); + if (result.done) { + pieces.push(decoder.decode()); + return pieces.join(""); + } + if (!(result.value instanceof Uint8Array)) { + throw new TypeError("Response body stream yielded a non-Uint8Array chunk"); + } + if (result.value.byteLength !== 0) { + pieces.push(decoder.decode(result.value, { stream: true })); + } + } + } + + function validateResponseInit(init) { + if (init === undefined || init === null) { + init = {}; + } else if (typeof init !== "object" && typeof init !== "function") { + throw new TypeError("Response init must be an object"); + } + + var status = init.status === undefined ? 200 : Math.trunc(Number(init.status)); + if (!Number.isFinite(status) || status < 200 || status > 599) { + throw new RangeError("Response status must be between 200 and 599"); + } + var statusText = init.statusText === undefined ? "" : + toByteString(init.statusText, "Response statusText"); + if (/[\r\n]/.test(statusText)) { + throw new TypeError("Invalid Response statusText"); + } + return { + headers: createResponseHeaders(init.headers), + status: status, + statusText: statusText + }; + } + + function initializeResponse(response, body, init, metadata) { + var normalizedInit = validateResponseInit(init); + var extracted = extractBody(body); + if (extracted.stream !== null && nullBodyStatuses.indexOf(normalizedInit.status) !== -1) { + throw new TypeError("Response with this status cannot have a body"); + } + if (extracted.contentType !== null && !normalizedInit.headers.has("content-type")) { + normalizedInit.headers.append("content-type", extracted.contentType); + } + + responseStates.set(response, { + body: extracted.stream, + headers: normalizedInit.headers, + redirected: metadata && metadata.redirected === true, + status: normalizedInit.status, + statusText: normalizedInit.statusText, + type: metadata && metadata.type ? String(metadata.type) : "default", + url: metadata && metadata.url ? String(metadata.url) : "", + used: false + }); + } + + class Response { + constructor(body, init) { + initializeResponse(this, body === undefined ? null : body, init, null); + } + + get body() { return requireResponseState(this).body; } + get bodyUsed() { + var state = requireResponseState(this); + return state.used || (state.body !== null && state.body._disturbed === true); + } + get headers() { return requireResponseState(this).headers; } + get ok() { + var status = requireResponseState(this).status; + return status >= 200 && status <= 299; + } + get redirected() { return requireResponseState(this).redirected; } + get status() { return requireResponseState(this).status; } + get statusText() { return requireResponseState(this).statusText; } + get type() { return requireResponseState(this).type; } + get url() { return requireResponseState(this).url; } + + async arrayBuffer() { + var bytes = joinChunks(await readBodyChunks(requireResponseState(this))); + if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) { + return bytes.buffer; + } + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + } + + async blob() { + var state = requireResponseState(this); + var bytes = joinChunks(await readBodyChunks(state)); + return new global.Blob([bytes], { type: state.headers.get("content-type") || "" }); + } + + async bytes() { + return joinChunks(await readBodyChunks(requireResponseState(this))); + } + + clone() { + var state = requireResponseState(this); + if (bodyIsUnusable(state)) { + throw new TypeError("Cannot clone a used Response"); + } + + var cloneBody = null; + if (state.body !== null) { + var branches = state.body.tee(); + state.body = branches[0]; + cloneBody = branches[1]; + } + var clone = new Response(cloneBody, { + headers: state.headers, + status: state.status, + statusText: state.statusText + }); + var cloneState = requireResponseState(clone); + cloneState.redirected = state.redirected; + cloneState.type = state.type; + cloneState.url = state.url; + return clone; + } + + async json() { + return JSON.parse(await consumeText(requireResponseState(this))); + } + + async text() { + return consumeText(requireResponseState(this)); + } + + static error() { + var response = Object.create(Response.prototype); + responseStates.set(response, { + body: null, + headers: createResponseHeaders(), + redirected: false, + status: 0, + statusText: "", + type: "error", + url: "", + used: false + }); + return response; + } + + static json(data, init) { + var text = JSON.stringify(data); + if (text === undefined) { + throw new TypeError("Response.json data is not JSON serializable"); + } + var hasExplicitContentType = init !== undefined && init !== null && + new Headers(init.headers).has("content-type"); + var response = new Response(text, init); + if (!hasExplicitContentType) { + response.headers.set("content-type", "application/json"); + } + return response; + } + + static redirect(url, status) { + status = status === undefined ? 302 : Math.trunc(Number(status)); + if ([301, 302, 303, 307, 308].indexOf(status) === -1) { + throw new RangeError("Invalid redirect status"); + } + var location = global.URL !== undefined && global.URL !== null + ? new global.URL(String(url), global.location && global.location.href || undefined).toString() + : String(url); + return new Response(null, { status: status, headers: { location: location } }); + } + } + + Object.defineProperty(Response.prototype, Symbol.toStringTag, { + configurable: true, + value: "Response" + }); + + function createFetchResponse(ResponseConstructor, bytes, init, metadata) { + var status = init && init.status; + var body = nullBodyStatuses.indexOf(status) === -1 ? streamFromBytes(bytes) : null; + var response = new ResponseConstructor(body, init); + var state = responseStates.get(response); + if (state) { + state.redirected = metadata.redirected === true; + state.type = metadata.type || "basic"; + state.url = metadata.url || ""; + } + return response; + } + + return { + Headers: Headers, + Response: Response, + createFetchResponse: createFetchResponse + }; +})(globalThis) diff --git a/Polyfills/Fetch/Source/FetchScripts.h.in b/Polyfills/Fetch/Source/FetchScripts.h.in new file mode 100644 index 00000000..8cc0e511 --- /dev/null +++ b/Polyfills/Fetch/Source/FetchScripts.h.in @@ -0,0 +1,8 @@ +#pragma once + +namespace Babylon::Polyfills::Internal::FetchScripts +{ + inline constexpr char Polyfill[] = R"JSRHFETCH( +@FETCH_POLYFILL_SOURCE@ +)JSRHFETCH"; +} diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 0df6e9de..3246a42b 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -293,11 +293,253 @@ describe("XMLHTTPRequest", function () { }); }); +describe("Headers", function () { + // Focused ports from WPT fetch/api/headers. + it("normalizes names and values and combines repeated fields", function () { + const headers = new Headers([ + ["X-Test", " first\t"], + ["x-test", "second"], + ["X-Other", "\r\n value \n"] + ]); + expect(headers.get("X-TEST")).to.equal("first, second"); + expect(headers.get("x-other")).to.equal("value"); + expect(Array.from(headers.keys())).to.deep.equal(["x-other", "x-test"]); + }); + + it("validates sequence shape and HTTP ByteStrings", function () { + expect(() => new Headers(null as any)).to.throw(); + expect(() => new Headers([["missing-value"]] as any)).to.throw(); + expect(() => new Headers([["too", "many", "values"]] as any)).to.throw(); + expect(() => new Headers([["invalid name", "value"]])).to.throw(); + expect(() => new Headers([["valid", "a\0b"]])).to.throw(); + expect(() => new Headers([["invalidĀ", "value"]])).to.throw(); + }); + + it("preserves Set-Cookie fields while combining other duplicates", function () { + const headers = new Headers([ + ["set-cookie", "a=1"], + ["x-value", "first"], + ["Set-Cookie", "b=2"], + ["X-Value", "second"] + ]); + expect(headers.getSetCookie()).to.deep.equal(["a=1", "b=2"]); + expect(Array.from(headers)).to.deep.equal([ + ["set-cookie", "a=1"], + ["set-cookie", "b=2"], + ["x-value", "first, second"] + ]); + }); + + it("keeps iteration live when headers are changed", function () { + const headers = new Headers({ bar: "0", baz: "1", foo: "2" }); + const seen: string[] = []; + for (const [name] of headers) { + seen.push(name); + headers.delete("foo"); + } + expect(seen).to.deep.equal(["bar", "baz"]); + }); + + it("copies an initializer and honors a custom iterator", function () { + const source = new Headers({ ignored: "value" }); + source[Symbol.iterator] = function* () { + yield ["custom", "value"]; + }; + const copy = new Headers(source); + source.set("custom", "changed"); + expect(Array.from(copy)).to.deep.equal([["custom", "value"]]); + }); +}); + +describe("Response", function () { + // Focused ports from WPT fetch/api/response. + it("exposes browser defaults and validates response metadata", function () { + const response = new Response(); + expect(String(response)).to.equal("[object Response]"); + expect(response.status).to.equal(200); + expect(response.statusText).to.equal(""); + expect(response.ok).to.equal(true); + expect(response.type).to.equal("default"); + expect(response.url).to.equal(""); + expect(response.body).to.equal(null); + expect(response.headers).to.equal(response.headers); + + expect(() => new Response("", { status: 199 })).to.throw(RangeError); + expect(() => new Response("", { status: 600 })).to.throw(RangeError); + expect(() => new Response("", { statusText: "bad\ntext" })).to.throw(TypeError); + expect(() => new Response("body", { status: 204 })).to.throw(TypeError); + }); + + it("streams strings and consumes a body only once", async function () { + const response = new Response("streamed text"); + expect(response.body).to.be.instanceOf(ReadableStream); + expect(response.bodyUsed).to.equal(false); + expect(await response.text()).to.equal("streamed text"); + expect(response.bodyUsed).to.equal(true); + + let rejected = false; + try { + await response.arrayBuffer(); + } catch (error) { + rejected = error instanceof TypeError; + } + expect(rejected).to.equal(true); + }); + + // Ported from WPT fetch/api/response/response-consume-empty.any.js. + it("consumes a null body as empty without disturbing it", async function () { + const textResponse = new Response(); + expect(await textResponse.text()).to.equal(""); + expect(textResponse.bodyUsed).to.equal(false); + + const bufferResponse = new Response(); + expect((await bufferResponse.arrayBuffer()).byteLength).to.equal(0); + expect(bufferResponse.bodyUsed).to.equal(false); + + const bytesResponse = new Response(); + expect((await bytesResponse.bytes()).byteLength).to.equal(0); + expect(bytesResponse.bodyUsed).to.equal(false); + + const blobResponse = new Response(); + expect((await blobResponse.blob()).size).to.equal(0); + expect(blobResponse.bodyUsed).to.equal(false); + + const jsonResponse = new Response(); + let jsonRejected = false; + try { + await jsonResponse.json(); + } catch { + jsonRejected = true; + } + expect(jsonRejected).to.equal(true); + expect(jsonResponse.bodyUsed).to.equal(false); + }); + + it("snapshots mutable BufferSource input once", async function () { + const input = new Uint8Array([80, 65, 83, 83]); + const response = new Response(input); + input.fill(0); + expect(await response.text()).to.equal("PASS"); + }); + + // Adapted from WebKit LayoutTests/fetch/body-init.html. + it("stringifies integer and object bodies", async function () { + expect(await new Response(1 as any).text()).to.equal("1"); + expect(await new Response({} as any).text()).to.equal("[object Object]"); + }); + + it("sets inferred content types without replacing explicit headers", async function () { + const text = new Response("text"); + expect(text.headers.get("content-type")).to.equal("text/plain;charset=UTF-8"); + + const blob = new Response(new Blob(["blob"], { type: "application/example" })); + expect(blob.headers.get("content-type")).to.equal("application/example"); + + const explicit = new Response("text", { headers: { "content-type": "text/custom" } }); + expect(explicit.headers.get("content-type")).to.equal("text/custom"); + }); + + it("clones stream branches for independent consumption", async function () { + const response = new Response("clone body", { + headers: { "x-test": "value" }, + status: 201, + statusText: "Created" + }); + const clone = response.clone(); + expect(clone.status).to.equal(201); + expect(clone.headers.get("x-test")).to.equal("value"); + expect(await response.text()).to.equal("clone body"); + expect(await clone.text()).to.equal("clone body"); + }); + + it("tracks direct stream reads and rejects invalid body chunks", async function () { + const direct = new Response("body"); + const reader = direct.body!.getReader(); + expect(direct.bodyUsed).to.equal(false); + await reader.read(); + expect(direct.bodyUsed).to.equal(true); + + const invalid = new Response(new ReadableStream({ + start(controller) { + controller.enqueue("not bytes"); + controller.close(); + } + }) as any); + let rejected = false; + try { + await invalid.bytes(); + } catch (error) { + rejected = error instanceof TypeError; + } + expect(rejected).to.equal(true); + }); + + // Adapted from Firefox dom/fetch/tests/crashtests/1939295.html. The + // unresolved read must remain safe through runtime teardown. + it("does not crash while consuming an open empty stream", function () { + const pending = new Response(new ReadableStream()).text(); + expect(pending).to.be.instanceOf(Promise); + }); + + // Adapted from WebKit's imported many-empty-chunks-crash.html. + it("consumes many empty chunks without retaining growing byte buffers", async function () { + const response = new Response(new ReadableStream({ + start(controller) { + for (let index = 0; index < 40000; ++index) { + controller.enqueue(new Uint8Array()); + } + controller.close(); + } + })); + expect((await response.arrayBuffer()).byteLength).to.equal(0); + }); + + // Bounded adaptation of Chromium's call-extra-crash-is-disturbed.html. + // QuickJS terminates before a JS-defined getter can run after a real + // native stack overflow, so retain the deep-call regression portably. + it("reads bodyUsed from a deep call stack without recursion", function () { + const response = new Response(new ReadableStream()); + function readAtDepth(depth: number): boolean { + return depth === 0 ? response.bodyUsed : readAtDepth(depth - 1); + } + expect(readAtDepth(128)).to.equal(false); + }); + + it("provides error, redirect, and JSON factories", async function () { + const error = Response.error(); + expect(error.type).to.equal("error"); + expect(error.status).to.equal(0); + + const redirect = Response.redirect("https://example.com/path", 307); + expect(redirect.status).to.equal(307); + expect(redirect.headers.get("location")).to.equal("https://example.com/path"); + + const json = Response.json({ value: 42 }); + expect(json.headers.get("content-type")).to.equal("application/json"); + expect(await json.json()).to.deep.equal({ value: 42 }); + }); + + it("filters forbidden response Set-Cookie fields", function () { + const response = new Response(null, { + headers: { + "set-cookie": "secret=value", + "set-cookie2": "legacy=value" + } + }); + response.headers.append("Set-Cookie", "other=value"); + expect(response.headers.getSetCookie()).to.deep.equal([]); + expect(response.headers.has("set-cookie2")).to.equal(false); + }); +}); + describe("fetch", function () { this.timeout(30000); it("should resolve with ok=true and status=200 for a resource that exists", async function () { const response = await fetch("https://github.com/"); + expect(response).to.be.instanceOf(Response); + expect(response.headers).to.be.instanceOf(Headers); + expect(response.body).to.be.instanceOf(ReadableStream); expect(response.ok).to.equal(true); expect(response.status).to.equal(200); }); diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index e08d1f4d..621c6921 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -86,12 +86,12 @@ TEST(JavaScript, All) Babylon::Polyfills::URL::Initialize(env); Babylon::Polyfills::WebSocket::Initialize(env); Babylon::Polyfills::XMLHttpRequest::Initialize(env); - Babylon::Polyfills::Fetch::Initialize(env); + Babylon::Polyfills::Streams::Initialize(env); Babylon::Polyfills::Blob::Initialize(env); Babylon::Polyfills::File::Initialize(env); Babylon::Polyfills::TextDecoder::Initialize(env); Babylon::Polyfills::TextEncoder::Initialize(env); - Babylon::Polyfills::Streams::Initialize(env); + Babylon::Polyfills::Fetch::Initialize(env); auto setExitCodeCallback = Napi::Function::New( env, [&exitCodePromise](const Napi::CallbackInfo& info) { @@ -137,6 +137,33 @@ TEST(Streams, PreservesHostConstructorsAndIsIdempotent) done.get_future().get(); } +TEST(Fetch, PreservesHostClassesAndIsIdempotent) +{ + Babylon::AppRuntime runtime{}; + std::promise done; + + runtime.Dispatch([&done](Napi::Env env) { + auto global = env.Global(); + const auto hostHeaders = Napi::Function::New(env, [](const Napi::CallbackInfo&) {}, "HostHeaders"); + const auto hostResponse = Napi::Function::New(env, [](const Napi::CallbackInfo&) {}, "HostResponse"); + global.Set("Headers", hostHeaders); + global.Set("Response", hostResponse); + + Babylon::Polyfills::Fetch::Initialize(env); + EXPECT_TRUE(global.Get("Headers").StrictEquals(hostHeaders)); + EXPECT_TRUE(global.Get("Response").StrictEquals(hostResponse)); + + const auto installedFetch = global.Get("fetch"); + Babylon::Polyfills::Fetch::Initialize(env); + EXPECT_TRUE(global.Get("Headers").StrictEquals(hostHeaders)); + EXPECT_TRUE(global.Get("Response").StrictEquals(hostResponse)); + EXPECT_TRUE(global.Get("fetch").StrictEquals(installedFetch)); + done.set_value(); + }); + + done.get_future().get(); +} + TEST(Console, Log) { Babylon::AppRuntime runtime{}; From 01b7d90eb9a5fcf9cd1174e42c58161e0cef5c63 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Tue, 21 Jul 2026 23:28:31 -0700 Subject: [PATCH 04/10] Handle empty Response byte bodies Close byte streams for zero-length BufferSource bodies without enqueueing an invalid empty chunk. Preserve the non-null body and bodyUsed semantics, and add focused regression coverage alongside the WPT empty-response cases. --- Polyfills/Fetch/Source/FetchPolyfill.js | 4 +++- Tests/UnitTests/Scripts/tests.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Polyfills/Fetch/Source/FetchPolyfill.js b/Polyfills/Fetch/Source/FetchPolyfill.js index 93f6811a..e41ee182 100644 --- a/Polyfills/Fetch/Source/FetchPolyfill.js +++ b/Polyfills/Fetch/Source/FetchPolyfill.js @@ -286,7 +286,9 @@ if (chunk !== null) { var value = chunk; chunk = null; - controller.enqueue(value); + if (value.byteLength !== 0) { + controller.enqueue(value); + } } controller.close(); }, diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 3246a42b..221c55fc 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -415,6 +415,16 @@ describe("Response", function () { expect(jsonResponse.bodyUsed).to.equal(false); }); + // Byte streams reject zero-length enqueues. An empty BufferSource still + // represents a non-null body, but its stream must close without a chunk. + it("consumes an empty BufferSource without enqueueing an empty byte chunk", async function () { + const response = new Response(new Uint8Array(0)); + expect(response.body).to.be.instanceOf(ReadableStream); + expect(response.bodyUsed).to.equal(false); + expect(new Uint8Array(await response.arrayBuffer())).to.eql(new Uint8Array(0)); + expect(response.bodyUsed).to.equal(true); + }); + it("snapshots mutable BufferSource input once", async function () { const input = new Uint8Array([80, 65, 83, 83]); const response = new Response(input); From cf07c38d7f897421844e2cd8861bf567d2c580e2 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Tue, 21 Jul 2026 18:01:34 -0700 Subject: [PATCH 05/10] Support data URLs in the fetch polyfill Resolve percent-encoded and base64 data URLs without entering the network transport. Use a validating WHATWG-style forgiving-base64 decoder, strip URL fragments from payloads, and preserve response metadata through the standard Response path. Decode into pre-sized native storage and copy the completed body once into JavaScript-owned memory. Cover representative WPT data URL and forgiving-base64 vectors, plus Chromium whitespace cases. --- Polyfills/Fetch/Source/Fetch.cpp | 317 +++++++++++++++++++++++++++---- Tests/UnitTests/Scripts/tests.ts | 65 +++++++ 2 files changed, 350 insertions(+), 32 deletions(-) diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp index 73dde50f..b3b3f2f4 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -12,9 +12,11 @@ #include #include #include +#include #include #include #include +#include namespace Babylon::Polyfills::Internal { @@ -53,6 +55,195 @@ namespace Babylon::Polyfills::Internal }); } + struct DataUrlResponse + { + std::string contentType; + std::string url; + std::vector body; + }; + + bool IsAsciiWhitespace(uint8_t value) + { + return value == 0x09 || value == 0x0A || value == 0x0C || value == 0x0D || value == 0x20; + } + + void TrimAsciiWhitespace(std::string& value) + { + const auto first = std::find_if_not(value.begin(), value.end(), [](unsigned char character) { + return IsAsciiWhitespace(character); + }); + const auto last = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char character) { + return IsAsciiWhitespace(character); + }).base(); + value = first < last ? std::string{first, last} : std::string{}; + } + + int HexDigitValue(char value) + { + if (value >= '0' && value <= '9') + { + return value - '0'; + } + if (value >= 'a' && value <= 'f') + { + return value - 'a' + 10; + } + if (value >= 'A' && value <= 'F') + { + return value - 'A' + 10; + } + return -1; + } + + std::vector PercentDecode(std::string_view value) + { + std::vector decoded; + decoded.reserve(value.size()); + for (size_t index = 0; index < value.size(); ++index) + { + if (value[index] == '%' && index + 2 < value.size()) + { + const int high = HexDigitValue(value[index + 1]); + const int low = HexDigitValue(value[index + 2]); + if (high >= 0 && low >= 0) + { + decoded.push_back(static_cast((high << 4) | low)); + index += 2; + continue; + } + } + decoded.push_back(static_cast(value[index])); + } + return decoded; + } + + int Base64DigitValue(uint8_t value) + { + if (value >= 'A' && value <= 'Z') + { + return value - 'A'; + } + if (value >= 'a' && value <= 'z') + { + return value - 'a' + 26; + } + if (value >= '0' && value <= '9') + { + return value - '0' + 52; + } + if (value == '+') + { + return 62; + } + if (value == '/') + { + return 63; + } + return -1; + } + + std::vector ForgivingBase64Decode(const std::vector& input) + { + size_t digitCount{}; + size_t paddingCount{}; + bool sawPadding{}; + for (const auto value : input) + { + if (IsAsciiWhitespace(value)) + { + continue; + } + if (value == '=') + { + sawPadding = true; + ++paddingCount; + continue; + } + if (sawPadding || Base64DigitValue(value) < 0) + { + throw std::runtime_error{"fetch: invalid base64 data URL"}; + } + ++digitCount; + } + + const auto encodedCount = digitCount + paddingCount; + if ((paddingCount > 0 && (encodedCount % 4 != 0 || paddingCount > 2)) || digitCount % 4 == 1) + { + throw std::runtime_error{"fetch: invalid base64 data URL"}; + } + + std::vector decoded; + decoded.reserve(digitCount * 6 / 8); + uint32_t accumulator{}; + size_t availableBits{}; + for (const auto value : input) + { + if (IsAsciiWhitespace(value) || value == '=') + { + continue; + } + accumulator = (accumulator << 6) | static_cast(Base64DigitValue(value)); + availableBits += 6; + if (availableBits >= 8) + { + availableBits -= 8; + decoded.push_back(static_cast(accumulator >> availableBits)); + accumulator &= (uint32_t{1} << availableBits) - 1; + } + } + return decoded; + } + + std::optional ParseDataUrl(std::string_view url) + { + if (url.size() < 5 || !EqualsIgnoreCase(url.substr(0, 4), "data") || url[4] != ':') + { + return std::nullopt; + } + + const auto comma = url.find(',', 5); + if (comma == std::string_view::npos) + { + throw std::runtime_error{"fetch: malformed data URL"}; + } + + std::string mediaType{url.substr(5, comma - 5)}; + TrimAsciiWhitespace(mediaType); + bool base64 = false; + if (const auto semicolon = mediaType.rfind(';'); semicolon != std::string::npos) + { + std::string finalParameter{mediaType.substr(semicolon + 1)}; + TrimAsciiWhitespace(finalParameter); + if (EqualsIgnoreCase(finalParameter, "base64")) + { + mediaType.resize(semicolon); + TrimAsciiWhitespace(mediaType); + base64 = true; + } + } + if (mediaType.empty()) + { + mediaType = "text/plain;charset=US-ASCII"; + } + else if (mediaType.front() == ';') + { + mediaType.insert(0, "text/plain"); + } + + const auto fragment = url.find('#', comma + 1); + const auto payload = url.substr(comma + 1, fragment == std::string_view::npos ? std::string_view::npos : fragment - comma - 1); + auto decodedPayload = PercentDecode(payload); + if (base64) + { + decodedPayload = ForgivingBase64Decode(decodedPayload); + } + + return DataUrlResponse{ + std::move(mediaType), + std::string{url.substr(0, fragment)}, + std::move(decodedPayload)}; + } + // Stable message used for every transport-failure rejection. Browsers and Node both keep // this constant (the variable detail rides on `cause`) so crash-report grouping stays // intact; we follow Node/undici's "fetch failed" spelling. @@ -185,6 +376,49 @@ namespace Babylon::Polyfills::Internal } JsRuntime::NativeObject::GetFromJavaScript(env).Set("createFetchResponse", exports.Get("createFetchResponse")); } + + template + Napi::Value CreateFetchResponse( + Napi::Env env, + const void* body, + size_t bodySize, + const THeaders& headers, + int status, + std::string_view statusText, + std::string_view url, + bool redirected) + { + auto arrayBuffer = Napi::ArrayBuffer::New(env, bodySize); + if (bodySize > 0) + { + std::memcpy(arrayBuffer.Data(), body, bodySize); + } + const auto bytes = Napi::Uint8Array::New(env, bodySize, arrayBuffer, 0); + + auto responseHeaders = Napi::Array::New(env, headers.size()); + uint32_t headerIndex{}; + for (const auto& header : headers) + { + auto pair = Napi::Array::New(env, 2); + pair.Set(uint32_t{0}, Napi::String::New(env, header.first)); + pair.Set(uint32_t{1}, Napi::String::New(env, header.second)); + responseHeaders.Set(headerIndex++, pair); + } + + auto init = Napi::Object::New(env); + init.Set("headers", responseHeaders); + init.Set("status", Napi::Number::New(env, status)); + init.Set("statusText", Napi::String::New(env, statusText.data(), statusText.size())); + + auto metadata = Napi::Object::New(env); + metadata.Set("redirected", Napi::Boolean::New(env, redirected)); + metadata.Set("type", Napi::String::New(env, "basic")); + metadata.Set("url", Napi::String::New(env, url.data(), url.size())); + + const auto nativeObject = JsRuntime::NativeObject::GetFromJavaScript(env); + const auto createResponse = nativeObject.Get("createFetchResponse").As(); + return createResponse.Call(nativeObject, {env.Global().Get("Response"), bytes, init, metadata}); + } } namespace Fetch @@ -250,6 +484,47 @@ namespace Babylon::Polyfills::Internal signal = init.Get("signal"); } + if (signal.IsObject()) + { + const auto signalObject = signal.As(); + if (signalObject.Get("aborted").ToBoolean().Value()) + { + deferred.Reject(GetAbortReason(env, signalObject)); + return deferred.Promise(); + } + } + + std::optional dataUrl; + try + { + dataUrl = ParseDataUrl(url); + } + catch (const std::runtime_error& error) + { + deferred.Reject(Napi::TypeError::New(env, error.what()).Value()); + return deferred.Promise(); + } + + if (dataUrl) + { + if (method != UrlLib::UrlMethod::Get || body.has_value()) + { + throw std::runtime_error{"fetch: data URLs only support GET requests"}; + } + const std::vector> responseHeaders{ + {"content-type", dataUrl->contentType}}; + deferred.Resolve(CreateFetchResponse( + env, + dataUrl->body.data(), + dataUrl->body.size(), + responseHeaders, + 200, + "OK", + dataUrl->url, + false)); + return deferred.Promise(); + } + auto request = std::make_shared(); request->Open(method, url); request->ResponseType(UrlLib::UrlResponseType::Buffer); @@ -341,40 +616,18 @@ namespace Babylon::Polyfills::Internal } const auto responseBuffer = request->ResponseBuffer(); - auto arrayBuffer = Napi::ArrayBuffer::New(env, responseBuffer.size()); - if (!responseBuffer.empty()) - { - std::memcpy(arrayBuffer.Data(), responseBuffer.data(), responseBuffer.size()); - } - const auto bytes = Napi::Uint8Array::New(env, responseBuffer.size(), arrayBuffer, 0); - - auto responseHeaders = Napi::Array::New(env, request->GetAllResponseHeaders().size()); - uint32_t headerIndex{}; - for (const auto& header : request->GetAllResponseHeaders()) - { - auto pair = Napi::Array::New(env, 2); - pair.Set(uint32_t{0}, Napi::String::New(env, header.first)); - pair.Set(uint32_t{1}, Napi::String::New(env, header.second)); - responseHeaders.Set(headerIndex++, pair); - } - - auto init = Napi::Object::New(env); - init.Set("headers", responseHeaders); - init.Set("status", Napi::Number::New(env, status)); const auto statusText = request->StatusText(); - init.Set("statusText", Napi::String::New(env, statusText.data(), statusText.size())); - const std::string responseUrl{request->ResponseUrl()}; - auto metadata = Napi::Object::New(env); - metadata.Set("redirected", Napi::Boolean::New(env, !responseUrl.empty() && responseUrl != url)); - metadata.Set("type", Napi::String::New(env, "basic")); - metadata.Set("url", Napi::String::New(env, responseUrl.empty() ? url : responseUrl)); - - const auto nativeObject = JsRuntime::NativeObject::GetFromJavaScript(env); - const auto createResponse = nativeObject.Get("createFetchResponse").As(); - deferred.Resolve(createResponse.Call( - nativeObject, - {env.Global().Get("Response"), bytes, init, metadata})); + const std::string_view finalUrl = responseUrl.empty() ? std::string_view{url} : std::string_view{responseUrl}; + deferred.Resolve(CreateFetchResponse( + env, + responseBuffer.data(), + responseBuffer.size(), + request->GetAllResponseHeaders(), + status, + statusText, + finalUrl, + !responseUrl.empty() && responseUrl != url)); }) .then(*scheduler, arcana::cancellation::none(), [deferred, env, scheduler](const arcana::expected& result) { diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 221c55fc..45ce5665 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -572,6 +572,71 @@ describe("fetch", function () { expect(await response.text()).to.equal("var symlink_target_js = true;"); }); + it("should resolve percent-encoded data URLs locally", async function () { + const url = "data:text/plain;charset=utf-8,hello%20native%20fetch%21"; + const response = await fetch(url); + expect(response.ok).to.equal(true); + expect(response.status).to.equal(200); + expect(response.url).to.equal(url); + expect(response.headers.get("content-type")).to.equal("text/plain;charset=utf-8"); + expect(await response.text()).to.equal("hello native fetch!"); + }); + + it("should decode base64 data URLs without using the network transport", async function () { + const response = await fetch("data:application/octet-stream;base64,AAEC/w=="); + const clone = response.clone(); + expect(new Uint8Array(await response.arrayBuffer())).to.eql(new Uint8Array([0, 1, 2, 255])); + const blob = await clone.blob(); + expect(blob.type).to.equal("application/octet-stream"); + expect(new Uint8Array(await blob.arrayBuffer())).to.eql(new Uint8Array([0, 1, 2, 255])); + }); + + // Adapted from WPT fetch/data-urls/processing.any.js and resources/data-urls.json. + const dataUrlCases: Array<[string, string, number[]]> = [ + ["data:,", "text/plain;charset=US-ASCII", []], + ["data:,%FF", "text/plain;charset=US-ASCII", [255]], + ["data:text/plain,X", "text/plain", [88]], + ["data:,X#fragment", "text/plain;charset=US-ASCII", [88]], + ["data:;BASe64,WA", "text/plain;charset=US-ASCII", [88]], + ["data: ;charset=x ; base64,W%20A", "text/plain;charset=x", [88]] + ]; + + for (const [url, expectedType, expectedBody] of dataUrlCases) { + it(`should process WPT data URL case ${JSON.stringify(url)}`, async function () { + const response = await fetch(url); + expect(response.headers.get("content-type")).to.equal(expectedType); + expect(Array.from(new Uint8Array(await response.arrayBuffer()))).to.eql(expectedBody); + }); + } + + // Adapted from WPT's forgiving-base64 vectors and Chromium's DataURL tests. + const base64Cases: Array<[string, number[]]> = [ + ["abcd", [105, 183, 29]], + ["ab%09%0A%0C%0D%20cd", [105, 183, 29]], + ["ab==", [105]], + ["/A", [252]], + ["YR", [97]] + ]; + + for (const [encoded, expectedBody] of base64Cases) { + it(`should forgiving-base64 decode ${JSON.stringify(encoded)}`, async function () { + const response = await fetch(`data:application/octet-stream;base64,${encoded}`); + expect(Array.from(new Uint8Array(await response.arrayBuffer()))).to.eql(expectedBody); + }); + } + + for (const encoded of ["a", "ab===", "ab%0Bcd", "=a", "a=b"]) { + it(`should reject invalid WPT base64 case ${JSON.stringify(encoded)}`, async function () { + let error: unknown; + try { + await fetch(`data:application/octet-stream;base64,${encoded}`); + } catch (caught) { + error = caught; + } + expect(error).to.be.instanceOf(TypeError); + }); + } + it("arrayBuffer() should return the body as bytes", async function () { const response = await fetch("app:///Scripts/symlink_target.js"); const expected = new Uint8Array("var symlink_target_js = true;".split("").map(x => x.charCodeAt(0))); From 3c94a842c224604e190cebbbff4117beb20be250 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Tue, 21 Jul 2026 23:57:04 -0700 Subject: [PATCH 06/10] Avoid intermediate data URL decode buffers Feed percent-decoded bytes directly into validating and decoding passes for Base64 data URLs. Allocate the final native payload at its exact size, avoiding the previous percent-decoded staging vector and any vector growth before the single JavaScript ownership copy. --- Polyfills/Fetch/Source/Fetch.cpp | 48 +++++++++++++++++--------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp index b3b3f2f4..dc8a9a90 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -95,10 +95,9 @@ namespace Babylon::Polyfills::Internal return -1; } - std::vector PercentDecode(std::string_view value) + template + void ForEachPercentDecodedByte(std::string_view value, TCallback&& callback) { - std::vector decoded; - decoded.reserve(value.size()); for (size_t index = 0; index < value.size(); ++index) { if (value[index] == '%' && index + 2 < value.size()) @@ -107,13 +106,22 @@ namespace Babylon::Polyfills::Internal const int low = HexDigitValue(value[index + 2]); if (high >= 0 && low >= 0) { - decoded.push_back(static_cast((high << 4) | low)); + callback(static_cast((high << 4) | low)); index += 2; continue; } } - decoded.push_back(static_cast(value[index])); + callback(static_cast(value[index])); } + } + + std::vector PercentDecode(std::string_view value) + { + std::vector decoded; + decoded.reserve(value.size()); + ForEachPercentDecodedByte(value, [&decoded](uint8_t byte) { + decoded.push_back(byte); + }); return decoded; } @@ -142,29 +150,28 @@ namespace Babylon::Polyfills::Internal return -1; } - std::vector ForgivingBase64Decode(const std::vector& input) + std::vector ForgivingBase64Decode(std::string_view input) { size_t digitCount{}; size_t paddingCount{}; bool sawPadding{}; - for (const auto value : input) - { + ForEachPercentDecodedByte(input, [&](uint8_t value) { if (IsAsciiWhitespace(value)) { - continue; + return; } if (value == '=') { sawPadding = true; ++paddingCount; - continue; + return; } if (sawPadding || Base64DigitValue(value) < 0) { throw std::runtime_error{"fetch: invalid base64 data URL"}; } ++digitCount; - } + }); const auto encodedCount = digitCount + paddingCount; if ((paddingCount > 0 && (encodedCount % 4 != 0 || paddingCount > 2)) || digitCount % 4 == 1) @@ -172,25 +179,24 @@ namespace Babylon::Polyfills::Internal throw std::runtime_error{"fetch: invalid base64 data URL"}; } - std::vector decoded; - decoded.reserve(digitCount * 6 / 8); + std::vector decoded(digitCount / 4 * 3 + digitCount % 4 * 3 / 4); + size_t outputIndex{}; uint32_t accumulator{}; size_t availableBits{}; - for (const auto value : input) - { + ForEachPercentDecodedByte(input, [&](uint8_t value) { if (IsAsciiWhitespace(value) || value == '=') { - continue; + return; } accumulator = (accumulator << 6) | static_cast(Base64DigitValue(value)); availableBits += 6; if (availableBits >= 8) { availableBits -= 8; - decoded.push_back(static_cast(accumulator >> availableBits)); + decoded[outputIndex++] = static_cast(accumulator >> availableBits); accumulator &= (uint32_t{1} << availableBits) - 1; } - } + }); return decoded; } @@ -232,11 +238,7 @@ namespace Babylon::Polyfills::Internal const auto fragment = url.find('#', comma + 1); const auto payload = url.substr(comma + 1, fragment == std::string_view::npos ? std::string_view::npos : fragment - comma - 1); - auto decodedPayload = PercentDecode(payload); - if (base64) - { - decodedPayload = ForgivingBase64Decode(decodedPayload); - } + auto decodedPayload = base64 ? ForgivingBase64Decode(payload) : PercentDecode(payload); return DataUrlResponse{ std::move(mediaType), From 4da47494badeaf5e177faa28b33bc3af88b79bb1 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 22 Jul 2026 00:26:50 -0700 Subject: [PATCH 07/10] Keep Streams implementations internally consistent Replace a partial or null host Streams surface as a complete constructor suite so stream products retain compatible instanceof relationships. Preserve a complete suite on repeated initialization.\n\nHarden the native initialization test so N-API failures complete the test promise instead of hanging, and cover partial host replacement plus null handling and cross-constructor compatibility. --- Polyfills/Streams/Source/Streams.cpp | 8 ++---- Tests/UnitTests/Shared/Shared.cpp | 42 +++++++++++++++++++++------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/Polyfills/Streams/Source/Streams.cpp b/Polyfills/Streams/Source/Streams.cpp index 4552ed07..e742344f 100644 --- a/Polyfills/Streams/Source/Streams.cpp +++ b/Polyfills/Streams/Source/Streams.cpp @@ -31,7 +31,8 @@ namespace Babylon::Polyfills::Streams bool needsPonyfill{}; for (const auto name : constructorNames) { - if (global.Get(name.data()).IsUndefined()) + const auto constructor = global.Get(name.data()); + if (constructor.IsUndefined() || constructor.IsNull()) { needsPonyfill = true; break; @@ -46,10 +47,7 @@ namespace Babylon::Polyfills::Streams const auto exports = Napi::Eval(env, Internal::StreamsScripts::Ponyfill.data(), "jsruntimehost://web-streams-polyfill.js").As(); for (const auto name : constructorNames) { - if (global.Get(name.data()).IsUndefined()) - { - global.Set(name.data(), exports.Get(name.data())); - } + global.Set(name.data(), exports.Get(name.data())); } } } diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index 621c6921..f8f6ff3a 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -113,28 +113,50 @@ TEST(JavaScript, All) EXPECT_EQ(exitCode, 0); } -TEST(Streams, PreservesHostConstructorsAndIsIdempotent) +TEST(Streams, ReplacesPartialHostSuiteAndIsIdempotent) { - Babylon::AppRuntime runtime{}; - std::promise done; + std::promise done; + std::atomic_bool completed{}; + const auto complete = [&done, &completed](std::string result) { + if (!completed.exchange(true)) + { + done.set_value(std::move(result)); + } + }; - runtime.Dispatch([&done](Napi::Env env) { + Babylon::AppRuntime::Options options{}; + options.UnhandledExceptionHandler = [&complete](const Napi::Error& error) { + complete(Napi::GetErrorString(error)); + }; + Babylon::AppRuntime runtime{options}; + + runtime.Dispatch([&complete](Napi::Env env) { auto global = env.Global(); const auto hostReadableStream = Napi::Function::New(env, [](const Napi::CallbackInfo&) {}, "HostReadableStream"); global.Set("ReadableStream", hostReadableStream); + global.Set("TransformStream", env.Null()); Babylon::Polyfills::Streams::Initialize(env); - EXPECT_TRUE(global.Get("ReadableStream").StrictEquals(hostReadableStream)); - EXPECT_TRUE(global.Get("TransformStream").IsFunction()); - + const auto installedReadableStream = global.Get("ReadableStream"); const auto installedTransformStream = global.Get("TransformStream"); + EXPECT_FALSE(installedReadableStream.StrictEquals(hostReadableStream)); + if (!installedReadableStream.IsFunction() || !installedTransformStream.IsFunction()) + { + complete("Streams::Initialize did not install a complete constructor suite."); + return; + } + + const auto transform = installedTransformStream.As().New({}); + EXPECT_TRUE(transform.Get("readable").As().InstanceOf(installedReadableStream.As())); + Babylon::Polyfills::Streams::Initialize(env); - EXPECT_TRUE(global.Get("ReadableStream").StrictEquals(hostReadableStream)); + EXPECT_TRUE(global.Get("ReadableStream").StrictEquals(installedReadableStream)); EXPECT_TRUE(global.Get("TransformStream").StrictEquals(installedTransformStream)); - done.set_value(); + complete({}); }); - done.get_future().get(); + const auto error = done.get_future().get(); + EXPECT_TRUE(error.empty()) << error; } TEST(Fetch, PreservesHostClassesAndIsIdempotent) From 7e7fae2677889c7cd26259521a7111941aff2f71 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 22 Jul 2026 00:29:36 -0700 Subject: [PATCH 08/10] Write Blob BYOB reads into caller buffers Use ReadableByteStreamController.byobRequest views directly and respond with the produced byte count instead of allocating and enqueueing a 64 KiB intermediate chunk. Keep the existing bounded allocation path for default readers.\n\nAdd WPT-derived coverage for small offset BYOB views that cross immutable Blob segment boundaries. --- Polyfills/Blob/Source/Blob.cpp | 69 +++++++++++++++++++++----------- Tests/UnitTests/Scripts/tests.ts | 27 +++++++++++++ 2 files changed, 73 insertions(+), 23 deletions(-) diff --git a/Polyfills/Blob/Source/Blob.cpp b/Polyfills/Blob/Source/Blob.cpp index 2c7ccc0a..4dcb4832 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -309,33 +309,56 @@ namespace Babylon::Polyfills::Internal } constexpr size_t CHUNK_SIZE = 64 * 1024; - const auto outputLength = std::min(CHUNK_SIZE, state->BlobData->Size - state->Position); - auto outputBuffer = Napi::ArrayBuffer::New(callbackEnv, outputLength); - auto destination = static_cast(outputBuffer.Data()); - auto segmentIndex = state->SegmentIndex; - auto segmentOffset = state->SegmentOffset; - size_t remaining = outputLength; - - while (remaining > 0) + const auto copyInto = [&state](std::byte* destination, size_t outputLength) { + auto segmentIndex = state->SegmentIndex; + auto segmentOffset = state->SegmentOffset; + size_t remaining = outputLength; + + while (remaining > 0) + { + const auto& segment = state->BlobData->Segments[segmentIndex]; + const auto copyLength = std::min(segment.Length - segmentOffset, remaining); + std::memcpy(destination, segment.Bytes->data() + segment.Offset + segmentOffset, copyLength); + destination += copyLength; + remaining -= copyLength; + segmentOffset += copyLength; + if (segmentOffset == segment.Length) + { + ++segmentIndex; + segmentOffset = 0; + } + } + + state->Position += outputLength; + state->SegmentIndex = segmentIndex; + state->SegmentOffset = segmentOffset; + }; + + const auto byobRequestValue = controller.Get("byobRequest"); + if (!byobRequestValue.IsUndefined() && !byobRequestValue.IsNull()) { - const auto& segment = state->BlobData->Segments[segmentIndex]; - const auto copyLength = std::min(segment.Length - segmentOffset, remaining); - std::memcpy(destination, segment.Bytes->data() + segment.Offset + segmentOffset, copyLength); - destination += copyLength; - remaining -= copyLength; - segmentOffset += copyLength; - if (segmentOffset == segment.Length) + const auto byobRequest = byobRequestValue.As(); + const auto viewValue = byobRequest.Get("view"); + if (!viewValue.IsTypedArray()) { - ++segmentIndex; - segmentOffset = 0; + throw Napi::TypeError::New(callbackEnv, "Blob.stream() received an invalid BYOB request view."); } - } - auto output = Napi::Uint8Array::New(callbackEnv, outputLength, outputBuffer, 0); - controller.Get("enqueue").As().Call(controller, {output}); - state->Position += outputLength; - state->SegmentIndex = segmentIndex; - state->SegmentOffset = segmentOffset; + const auto view = viewValue.As(); + const auto outputLength = std::min({CHUNK_SIZE, view.ByteLength(), state->BlobData->Size - state->Position}); + const auto outputBuffer = view.ArrayBuffer(); + auto destination = static_cast(outputBuffer.Data()) + view.ByteOffset(); + copyInto(destination, outputLength); + byobRequest.Get("respond").As().Call(byobRequest, {Napi::Number::New(callbackEnv, outputLength)}); + } + else + { + const auto outputLength = std::min(CHUNK_SIZE, state->BlobData->Size - state->Position); + auto outputBuffer = Napi::ArrayBuffer::New(callbackEnv, outputLength); + copyInto(static_cast(outputBuffer.Data()), outputLength); + auto output = Napi::Uint8Array::New(callbackEnv, outputLength, outputBuffer, 0); + controller.Get("enqueue").As().Call(controller, {output}); + } if (state->Position == state->BlobData->Size) { diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 45ce5665..2dbc9573 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -2111,6 +2111,33 @@ describe("Blob", function () { expect(await readStream(new Blob().stream())).to.deep.equal([]); }); + // Adapted from WPT streams/readable-byte-streams/general.any.js BYOB + // coverage. Small, offset views exercise the caller-owned output path. + it("fills bounded BYOB views across Blob segment boundaries", async function () { + const input = new Uint8Array(97); + for (let index = 0; index < input.length; ++index) { + input[index] = (index * 31) & 0xff; + } + + const blob = new Blob([input.subarray(0, 11), input.subarray(11, 53), input.subarray(53)]); + const reader = blob.stream().getReader({ mode: "byob" }); + const output: number[] = []; + const requestSizes = [1, 3, 7, 16]; + let requestIndex = 0; + while (true) { + const requestSize = requestSizes[requestIndex++ % requestSizes.length]; + const request = new Uint8Array(new ArrayBuffer(requestSize + 4), 2, requestSize); + const result = await reader.read(request); + if (result.done) { + break; + } + expect(result.value!.byteLength).to.be.at.most(requestSize); + output.push(...Array.from(result.value!)); + } + + expect(output).to.deep.equal(Array.from(input)); + }); + it("keeps independent stream cursors after the Blob reference is dropped", async function () { let blob: Blob | null = new Blob(["PASS"]); const first = blob.stream(); From 418be9215ce6e3d82023556bc87d19a33a103039 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 22 Jul 2026 00:39:52 -0700 Subject: [PATCH 09/10] Track Response body disturbance through stream operations Observe standard ReadableStream consumption paths with weak per-stream state instead of depending on web-streams-polyfill's private _disturbed field. Instrument stream readers and piping once during Fetch initialization, preserve stream identity, and cache the initialized Fetch implementation so repeated initialization does not stack wrappers or replace its internal state. Treat Headers and Response as an implementation pair when either host global is missing or null. Harden native initialization tests against unhandled N-API failures and add WPT-derived coverage for reads, cancellation, piping, and streams disturbed before Response construction. --- Polyfills/Fetch/Source/Fetch.cpp | 17 ++-- Polyfills/Fetch/Source/FetchPolyfill.js | 104 +++++++++++++++++++++++- Tests/UnitTests/Scripts/tests.ts | 60 +++++++++++++- Tests/UnitTests/Shared/Shared.cpp | 71 ++++++++++++++-- 4 files changed, 237 insertions(+), 15 deletions(-) diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp index dc8a9a90..0168a429 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -250,6 +250,7 @@ namespace Babylon::Polyfills::Internal // this constant (the variable detail rides on `cause`) so crash-report grouping stays // intact; we follow Node/undici's "fetch failed" spelling. constexpr const char* FETCH_FAILED_MESSAGE = "fetch failed"; + constexpr const char* JS_FETCH_POLYFILL_EXPORTS_NAME = "fetchPolyfillExports"; // Snapshot the JS call-site stack synchronously, inside fetch(), before SendAsync() hands // the request to a worker thread. The transport-failure rejection is otherwise built in a @@ -367,16 +368,22 @@ namespace Babylon::Polyfills::Internal void InitializeFetchClasses(Napi::Env env) { auto global = env.Global(); - const auto exports = Napi::Eval(env, FetchScripts::Polyfill, "jsruntimehost://fetch-polyfill.js").As(); - if (global.Get("Headers").IsUndefined()) + auto nativeObject = JsRuntime::NativeObject::GetFromJavaScript(env); + auto exportsValue = nativeObject.Get(JS_FETCH_POLYFILL_EXPORTS_NAME); + if (exportsValue.IsUndefined() || exportsValue.IsNull()) { - global.Set("Headers", exports.Get("Headers")); + exportsValue = Napi::Eval(env, FetchScripts::Polyfill, "jsruntimehost://fetch-polyfill.js"); + nativeObject.Set(JS_FETCH_POLYFILL_EXPORTS_NAME, exportsValue); } - if (global.Get("Response").IsUndefined()) + const auto exports = exportsValue.As(); + const auto headers = global.Get("Headers"); + const auto response = global.Get("Response"); + if (headers.IsUndefined() || headers.IsNull() || response.IsUndefined() || response.IsNull()) { + global.Set("Headers", exports.Get("Headers")); global.Set("Response", exports.Get("Response")); } - JsRuntime::NativeObject::GetFromJavaScript(env).Set("createFetchResponse", exports.Get("createFetchResponse")); + nativeObject.Set("createFetchResponse", exports.Get("createFetchResponse")); } template diff --git a/Polyfills/Fetch/Source/FetchPolyfill.js b/Polyfills/Fetch/Source/FetchPolyfill.js index e41ee182..4435d1ef 100644 --- a/Polyfills/Fetch/Source/FetchPolyfill.js +++ b/Polyfills/Fetch/Source/FetchPolyfill.js @@ -3,9 +3,105 @@ var headerStates = new WeakMap(); var responseStates = new WeakMap(); + var streamTrackers = new WeakMap(); + var readerTrackers = new WeakMap(); + var iteratorTrackers = new WeakMap(); var headerNamePattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; var nullBodyStatuses = [204, 205, 304]; + function wrapMethod(target, name, createWrapper) { + var descriptor = Object.getOwnPropertyDescriptor(target, name); + var original = descriptor ? descriptor.value : target[name]; + if (typeof original !== "function") { + return; + } + if (!descriptor) { + descriptor = { + configurable: true, + enumerable: false, + writable: true + }; + } + descriptor.value = createWrapper(original); + Object.defineProperty(target, name, descriptor); + } + + function trackStream(stream) { + var tracker = streamTrackers.get(stream); + if (!tracker) { + tracker = { disturbed: false }; + streamTrackers.set(stream, tracker); + } + return tracker; + } + + function trackReader(reader, tracker) { + if (readerTrackers.has(reader)) { + return reader; + } + readerTrackers.set(reader, tracker); + for (var name of ["read", "cancel"]) { + wrapMethod(reader, name, function(original) { + return function() { + tracker.disturbed = true; + return original.apply(this, arguments); + }; + }); + } + return reader; + } + + function trackIterator(iterator, tracker) { + if (iteratorTrackers.has(iterator)) { + return iterator; + } + iteratorTrackers.set(iterator, tracker); + for (var name of ["next", "return", "throw"]) { + wrapMethod(iterator, name, function(original) { + return function() { + tracker.disturbed = true; + return original.apply(this, arguments); + }; + }); + } + return iterator; + } + + function instrumentReadableStreams() { + if (global.ReadableStream === undefined || global.ReadableStream === null) { + return; + } + var prototype = global.ReadableStream.prototype; + wrapMethod(prototype, "getReader", function(original) { + return function() { + return trackReader(original.apply(this, arguments), trackStream(this)); + }; + }); + for (var name of ["cancel", "pipeTo", "pipeThrough", "tee"]) { + wrapMethod(prototype, name, function(original) { + return function() { + var result = original.apply(this, arguments); + trackStream(this).disturbed = true; + return result; + }; + }); + } + wrapMethod(prototype, "values", function(original) { + return function() { + return trackIterator(original.apply(this, arguments), trackStream(this)); + }; + }); + if (typeof Symbol.asyncIterator === "symbol") { + wrapMethod(prototype, Symbol.asyncIterator, function(original) { + return function() { + return trackIterator(original.apply(this, arguments), trackStream(this)); + }; + }); + } + } + + instrumentReadableStreams(); + function requireHeaderState(value) { var state = headerStates.get(value); if (!state) { @@ -315,7 +411,7 @@ return { stream: null, contentType: null }; } if (isReadableStream(body)) { - if (body.locked || body._disturbed) { + if (body.locked || trackStream(body).disturbed) { throw new TypeError("Response body stream is already disturbed or locked"); } return { stream: body, contentType: null }; @@ -343,7 +439,7 @@ } function bodyIsUnusable(state) { - return state.used || (state.body !== null && (state.body.locked || state.body._disturbed)); + return state.used || (state.body !== null && (state.body.locked || state.bodyTracker.disturbed)); } async function readBodyChunks(state) { @@ -458,6 +554,7 @@ responseStates.set(response, { body: extracted.stream, + bodyTracker: extracted.stream === null ? null : trackStream(extracted.stream), headers: normalizedInit.headers, redirected: metadata && metadata.redirected === true, status: normalizedInit.status, @@ -476,7 +573,7 @@ get body() { return requireResponseState(this).body; } get bodyUsed() { var state = requireResponseState(this); - return state.used || (state.body !== null && state.body._disturbed === true); + return state.used || (state.body !== null && state.bodyTracker.disturbed); } get headers() { return requireResponseState(this).headers; } get ok() { @@ -517,6 +614,7 @@ if (state.body !== null) { var branches = state.body.tee(); state.body = branches[0]; + state.bodyTracker = trackStream(state.body); cloneBody = branches[1]; } var clone = new Response(cloneBody, { diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 2dbc9573..c8ef3b64 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -462,8 +462,23 @@ describe("Response", function () { expect(await clone.text()).to.equal("clone body"); }); + function withoutPrivateDisturbedState(stream: ReadableStream): ReadableStream { + Object.defineProperty(stream, "_disturbed", { + configurable: true, + get() { return undefined; }, + set() {} + }); + return stream; + } + it("tracks direct stream reads and rejects invalid body chunks", async function () { - const direct = new Response("body"); + const directStream = withoutPrivateDisturbedState(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("body")); + controller.close(); + } + })); + const direct = new Response(directStream); const reader = direct.body!.getReader(); expect(direct.bodyUsed).to.equal(false); await reader.read(); @@ -484,6 +499,49 @@ describe("Response", function () { expect(rejected).to.equal(true); }); + it("rejects a host-shaped stream disturbed before Response construction", async function () { + const stream = withoutPrivateDisturbedState(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1])); + controller.close(); + } + })); + const reader = stream.getReader(); + await reader.read(); + reader.releaseLock(); + + expect(() => new Response(stream)).to.throw(TypeError); + }); + + // Focused ports from WPT response-stream-disturbed-6.any.js and + // response-stream-disturbed-by-pipe.any.js. These must not depend on a + // private field supplied by one particular Streams implementation. + it("tracks cancellation and piping through standard stream methods", async function () { + const cancelled = new Response(withoutPrivateDisturbedState(new ReadableStream())); + const cancelledReader = cancelled.body!.getReader(); + expect(cancelled.bodyUsed).to.equal(false); + await cancelledReader.cancel(); + expect(cancelled.bodyUsed).to.equal(true); + + const piped = new Response(withoutPrivateDisturbedState(new ReadableStream({ + start(controller) { + controller.close(); + } + }))); + const pipePromise = piped.body!.pipeTo(new WritableStream({}, { highWaterMark: 0 })); + expect(piped.bodyUsed).to.equal(true); + await pipePromise; + + const pipedThrough = new Response(withoutPrivateDisturbedState(new ReadableStream({ + start(controller) { + controller.close(); + } + }))); + const output = pipedThrough.body!.pipeThrough(new TransformStream()); + expect(pipedThrough.bodyUsed).to.equal(true); + await output.cancel(); + }); + // Adapted from Firefox dom/fetch/tests/crashtests/1939295.html. The // unresolved read must remain safe through runtime teardown. it("does not crash while consuming an open empty stream", function () { diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index f8f6ff3a..23ca47b5 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -159,12 +159,24 @@ TEST(Streams, ReplacesPartialHostSuiteAndIsIdempotent) EXPECT_TRUE(error.empty()) << error; } -TEST(Fetch, PreservesHostClassesAndIsIdempotent) +TEST(Fetch, PreservesCompleteHostClassesAndIsIdempotent) { - Babylon::AppRuntime runtime{}; - std::promise done; + std::promise done; + std::atomic_bool completed{}; + const auto complete = [&done, &completed](std::string result) { + if (!completed.exchange(true)) + { + done.set_value(std::move(result)); + } + }; - runtime.Dispatch([&done](Napi::Env env) { + Babylon::AppRuntime::Options options{}; + options.UnhandledExceptionHandler = [&complete](const Napi::Error& error) { + complete(Napi::GetErrorString(error)); + }; + Babylon::AppRuntime runtime{options}; + + runtime.Dispatch([&complete](Napi::Env env) { auto global = env.Global(); const auto hostHeaders = Napi::Function::New(env, [](const Napi::CallbackInfo&) {}, "HostHeaders"); const auto hostResponse = Napi::Function::New(env, [](const Napi::CallbackInfo&) {}, "HostResponse"); @@ -180,10 +192,57 @@ TEST(Fetch, PreservesHostClassesAndIsIdempotent) EXPECT_TRUE(global.Get("Headers").StrictEquals(hostHeaders)); EXPECT_TRUE(global.Get("Response").StrictEquals(hostResponse)); EXPECT_TRUE(global.Get("fetch").StrictEquals(installedFetch)); - done.set_value(); + complete({}); }); - done.get_future().get(); + const auto error = done.get_future().get(); + EXPECT_TRUE(error.empty()) << error; +} + +TEST(Fetch, ReplacesPartialOrNullHostClassesAsACompletePair) +{ + std::promise done; + std::atomic_bool completed{}; + const auto complete = [&done, &completed](std::string result) { + if (!completed.exchange(true)) + { + done.set_value(std::move(result)); + } + }; + + Babylon::AppRuntime::Options options{}; + options.UnhandledExceptionHandler = [&complete](const Napi::Error& error) { + complete(Napi::GetErrorString(error)); + }; + Babylon::AppRuntime runtime{options}; + + runtime.Dispatch([&complete](Napi::Env env) { + auto global = env.Global(); + const auto hostHeaders = Napi::Function::New(env, [](const Napi::CallbackInfo&) {}, "HostHeaders"); + global.Set("Headers", hostHeaders); + global.Set("Response", env.Null()); + + Babylon::Polyfills::Fetch::Initialize(env); + const auto installedHeaders = global.Get("Headers"); + const auto installedResponse = global.Get("Response"); + EXPECT_FALSE(installedHeaders.StrictEquals(hostHeaders)); + if (!installedHeaders.IsFunction() || !installedResponse.IsFunction()) + { + complete("Fetch::Initialize did not install a complete Headers/Response pair."); + return; + } + + const auto response = installedResponse.As().New({}); + EXPECT_TRUE(response.Get("headers").As().InstanceOf(installedHeaders.As())); + + Babylon::Polyfills::Fetch::Initialize(env); + EXPECT_TRUE(global.Get("Headers").StrictEquals(installedHeaders)); + EXPECT_TRUE(global.Get("Response").StrictEquals(installedResponse)); + complete({}); + }); + + const auto error = done.get_future().get(); + EXPECT_TRUE(error.empty()) << error; } TEST(Console, Log) From dac2d8c862057a3974a607232691fe809f3c0949 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 26 Jul 2026 02:15:02 -0700 Subject: [PATCH 10/10] Preserve Streams bundle bytes across source splits --- Polyfills/Streams/Source/StreamsScripts.h.in | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Polyfills/Streams/Source/StreamsScripts.h.in b/Polyfills/Streams/Source/StreamsScripts.h.in index 9bf7f5bf..126a23f7 100644 --- a/Polyfills/Streams/Source/StreamsScripts.h.in +++ b/Polyfills/Streams/Source/StreamsScripts.h.in @@ -9,9 +9,10 @@ namespace Babylon::Polyfills::Internal::StreamsScripts (function() { var exports = {}; var module = { exports: exports }; -@WEB_STREAMS_POLYFILL_SOURCE_1@ -)JSRHSTREAM"; +@WEB_STREAMS_POLYFILL_SOURCE_1@)JSRHSTREAM"; + // Keep the split transparent: inserting whitespace here can divide an + // identifier or string literal when the vendored bundle is regenerated. inline constexpr char PonyfillPart2[] = R"JSRHSTREAM(@WEB_STREAMS_POLYFILL_SOURCE_2@ return module.exports; })()