From 29e0357c4e083095901f26eae615e9c743b62a28 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Tue, 21 Jul 2026 21:55:02 -0700 Subject: [PATCH 01/22] 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 21882d3e..6535a653 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 f3676d7d..254b10dc 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -78,6 +78,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 654c60b6..6b153c59 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1541,6 +1541,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 1c7e9ff7..b71218bd 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) { @@ -166,6 +168,30 @@ TEST(Scheduling, IntervalSurvivesThrowingCallback) EXPECT_EQ(unhandledErrorCount.load(), 2); } +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 8e8ff8fc3eeb375aee07543988eb340636b9a573 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Tue, 21 Jul 2026 22:19:21 -0700 Subject: [PATCH 02/22] 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 | 507 ++++++++++++++++++++++++++++--- Polyfills/Blob/Source/Blob.h | 27 +- Polyfills/File/Source/File.cpp | 47 ++- Polyfills/File/Source/File.h | 2 + Tests/UnitTests/Scripts/tests.ts | 227 ++++++++++++++ 7 files changed, 761 insertions(+), 62 deletions(-) create mode 100644 Polyfills/Blob/README.md diff --git a/Polyfills/Blob/CMakeLists.txt b/Polyfills/Blob/CMakeLists.txt index b8da610e..d2f9d88f 100644 --- a/Polyfills/Blob/CMakeLists.txt +++ b/Polyfills/Blob/CMakeLists.txt @@ -1,6 +1,7 @@ set(SOURCES "Include/Babylon/Polyfills/Blob.h" "InternalInclude/Babylon/Polyfills/BlobInternal.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 242763fe..bed99cb6 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -3,8 +3,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::Storage + { + 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"; @@ -19,8 +74,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); } } @@ -28,36 +89,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."); + } - if (blobParts.Length() > 1) + const auto iterator = iteratorMethod.As().Call(blobParts, {}).As(); + const auto next = iterator.Get("next").As(); + try + { + while (true) { - throw Napi::Error::New(Env(), "Using multiple BlobParts in Blob constructor is not implemented."); + const auto result = next.Call(iterator, {}).As(); + if (result.Get("done").ToBoolean().Value()) + { + break; + } + + 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 (...) + { + // 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&) @@ -68,8 +215,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)); @@ -78,60 +228,333 @@ 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(Storage& 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 = std::make_shared>(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 = std::make_shared>(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.IsString()) + else if (blobPart.IsObject()) { - const auto str = blobPart.As().Utf8Value(); - const auto begin = reinterpret_cast(str.data()); - m_data = std::make_shared>(begin, begin + str.length()); + 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; + } } - else + + if (isBufferSource) { - // Assume it's another Blob object. Blobs are immutable, so the buffer can be shared - // rather than copied. - const auto obj = blobPart.As(); - const auto blobObj = Napi::ObjectWrap::Unwrap(obj); - m_data = blobObj->m_data; + auto bytes = std::make_shared>(length); + if (length > 0) + { + std::memcpy(bytes->data(), source, length); + } + data.Append({std::move(bytes), 0, length}); + return false; } + + auto value = blobPart.ToString().Utf8Value(); + auto bytes = std::make_shared>(value.size()); + if (!value.empty()) + { + std::memcpy(bytes->data(), value.data(), value.size()); + } + 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) + { + m_data->CopyTo(0, static_cast(arrayBuffer.Data()), m_data->Size); + } + return arrayBuffer; + } + + const std::shared_ptr>& Blob::Data() const + { + if (!m_contiguous) + { + const auto& segments = m_data->Segments; + if (segments.size() == 1 && segments[0].Offset == 0 && segments[0].Length == segments[0].Bytes->size()) + { + m_contiguous = segments[0].Bytes; + } + else + { + auto bytes = std::make_shared>(m_data->Size); + if (m_data->Size > 0) + { + m_data->CopyTo(0, bytes->data(), m_data->Size); + } + m_contiguous = std::move(bytes); + } + } + return m_contiguous; + } + + 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 5d0147d0..1f061f97 100644 --- a/Polyfills/Blob/Source/Blob.h +++ b/Polyfills/Blob/Source/Blob.h @@ -6,6 +6,7 @@ #include #include #include +#include namespace Babylon::Polyfills::Internal { @@ -17,9 +18,11 @@ namespace Babylon::Polyfills::Internal explicit Blob(const Napi::CallbackInfo& info); // Synchronous accessors for internal cross-polyfill use (e.g. URL.createObjectURL). - // A Blob is immutable once constructed, so the bytes are held in a shared_ptr and can be - // shared with consumers rather than copied. Never null. - const std::shared_ptr>& Data() const { return m_data; } + // A Blob is immutable once constructed, so its bytes can be shared with consumers rather + // than copied. Storage is segmented (slices and multi-part Blobs share their parts' + // buffers), so Data() hands back the one underlying buffer when there is exactly one, and + // otherwise materializes a contiguous copy once and caches it. Never null. + const std::shared_ptr>& Data() const; const std::string& Type() const { return m_type; } private: @@ -28,10 +31,22 @@ 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 Storage; + struct StreamState; + + bool AppendBlobPart(Storage& 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::shared_ptr> m_data{std::make_shared>()}; + std::shared_ptr m_data; + mutable std::shared_ptr> m_contiguous; 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 75261e79..47c07169 100644 --- a/Polyfills/File/Source/File.cpp +++ b/Polyfills/File/Source/File.cpp @@ -46,6 +46,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); @@ -55,12 +57,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) @@ -75,7 +81,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]; @@ -114,21 +120,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); } @@ -169,6 +165,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 6b153c59..122cc1b2 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1698,8 +1698,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"]); @@ -1742,6 +1758,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) { @@ -1763,6 +1893,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(); @@ -1809,6 +1944,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 () { @@ -2162,6 +2373,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 f16e5a7d5d89f30d519f8a0e490309f7379671bd Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Tue, 21 Jul 2026 22:44:34 -0700 Subject: [PATCH 03/22] 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 | 241 +++------ 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(+), 199 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 cf6c171e..5bbf6001 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). @@ -153,18 +143,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()) @@ -172,166 +150,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); - - // Require the Blob polyfill to be installed. - const auto blobConstructor = env.Global().Get("Blob"); - if (!blobConstructor.IsFunction()) - { - 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")); } } @@ -340,6 +188,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(); @@ -487,18 +336,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) { @@ -518,8 +390,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 122cc1b2..1eca74f0 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -302,11 +302,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 b71218bd..3b78d2fb 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) { @@ -192,6 +192,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 4b2961d813eea3a69eef288fd287ea9bb622c18d Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Tue, 21 Jul 2026 23:28:31 -0700 Subject: [PATCH 04/22] 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 1eca74f0..17a99a6d 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -424,6 +424,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 476cc2b5120620bf7dfdafb6eedf8390007ae7a8 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Tue, 21 Jul 2026 18:01:34 -0700 Subject: [PATCH 05/22] 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 5bbf6001..520abb65 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. @@ -181,6 +372,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 @@ -246,6 +480,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); @@ -337,40 +612,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 17a99a6d..28073017 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -581,6 +581,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 e04a95f5cf98e0ea672f4424fc3aacfb690d9600 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Tue, 21 Jul 2026 23:57:04 -0700 Subject: [PATCH 06/22] 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 520abb65..c573c7e6 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 a823b2ffe2117ec73b1c8333cbedaf799f588d2c Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 22 Jul 2026 00:26:50 -0700 Subject: [PATCH 07/22] 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 3b78d2fb..e60e7b70 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -168,28 +168,50 @@ TEST(Scheduling, IntervalSurvivesThrowingCallback) EXPECT_EQ(unhandledErrorCount.load(), 2); } -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 e29e752de91fee590962e224904d898945b653de Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 22 Jul 2026 00:29:36 -0700 Subject: [PATCH 08/22] 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 bed99cb6..635bb7e1 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -310,33 +310,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 28073017..7b9ca0dc 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -2281,6 +2281,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 006a18d6e3b44c0e33ddb25f515049e5c2c6b88f Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 22 Jul 2026 00:39:52 -0700 Subject: [PATCH 09/22] 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 c573c7e6..a7dd3bd2 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 @@ -363,16 +364,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 7b9ca0dc..aab3b8c7 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -471,8 +471,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(); @@ -493,6 +508,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 e60e7b70..b646144f 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -214,12 +214,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"); @@ -235,10 +247,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 517b29808f912adbd4ed27ff61ab83b27a7cc7cc Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 26 Jul 2026 02:15:02 -0700 Subject: [PATCH 10/22] 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; })() From 242dccf09f3afbdcbc6b9a030bcc56165d921d3f Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 13 Sep 2026 02:56:11 -0500 Subject: [PATCH 11/22] Streams: emit the embedded bundle as <=16 KB literals; link Streams into the Android test host MSVC caps a single string literal at 16,380 bytes (C2026) and a concatenated one at 65,535; the 71 KB bundle was emitted as two 60,000/11,188-byte raw strings, so every Windows and UWP job failed at "Build Solution". CMake now emits consecutive parts of at most 16,000 bytes, abutting with no separator (the split stays byte-transparent), and a variadic consteval Join reassembles them into the same std::array as before. The desktop UnitTests target links Streams for the Shared.cpp coverage but the Android UnitTestsJNI host did not, so the Android build failed on the include. --- Polyfills/Streams/CMakeLists.txt | 26 ++++++++++++- Polyfills/Streams/Source/StreamsScripts.h.in | 37 ++++++++++--------- .../Android/app/src/main/cpp/CMakeLists.txt | 3 +- 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/Polyfills/Streams/CMakeLists.txt b/Polyfills/Streams/CMakeLists.txt index 6c00ed81..6a756ccc 100644 --- a/Polyfills/Streams/CMakeLists.txt +++ b/Polyfills/Streams/CMakeLists.txt @@ -3,8 +3,30 @@ set(WEB_STREAMS_POLYFILL_FILE 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) +# MSVC caps a single string literal at 16,380 bytes (C2026) and a concatenated one at 65,535, so +# the bundle is emitted as consecutive raw-string parts of at most 16,000 bytes and joined back +# into one array at compile time. Parts abut with no separator so the split stays byte-transparent +# (a separator could land inside an identifier or string literal when the bundle is regenerated). +string(LENGTH "${WEB_STREAMS_POLYFILL_SOURCE}" _streams_length) +set(_streams_part_size 16000) +set(_streams_offset 0) +set(_streams_index 0) +set(WEB_STREAMS_POLYFILL_PARTS "") +set(WEB_STREAMS_POLYFILL_PART_NAMES "") +while(_streams_offset LESS _streams_length) + math(EXPR _streams_index "${_streams_index} + 1") + math(EXPR _streams_remaining "${_streams_length} - ${_streams_offset}") + if(_streams_remaining LESS _streams_part_size) + set(_streams_take ${_streams_remaining}) + else() + set(_streams_take ${_streams_part_size}) + endif() + string(SUBSTRING "${WEB_STREAMS_POLYFILL_SOURCE}" ${_streams_offset} ${_streams_take} _streams_part) + string(APPEND WEB_STREAMS_POLYFILL_PARTS + " inline constexpr char PonyfillPart${_streams_index}[] = R\"JSRHSTREAM(${_streams_part})JSRHSTREAM\";\n") + string(APPEND WEB_STREAMS_POLYFILL_PART_NAMES ", PonyfillPart${_streams_index}") + math(EXPR _streams_offset "${_streams_offset} + ${_streams_take}") +endwhile() configure_file( "Source/StreamsScripts.h.in" "${CMAKE_CURRENT_BINARY_DIR}/Generated/StreamsScripts.h" diff --git a/Polyfills/Streams/Source/StreamsScripts.h.in b/Polyfills/Streams/Source/StreamsScripts.h.in index 126a23f7..6b145ef5 100644 --- a/Polyfills/Streams/Source/StreamsScripts.h.in +++ b/Polyfills/Streams/Source/StreamsScripts.h.in @@ -5,33 +5,36 @@ namespace Babylon::Polyfills::Internal::StreamsScripts { - inline constexpr char PonyfillPart1[] = R"JSRHSTREAM( + // The vendored bundle is split by CMake into PonyfillPart1..N raw-string literals of at most + // 16,000 bytes each (MSVC C2026) and joined below. The prologue and epilogue wrap it as a + // CommonJS module so its exports can be read back. Nothing is inserted between parts: a + // separator could divide an identifier or string literal when the bundle is regenerated. + inline constexpr char PonyfillPrologue[] = R"JSRHSTREAM( (function() { var exports = {}; var module = { exports: exports }; -@WEB_STREAMS_POLYFILL_SOURCE_1@)JSRHSTREAM"; +)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@ +@WEB_STREAMS_POLYFILL_PARTS@ + inline constexpr char PonyfillEpilogue[] = R"JSRHSTREAM( return module.exports; })() )JSRHSTREAM"; - template - consteval auto Join(const char (&part1)[Part1Size], const char (&part2)[Part2Size]) + template + consteval auto Join(const char (&... parts)[Sizes]) { - 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]; - } + std::array result{}; + std::size_t offset{}; + auto append = [&](const char* part, std::size_t size) { + for (std::size_t index{}; index + 1 < size; ++index) + { + result[offset++] = part[index]; + } + }; + (append(parts, Sizes), ...); return result; } - inline constexpr auto Ponyfill = Join(PonyfillPart1, PonyfillPart2); + inline constexpr auto Ponyfill = Join(PonyfillPrologue@WEB_STREAMS_POLYFILL_PART_NAMES@, PonyfillEpilogue); } diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt index 2e31e0fb..844fa056 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -55,4 +55,5 @@ target_link_libraries(UnitTestsJNI PRIVATE File PRIVATE TextDecoder PRIVATE TextEncoder - PRIVATE Performance) + PRIVATE Performance + PRIVATE Streams) \ No newline at end of file From df025b341c76fb687d07894954b1c44c4c043896 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 13 Sep 2026 05:14:45 -0500 Subject: [PATCH 12/22] Blob: cast the BYOB respond() length to double (MSVC C4244 as error) --- Polyfills/Blob/Source/Blob.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Polyfills/Blob/Source/Blob.cpp b/Polyfills/Blob/Source/Blob.cpp index 635bb7e1..f0a30797 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -350,7 +350,7 @@ namespace Babylon::Polyfills::Internal 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)}); + byobRequest.Get("respond").As().Call(byobRequest, {Napi::Number::New(callbackEnv, static_cast(outputLength))}); } else { From 160557a2c004ebfb0a12a3695d77109de1659ff4 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 13 Sep 2026 05:14:45 -0500 Subject: [PATCH 13/22] Fetch: emit the embedded polyfill as adjacent <=16 KB literals (MSVC C2026) --- Polyfills/Fetch/CMakeLists.txt | 18 ++++++++++++++++++ Polyfills/Fetch/Source/FetchScripts.h.in | 8 +++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Polyfills/Fetch/CMakeLists.txt b/Polyfills/Fetch/CMakeLists.txt index 24346db0..0fa13e35 100644 --- a/Polyfills/Fetch/CMakeLists.txt +++ b/Polyfills/Fetch/CMakeLists.txt @@ -1,6 +1,24 @@ 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) + +# MSVC caps a single string literal at 16,380 bytes (C2026), so the bundle is emitted as adjacent +# raw-string literals of at most 16,000 bytes, which the compiler concatenates back into one array. +# Parts abut with no separator so the split stays byte-transparent. +string(LENGTH "${FETCH_POLYFILL_SOURCE}" _fetch_length) +set(_fetch_offset 0) +set(FETCH_POLYFILL_LITERALS "") +while(_fetch_offset LESS _fetch_length) + math(EXPR _fetch_remaining "${_fetch_length} - ${_fetch_offset}") + if(_fetch_remaining LESS 16000) + set(_fetch_take ${_fetch_remaining}) + else() + set(_fetch_take 16000) + endif() + string(SUBSTRING "${FETCH_POLYFILL_SOURCE}" ${_fetch_offset} ${_fetch_take} _fetch_part) + string(APPEND FETCH_POLYFILL_LITERALS " R\"JSRHFETCH(${_fetch_part})JSRHFETCH\"\n") + math(EXPR _fetch_offset "${_fetch_offset} + ${_fetch_take}") +endwhile() configure_file( "Source/FetchScripts.h.in" "${CMAKE_CURRENT_BINARY_DIR}/Generated/FetchScripts.h" diff --git a/Polyfills/Fetch/Source/FetchScripts.h.in b/Polyfills/Fetch/Source/FetchScripts.h.in index 8cc0e511..13defcf3 100644 --- a/Polyfills/Fetch/Source/FetchScripts.h.in +++ b/Polyfills/Fetch/Source/FetchScripts.h.in @@ -2,7 +2,9 @@ namespace Babylon::Polyfills::Internal::FetchScripts { - inline constexpr char Polyfill[] = R"JSRHFETCH( -@FETCH_POLYFILL_SOURCE@ -)JSRHFETCH"; + // CMake splits the bundle into adjacent raw-string literals of at most 16,000 bytes (MSVC C2026) + // and the compiler concatenates them back; nothing is inserted between parts. + inline constexpr char Polyfill[] = + "\n" +@FETCH_POLYFILL_LITERALS@ "\n"; } From ec9bacbe5b89bf7f3bb4d0c2aa97770d628600b8 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 13 Sep 2026 07:19:02 -0500 Subject: [PATCH 14/22] Blob: read DataView parts through JS properties (JSI's Napi::DataView stub breaks the UWP build) --- Polyfills/Blob/Source/Blob.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Polyfills/Blob/Source/Blob.cpp b/Polyfills/Blob/Source/Blob.cpp index f0a30797..0d2b91a5 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -401,11 +401,15 @@ namespace Babylon::Polyfills::Internal } else if (blobPart.IsDataView()) { - const auto view = blobPart.As(); - const auto buffer = view.ArrayBuffer(); + // Read the view through its JS properties rather than Napi::DataView: the JSI + // backend's DataView wrapper is an unimplemented stub, and merely instantiating it + // fails the UWP build (its `throw "TODO"` leaves unreachable code, C4702 as error). + const auto view = blobPart.As(); + const auto buffer = view.Get("buffer").As(); const auto bufferData = static_cast(buffer.Data()); - source = bufferData == nullptr ? nullptr : bufferData + view.ByteOffset(); - length = view.ByteLength(); + const auto byteOffset = static_cast(view.Get("byteOffset").As().Int64Value()); + source = bufferData == nullptr ? nullptr : bufferData + byteOffset; + length = static_cast(view.Get("byteLength").As().Int64Value()); isBufferSource = true; } else if (blobPart.IsObject()) From 8163ab885a56b25e40943e8756ca0e65f71bf92f Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 13 Sep 2026 07:19:03 -0500 Subject: [PATCH 15/22] CI: suppress WebKitGTK-internal LeakSanitizer reports on the Linux sanitizer job The ASan/UBSan job failed on a 24-byte "direct leak" allocated inside libjavascriptcoregtk (WTF::BitVector::resizeOutOfLine under JSObjectMake -> JSObject::setPrototypeDirect), reached from napi reference creation. It is JavaScriptCore's own global bookkeeping, never freed at exit, and surfaces for whichever caller happens to trigger the growth -- the Blob stream work already documented the same artifact. Mirrors the existing tsan_suppressions.txt wiring. --- .github/lsan_suppressions.txt | 7 +++++++ .github/workflows/build-linux.yml | 1 + 2 files changed, 8 insertions(+) create mode 100644 .github/lsan_suppressions.txt diff --git a/.github/lsan_suppressions.txt b/.github/lsan_suppressions.txt new file mode 100644 index 00000000..e6074781 --- /dev/null +++ b/.github/lsan_suppressions.txt @@ -0,0 +1,7 @@ +# LeakSanitizer suppressions for the Linux sanitizer job. +# +# WebKitGTK's JavaScriptCore grows global bookkeeping (e.g. WTF::BitVector under +# JSObjectMake -> JSObject::setPrototypeDirect on a Structure transition) that it never +# frees at exit, and LSan reports it as a direct leak attributed to whichever caller +# happened to trigger the growth -- here napi reference creation. Not our allocation. +leak:libjavascriptcoregtk diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 3357bf59..aed45f76 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -57,6 +57,7 @@ jobs: run: ./UnitTests env: TSAN_OPTIONS: ${{ inputs.enable-thread-sanitizer && format('suppressions={0}/.github/tsan_suppressions.txt', github.workspace) || '' }} + LSAN_OPTIONS: ${{ inputs.enable-sanitizers && format('suppressions={0}/.github/lsan_suppressions.txt', github.workspace) || '' }} # JSC's concurrent GC on Linux uses SIGUSR1 + sem_wait to suspend mutator # threads at safepoints. TSan's signal interception delays SIGUSR1 delivery # indefinitely, deadlocking the Collector Thread's sem_wait. Disabling the From d667e488c2bd7ba95c012129e7663d08dea9d695 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 13 Sep 2026 07:42:47 -0500 Subject: [PATCH 16/22] Tests: give the heavy Blob/Response stream tests a 60 s budget (timed out on iOS simulator / Hermes) --- Tests/UnitTests/Scripts/tests.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index aab3b8c7..39b1e8c5 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -560,6 +560,9 @@ describe("Response", function () { // Adapted from WebKit's imported many-empty-chunks-crash.html. it("consumes many empty chunks without retaining growing byte buffers", async function () { + // 40k-chunk / nested-part workloads run through the Streams polyfill's per-chunk promise + // machinery; on the iOS simulator and Hermes that straddles mocha's default budget. + this.timeout(60000); const response = new Response(new ReadableStream({ start(controller) { for (let index = 0; index < 40000; ++index) { @@ -2396,6 +2399,9 @@ describe("Blob", function () { // Scaled port of Firefox's dom/streams/test/xpcshell/large-pipeto.js. it("pipes nested shared Blob parts without corrupting chunk boundaries", async function () { + // 40k-chunk / nested-part workloads run through the Streams polyfill's per-chunk promise + // machinery; on the iOS simulator and Hermes that straddles mocha's default budget. + this.timeout(60000); const pattern = new Uint8Array(256 * 1024); for (let index = 0; index < pattern.length; ++index) { pattern[index] = index % 256; From 30eebd219b1466c980c022e8245c26e37c4e53c6 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 13 Sep 2026 13:35:06 -0500 Subject: [PATCH 17/22] QuickJS: bound the JS stack to the thread's real stack; Chakra: define globalThis QuickJS guards JS recursion against stack_top - JS_DEFAULT_STACK_SIZE, which is 1 MiB in quickjs-ng -- also the default size of a non-main thread on Android and Windows. On those threads the check sits below the guard page, so deep recursion faults before QuickJS can raise "InternalError: stack overflow": Android_QuickJS died with SIGSEGV (one frame repeated 150+ deep) in the Fetch polyfill's 40k-chunk Response test, while the 8 MiB-stacked desktop QuickJS jobs were fine. Derive the limit from the running thread (pthread_getattr_np / GetCurrentThreadStackLimits) minus a margin for native frames. The Windows 10 Chakra predates ES2020 and has no `globalThis`; every Fetch test failed there with "ReferenceError: 'globalThis' is not defined". Define it on the global object at env attach, as a plain writable configurable property. --- Core/AppRuntime/Source/AppRuntime_QuickJS.cpp | 55 +++++++++++++++++++ Core/Node-API/Source/env_chakra.cc | 13 +++++ 2 files changed, 68 insertions(+) diff --git a/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp b/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp index 10505fea..d9ab764c 100644 --- a/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp +++ b/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp @@ -18,8 +18,62 @@ #pragma warning(pop) #endif +#if !defined(_WIN32) +#include +#endif +#if defined(_WIN32) +#include +#endif + +#include +#include +#include + namespace Babylon { + namespace + { + // QuickJS guards against JS recursion by comparing the C stack pointer against + // stack_top - stack_size, where stack_size defaults to JS_DEFAULT_STACK_SIZE (1 MiB in + // quickjs-ng). That is also the default size of a non-main thread on Android and Windows, + // so on those threads the limit sits below the real guard page: deep recursion faults + // (SIGSEGV) before QuickJS can raise "InternalError: stack overflow". Derive the limit + // from the thread that actually runs the runtime instead, keeping a margin for the native + // frames QuickJS and the host add between the check and the guard page. + size_t JavaScriptStackLimit() + { + constexpr size_t Margin{256 * 1024}; + constexpr size_t Fallback{512 * 1024}; + size_t threadStack{}; +#if defined(_WIN32) + ULONG_PTR low{}; + ULONG_PTR high{}; + GetCurrentThreadStackLimits(&low, &high); + threadStack = static_cast(high - low); +#elif defined(__APPLE__) + // pthread_getattr_np is a GNU/bionic extension; Apple exposes the size directly. + threadStack = pthread_get_stacksize_np(pthread_self()); +#else + pthread_attr_t attributes; + if (pthread_getattr_np(pthread_self(), &attributes) == 0) + { + void* base{}; + size_t size{}; + if (pthread_attr_getstack(&attributes, &base, &size) == 0) + { + threadStack = size; + } + pthread_attr_destroy(&attributes); + } +#endif + if (threadStack <= Margin) + { + return Fallback; + } + return std::min(threadStack - Margin, static_cast(JS_DEFAULT_STACK_SIZE) * 8); + } + } + void AppRuntime::RunEnvironmentTier(const char* /*executablePath*/) { // Create the runtime. @@ -28,6 +82,7 @@ namespace Babylon { throw std::runtime_error{"Failed to create QuickJS runtime"}; } + JS_SetMaxStackSize(runtime, JavaScriptStackLimit()); // Create the context. JSContext* context = JS_NewContext(runtime); diff --git a/Core/Node-API/Source/env_chakra.cc b/Core/Node-API/Source/env_chakra.cc index 0c0be91c..641467df 100644 --- a/Core/Node-API/Source/env_chakra.cc +++ b/Core/Node-API/Source/env_chakra.cc @@ -23,6 +23,19 @@ namespace Napi JsValueRef global; ThrowIfFailed(JsGetGlobalObject(&global)); JsPropertyIdRef propertyId; + + // The Windows 10 Chakra predates ES2020 and has no `globalThis`; scripts written against + // browsers (and the polyfills in this repo) reference it. Define it as a plain, writable, + // configurable property of the global object, exactly as the spec describes. + ThrowIfFailed(JsGetPropertyIdFromName(L"globalThis", &propertyId)); + JsValueRef existingGlobalThis; + ThrowIfFailed(JsGetProperty(global, propertyId, &existingGlobalThis)); + JsValueType existingType; + ThrowIfFailed(JsGetValueType(existingGlobalThis, &existingType)); + if (existingType == JsUndefined) + { + ThrowIfFailed(JsSetProperty(global, propertyId, global, true)); + } ThrowIfFailed(JsGetPropertyIdFromName(L"Object", &propertyId)); JsValueRef object; ThrowIfFailed(JsGetProperty(global, propertyId, &object)); From f30a41c6303d7ff3fc6d51590a8e10152b00da93 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 13 Sep 2026 13:57:14 -0500 Subject: [PATCH 18/22] Resolve data: URLs for every UrlLib consumer via a registered scheme resolver #212 decoded data: URLs inside the fetch polyfill only, so XMLHttpRequest -- the path Babylon.js' asset and texture loaders take -- and every other UrlLib consumer still treated them as network failures (#67). The WHATWG data: URL processor is lifted out of Fetch.cpp into Polyfills/URL (a URLInternal seam, like BlobInternal) and registered with UrlLib::UrlRequest::RegisterSchemeResolver("data", ...) next to blob:, exactly the shape #207 established. A malformed data: URL is a network error per spec, which UrlLib expresses as `handled == false`. fetch keeps its synchronous fast path on the shared decoder. Tests cover XMLHttpRequest text / base64 arraybuffer / malformed, a data: -> Blob -> object URL round trip, and a base64 gzip data: URL piped through DecompressionStream (skipped where the compression polyfill is not present). --- Polyfills/Fetch/CMakeLists.txt | 3 +- Polyfills/Fetch/Source/Fetch.cpp | 194 +---------------- Polyfills/URL/CMakeLists.txt | 9 + .../Babylon/Polyfills/DataUrl.h | 25 +++ Polyfills/URL/Source/DataUrl.cpp | 206 ++++++++++++++++++ Polyfills/URL/Source/URL.cpp | 39 ++++ Tests/UnitTests/Scripts/tests.ts | 60 +++++ 7 files changed, 344 insertions(+), 192 deletions(-) create mode 100644 Polyfills/URL/InternalInclude/Babylon/Polyfills/DataUrl.h create mode 100644 Polyfills/URL/Source/DataUrl.cpp diff --git a/Polyfills/Fetch/CMakeLists.txt b/Polyfills/Fetch/CMakeLists.txt index 0fa13e35..79d9fd8c 100644 --- a/Polyfills/Fetch/CMakeLists.txt +++ b/Polyfills/Fetch/CMakeLists.txt @@ -42,7 +42,8 @@ target_include_directories(Fetch target_link_libraries(Fetch PUBLIC JsRuntime PRIVATE arcana - PRIVATE UrlLib) + PRIVATE UrlLib + PRIVATE URLInternal) set_property(TARGET Fetch PROPERTY FOLDER Polyfills) source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp index a7dd3bd2..f89215be 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -20,6 +21,7 @@ namespace Babylon::Polyfills::Internal { + using DataUrlResponse = Babylon::Polyfills::DataUrl::Response; namespace { // Shared state for honoring an AbortSignal passed via init.signal. Co-owned by the "abort" @@ -55,196 +57,6 @@ 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; - } - - template - void ForEachPercentDecodedByte(std::string_view value, TCallback&& callback) - { - 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) - { - callback(static_cast((high << 4) | low)); - index += 2; - continue; - } - } - 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; - } - - 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(std::string_view input) - { - size_t digitCount{}; - size_t paddingCount{}; - bool sawPadding{}; - ForEachPercentDecodedByte(input, [&](uint8_t value) { - if (IsAsciiWhitespace(value)) - { - return; - } - if (value == '=') - { - sawPadding = true; - ++paddingCount; - 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) - { - throw std::runtime_error{"fetch: invalid base64 data URL"}; - } - - std::vector decoded(digitCount / 4 * 3 + digitCount % 4 * 3 / 4); - size_t outputIndex{}; - uint32_t accumulator{}; - size_t availableBits{}; - ForEachPercentDecodedByte(input, [&](uint8_t value) { - if (IsAsciiWhitespace(value) || value == '=') - { - return; - } - accumulator = (accumulator << 6) | static_cast(Base64DigitValue(value)); - availableBits += 6; - if (availableBits >= 8) - { - availableBits -= 8; - decoded[outputIndex++] = 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 = base64 ? ForgivingBase64Decode(payload) : PercentDecode(payload); - - 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 @@ -502,7 +314,7 @@ namespace Babylon::Polyfills::Internal std::optional dataUrl; try { - dataUrl = ParseDataUrl(url); + dataUrl = Babylon::Polyfills::DataUrl::Parse(url); } catch (const std::runtime_error& error) { diff --git a/Polyfills/URL/CMakeLists.txt b/Polyfills/URL/CMakeLists.txt index 54026df6..0f701ffc 100644 --- a/Polyfills/URL/CMakeLists.txt +++ b/Polyfills/URL/CMakeLists.txt @@ -1,6 +1,8 @@ set(SOURCES ${SOURCES} "Include/Babylon/Polyfills/URL.h" + "InternalInclude/Babylon/Polyfills/DataUrl.h" + "Source/DataUrl.cpp" "Source/URL.cpp" "Source/URL.h" "Source/URLSearchParams.cpp" @@ -9,6 +11,7 @@ set(SOURCES add_library(URL ${SOURCES}) target_include_directories(URL PUBLIC "Include") +target_include_directories(URL PRIVATE "InternalInclude") target_link_libraries(URL PRIVATE BlobInternal @@ -17,3 +20,9 @@ target_link_libraries(URL set_property(TARGET URL PROPERTY FOLDER Polyfills) source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) + +# Seam for other polyfills in this repo that need the data: URL decoder (the fetch polyfill keeps its +# synchronous fast path on it). Not part of the public "Include" directory, like BlobInternal. +add_library(URLInternal INTERFACE) +target_include_directories(URLInternal INTERFACE "InternalInclude") +target_link_libraries(URLInternal INTERFACE URL) diff --git a/Polyfills/URL/InternalInclude/Babylon/Polyfills/DataUrl.h b/Polyfills/URL/InternalInclude/Babylon/Polyfills/DataUrl.h new file mode 100644 index 00000000..1129a35e --- /dev/null +++ b/Polyfills/URL/InternalInclude/Babylon/Polyfills/DataUrl.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace Babylon::Polyfills::DataUrl +{ + // A decoded data: URL (RFC 2397 as processed by the WHATWG Fetch "data: URL processor"). + struct Response + { + std::string contentType; + std::string url; + std::vector body; + }; + + // Returns nullopt when `url` is not a data: URL. Throws std::runtime_error when it is one but + // is malformed (invalid forgiving-base64, etc.), so callers can surface a TypeError / network + // error as their contract requires. + std::optional BABYLON_API Parse(std::string_view url); +} diff --git a/Polyfills/URL/Source/DataUrl.cpp b/Polyfills/URL/Source/DataUrl.cpp new file mode 100644 index 00000000..0af40241 --- /dev/null +++ b/Polyfills/URL/Source/DataUrl.cpp @@ -0,0 +1,206 @@ +// Lifted from the fetch polyfill so every UrlLib consumer (XMLHttpRequest, texture and asset +// loaders, fetch) shares one data: URL decoder; see Polyfills/URL/Source/URL.cpp for the scheme +// resolver that registers it. +#include + +#include +#include +#include +#include + +namespace Babylon::Polyfills::DataUrl +{ + namespace + { + bool EqualsIgnoreCase(std::string_view a, std::string_view b) + { + return std::equal(a.begin(), a.end(), b.begin(), b.end(), [](unsigned char l, unsigned char r) { + return std::tolower(l) == std::tolower(r); + }); + } + + 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; + } + + template + void ForEachPercentDecodedByte(std::string_view value, TCallback&& callback) + { + 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) + { + callback(static_cast((high << 4) | low)); + index += 2; + continue; + } + } + 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; + } + + 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(std::string_view input) + { + size_t digitCount{}; + size_t paddingCount{}; + bool sawPadding{}; + ForEachPercentDecodedByte(input, [&](uint8_t value) { + if (IsAsciiWhitespace(value)) + { + return; + } + if (value == '=') + { + sawPadding = true; + ++paddingCount; + 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) + { + throw std::runtime_error{"fetch: invalid base64 data URL"}; + } + + std::vector decoded(digitCount / 4 * 3 + digitCount % 4 * 3 / 4); + size_t outputIndex{}; + uint32_t accumulator{}; + size_t availableBits{}; + ForEachPercentDecodedByte(input, [&](uint8_t value) { + if (IsAsciiWhitespace(value) || value == '=') + { + return; + } + accumulator = (accumulator << 6) | static_cast(Base64DigitValue(value)); + availableBits += 6; + if (availableBits >= 8) + { + availableBits -= 8; + decoded[outputIndex++] = static_cast(accumulator >> availableBits); + accumulator &= (uint32_t{1} << availableBits) - 1; + } + }); + return decoded; + } + } + + std::optional Parse(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 = base64 ? ForgivingBase64Decode(payload) : PercentDecode(payload); + + return Response{ + std::move(mediaType), + std::string{url.substr(0, fragment)}, + std::move(decodedPayload)}; + } +} diff --git a/Polyfills/URL/Source/URL.cpp b/Polyfills/URL/Source/URL.cpp index 3d2b9d7d..8a3c969e 100644 --- a/Polyfills/URL/Source/URL.cpp +++ b/Polyfills/URL/Source/URL.cpp @@ -1,8 +1,11 @@ #include "URL.h" #include #include +#include #include +#include #include +#include #include #include #include @@ -65,6 +68,41 @@ namespace // minted by URL.createObjectURL uniformly through the transport layer, instead of each polyfill // re-implementing the store lookup. A revoked (or never-registered) URL reports handled=false, // which UrlLib surfaces as a status-0 network error -- matching browser behavior. + // data: URLs are standard (RFC 2397; WHATWG Fetch "data: URL processor"), so resolve them for + // every UrlLib consumer -- XMLHttpRequest, image/texture/asset loaders, fetch -- rather than + // only inside the fetch polyfill (#67). A malformed data: URL is a network error per spec, which + // UrlLib expresses as `handled == false`. + void EnsureDataSchemeResolverRegistered() + { + static std::once_flag onceFlag; + std::call_once(onceFlag, [] { + UrlLib::UrlRequest::RegisterSchemeResolver("data", [](const std::string& url) { + UrlLib::UrlSchemeResolverResult result; + std::optional parsed; + try + { + parsed = Babylon::Polyfills::DataUrl::Parse(url); + } + catch (const std::runtime_error&) + { + return result; + } + if (!parsed) + { + return result; + } + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + result.statusText = "OK"; + result.contentType = std::move(parsed->contentType); + auto bytes = std::make_shared>(parsed->body.size()); + std::transform(parsed->body.begin(), parsed->body.end(), bytes->begin(), [](uint8_t value) { return static_cast(value); }); + result.body = std::move(bytes); + return result; + }); + }); + } + void EnsureBlobSchemeResolverRegistered() { static std::once_flag onceFlag; @@ -461,6 +499,7 @@ namespace Babylon::Polyfills::Internal void URL::Initialize(Napi::Env env) { EnsureBlobSchemeResolverRegistered(); + EnsureDataSchemeResolverRegistered(); if (env.Global().Get(JS_URL_CONSTRUCTOR_NAME).IsUndefined()) { diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 39b1e8c5..0296aa3b 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -661,6 +661,66 @@ describe("fetch", function () { expect(new Uint8Array(await blob.arrayBuffer())).to.eql(new Uint8Array([0, 1, 2, 255])); }); + // The data: scheme is resolved by UrlLib for every consumer, not only fetch (#67): XMLHttpRequest + // is the path Babylon.js' asset and texture loaders take. + it("resolves data: URLs through XMLHttpRequest for asset-style loads", async function () { + const xhr = await new Promise((resolve) => { + const req = new XMLHttpRequest(); + req.open("GET", "data:text/plain;charset=utf-8,hello%20world"); + req.addEventListener("loadend", () => resolve(req)); + req.send(); + }); + expect(xhr.status).to.equal(200); + expect(xhr.responseText).to.equal("hello world"); + expect((xhr.getResponseHeader("content-type") || "").toLowerCase()).to.contain("text/plain"); + }); + + it("decodes base64 data: URLs into an ArrayBuffer through XMLHttpRequest", async function () { + const xhr = await new Promise((resolve) => { + const req = new XMLHttpRequest(); + req.responseType = "arraybuffer"; + req.open("GET", "data:application/octet-stream;base64,AQID"); + req.addEventListener("loadend", () => resolve(req)); + req.send(); + }); + expect(xhr.status).to.equal(200); + expect(Array.from(new Uint8Array(xhr.response))).to.deep.equal([1, 2, 3]); + }); + + it("surfaces a malformed data: URL as a network error", async function () { + const xhr = await new Promise((resolve) => { + const req = new XMLHttpRequest(); + req.open("GET", "data:text/plain;base64,@@@"); + req.addEventListener("loadend", () => resolve(req)); + req.send(); + }); + expect(xhr.status).to.equal(0); + }); + + it("round-trips a data: URL through Blob and an object URL", async function () { + const response = await fetch("data:application/octet-stream;base64,AQID"); + const blob = await response.blob(); + expect(blob.size).to.equal(3); + expect(blob.type).to.equal("application/octet-stream"); + const objectUrl = URL.createObjectURL(blob); + try { + const again = await fetch(objectUrl); + expect(Array.from(new Uint8Array(await again.arrayBuffer()))).to.deep.equal([1, 2, 3]); + } finally { + URL.revokeObjectURL(objectUrl); + } + }); + + it("streams a base64 gzip data: URL through DecompressionStream", async function () { + if (typeof DecompressionStream !== "function") { + this.skip(); // the compression polyfill is optional + } + // gzip.compress(b"hello gzip", mtime=0) + const response = await fetch("data:application/gzip;base64,H4sIAAAAAAAC/8tIzcnJV0ivyiwAABlq0t8KAAAA"); + const decompressed = response.body.pipeThrough(new DecompressionStream("gzip")); + expect(await new Response(decompressed).text()).to.equal("hello gzip"); + }); + // 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", []], From e5a96638fe10a35b1e91654160bb44805149fa63 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 13 Sep 2026 14:01:49 -0500 Subject: [PATCH 19/22] Address review: define _GNU_SOURCE before system headers, bound the fallback stack limit by the thread's own size, define globalThis non-enumerable via JsDefineProperty (cherry picked from commit fef8cb97602e2d214d680aeedf9be29babbb376e) --- Core/AppRuntime/Source/AppRuntime_QuickJS.cpp | 15 +++++++++++--- Core/Node-API/Source/env_chakra.cc | 20 ++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp b/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp index d9ab764c..b1828b7f 100644 --- a/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp +++ b/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp @@ -1,3 +1,8 @@ +// pthread_getattr_np is declared by glibc only under the GNU feature set; gnu++20 predefines it, +// but this translation unit should not depend on the language dialect for a system declaration. +#if defined(__linux__) && !defined(_GNU_SOURCE) +#define _GNU_SOURCE +#endif #include "AppRuntime.h" #include @@ -43,7 +48,7 @@ namespace Babylon size_t JavaScriptStackLimit() { constexpr size_t Margin{256 * 1024}; - constexpr size_t Fallback{512 * 1024}; + constexpr size_t Fallback{256 * 1024}; size_t threadStack{}; #if defined(_WIN32) ULONG_PTR low{}; @@ -66,9 +71,13 @@ namespace Babylon pthread_attr_destroy(&attributes); } #endif - if (threadStack <= Margin) + if (threadStack == 0) + { + return Fallback; // unknown: conservative, well under any plausible thread + } + if (threadStack <= 2 * Margin) { - return Fallback; + return threadStack / 2; // a known small stack must not get a limit larger than itself } return std::min(threadStack - Margin, static_cast(JS_DEFAULT_STACK_SIZE) * 8); } diff --git a/Core/Node-API/Source/env_chakra.cc b/Core/Node-API/Source/env_chakra.cc index 641467df..1351525e 100644 --- a/Core/Node-API/Source/env_chakra.cc +++ b/Core/Node-API/Source/env_chakra.cc @@ -34,7 +34,25 @@ namespace Napi ThrowIfFailed(JsGetValueType(existingGlobalThis, &existingType)); if (existingType == JsUndefined) { - ThrowIfFailed(JsSetProperty(global, propertyId, global, true)); + // { value: globalThis, writable: true, enumerable: false, configurable: true } -- the + // spec's own data property; plain assignment would make it enumerable. + JsValueRef descriptor; + ThrowIfFailed(JsCreateObject(&descriptor)); + JsValueRef trueValue; + ThrowIfFailed(JsGetTrueValue(&trueValue)); + JsValueRef falseValue; + ThrowIfFailed(JsGetFalseValue(&falseValue)); + JsPropertyIdRef descriptorPropertyId; + ThrowIfFailed(JsGetPropertyIdFromName(L"value", &descriptorPropertyId)); + ThrowIfFailed(JsSetProperty(descriptor, descriptorPropertyId, global, true)); + ThrowIfFailed(JsGetPropertyIdFromName(L"writable", &descriptorPropertyId)); + ThrowIfFailed(JsSetProperty(descriptor, descriptorPropertyId, trueValue, true)); + ThrowIfFailed(JsGetPropertyIdFromName(L"enumerable", &descriptorPropertyId)); + ThrowIfFailed(JsSetProperty(descriptor, descriptorPropertyId, falseValue, true)); + ThrowIfFailed(JsGetPropertyIdFromName(L"configurable", &descriptorPropertyId)); + ThrowIfFailed(JsSetProperty(descriptor, descriptorPropertyId, trueValue, true)); + bool defined; + ThrowIfFailed(JsDefineProperty(global, propertyId, descriptor, &defined)); } ThrowIfFailed(JsGetPropertyIdFromName(L"Object", &propertyId)); JsValueRef object; From f5b43de58eb01c0d3333419e656d9414b57ed909 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 13 Sep 2026 14:09:10 -0500 Subject: [PATCH 20/22] QuickJS: keep from defining min/max (Win32 QuickJS build) (cherry picked from commit 5cd790d5f628e1eb13057a1006260e91dc8a3dfb) --- Core/AppRuntime/Source/AppRuntime_QuickJS.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp b/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp index b1828b7f..621f6310 100644 --- a/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp +++ b/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp @@ -27,6 +27,9 @@ #include #endif #if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX // would otherwise define min/max macros that break std::min below +#endif #include #endif @@ -79,7 +82,7 @@ namespace Babylon { return threadStack / 2; // a known small stack must not get a limit larger than itself } - return std::min(threadStack - Margin, static_cast(JS_DEFAULT_STACK_SIZE) * 8); + return (std::min)(threadStack - Margin, static_cast(JS_DEFAULT_STACK_SIZE) * 8); } } From 6e662d7c55907028c3831b735e05d58ec90df514 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 13 Sep 2026 14:11:56 -0500 Subject: [PATCH 21/22] QuickJS: measure the JS stack budget from the current stack pointer to the thread's base (the nominal size under-budgeted the Android runtime thread: depth-128 recursion hit the limit) (cherry picked from commit d1ca32e239b60f5b71e20e0656f673c10fa711ba) --- Core/AppRuntime/Source/AppRuntime_QuickJS.cpp | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp b/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp index 621f6310..b2757f0c 100644 --- a/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp +++ b/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp @@ -48,41 +48,49 @@ namespace Babylon // (SIGSEGV) before QuickJS can raise "InternalError: stack overflow". Derive the limit // from the thread that actually runs the runtime instead, keeping a margin for the native // frames QuickJS and the host add between the check and the guard page. + // Bytes of C stack below the current frame, measured from the actual stack pointer to the + // thread's stack base, minus a margin for the native frames QuickJS and the host add between + // the check and the guard page. Measuring from the current position (rather than trusting + // the nominal size) also absorbs whatever the host already consumed above this call. size_t JavaScriptStackLimit() { - constexpr size_t Margin{256 * 1024}; + constexpr size_t Margin{96 * 1024}; constexpr size_t Fallback{256 * 1024}; - size_t threadStack{}; + volatile char marker{}; // the address of a local is a portable stack-pointer proxy (MSVC has no __builtin_frame_address) + const auto here = reinterpret_cast(&marker); + uintptr_t base{}; #if defined(_WIN32) ULONG_PTR low{}; ULONG_PTR high{}; GetCurrentThreadStackLimits(&low, &high); - threadStack = static_cast(high - low); + base = static_cast(low); #elif defined(__APPLE__) - // pthread_getattr_np is a GNU/bionic extension; Apple exposes the size directly. - threadStack = pthread_get_stacksize_np(pthread_self()); + // pthread_getattr_np is a GNU/bionic extension; Apple exposes the bounds directly. + const auto top = reinterpret_cast(pthread_get_stackaddr_np(pthread_self())); + base = top - pthread_get_stacksize_np(pthread_self()); #else pthread_attr_t attributes; if (pthread_getattr_np(pthread_self(), &attributes) == 0) { - void* base{}; + void* address{}; size_t size{}; - if (pthread_attr_getstack(&attributes, &base, &size) == 0) + if (pthread_attr_getstack(&attributes, &address, &size) == 0) { - threadStack = size; + base = reinterpret_cast(address); } pthread_attr_destroy(&attributes); } #endif - if (threadStack == 0) + if (base == 0 || here <= base) { return Fallback; // unknown: conservative, well under any plausible thread } - if (threadStack <= 2 * Margin) + const size_t usable = here - base; + if (usable <= 2 * Margin) { - return threadStack / 2; // a known small stack must not get a limit larger than itself + return usable / 2; // a known small stack must not get a limit larger than itself } - return (std::min)(threadStack - Margin, static_cast(JS_DEFAULT_STACK_SIZE) * 8); + return (std::min)(usable - Margin, static_cast(JS_DEFAULT_STACK_SIZE) * 8); } } From 5d7db945e0b569e133725f97db91d0c2ce9c637a Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Sun, 13 Sep 2026 15:30:10 -0500 Subject: [PATCH 22/22] QuickJS: nested 8 MiB Android thread + 6 MiB JS stack limit (supersedes the measured-limit approach; emulator-validated depth-128) --- Core/AppRuntime/Source/AppRuntime_QuickJS.cpp | 166 +++++++++--------- 1 file changed, 82 insertions(+), 84 deletions(-) diff --git a/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp b/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp index b2757f0c..ca219021 100644 --- a/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp +++ b/Core/AppRuntime/Source/AppRuntime_QuickJS.cpp @@ -1,8 +1,3 @@ -// pthread_getattr_np is declared by glibc only under the GNU feature set; gnu++20 predefines it, -// but this translation unit should not depend on the language dialect for a system declaration. -#if defined(__linux__) && !defined(_GNU_SOURCE) -#define _GNU_SOURCE -#endif #include "AppRuntime.h" #include @@ -23,107 +18,110 @@ #pragma warning(pop) #endif -#if !defined(_WIN32) +#include +#include + +#if defined(__ANDROID__) #include -#endif -#if defined(_WIN32) -#ifndef NOMINMAX -#define NOMINMAX // would otherwise define min/max macros that break std::min below -#endif -#include +#include +#include +#include #endif -#include -#include -#include - namespace Babylon { namespace { - // QuickJS guards against JS recursion by comparing the C stack pointer against - // stack_top - stack_size, where stack_size defaults to JS_DEFAULT_STACK_SIZE (1 MiB in - // quickjs-ng). That is also the default size of a non-main thread on Android and Windows, - // so on those threads the limit sits below the real guard page: deep recursion faults - // (SIGSEGV) before QuickJS can raise "InternalError: stack overflow". Derive the limit - // from the thread that actually runs the runtime instead, keeping a margin for the native - // frames QuickJS and the host add between the check and the guard page. - // Bytes of C stack below the current frame, measured from the actual stack pointer to the - // thread's stack base, minus a margin for the native frames QuickJS and the host add between - // the check and the guard page. Measuring from the current position (rather than trusting - // the nominal size) also absorbs whatever the host already consumed above this call. - size_t JavaScriptStackLimit() + // Runs the QuickJS environment on the calling thread. QuickJS's interpreter recurses in C + // (one JS_CallInternal frame per JS call), so deep JS call stacks need a comparably deep C + // stack; QuickJS's own limit (JS_DEFAULT_STACK_SIZE, 1 MiB) guards against overrun. + // jsStackLimit: value for JS_SetMaxStackSize, or 0 to keep QuickJS's default (1 MiB). A + // non-default limit is only safe when the caller guarantees a stack large enough to hold it + // below the guard page -- see the Android nested-thread path below. + void RunQuickJSEnvironment(AppRuntime& appRuntime, void (AppRuntime::*run)(Napi::Env), size_t jsStackLimit) { - constexpr size_t Margin{96 * 1024}; - constexpr size_t Fallback{256 * 1024}; - volatile char marker{}; // the address of a local is a portable stack-pointer proxy (MSVC has no __builtin_frame_address) - const auto here = reinterpret_cast(&marker); - uintptr_t base{}; -#if defined(_WIN32) - ULONG_PTR low{}; - ULONG_PTR high{}; - GetCurrentThreadStackLimits(&low, &high); - base = static_cast(low); -#elif defined(__APPLE__) - // pthread_getattr_np is a GNU/bionic extension; Apple exposes the bounds directly. - const auto top = reinterpret_cast(pthread_get_stackaddr_np(pthread_self())); - base = top - pthread_get_stacksize_np(pthread_self()); -#else - pthread_attr_t attributes; - if (pthread_getattr_np(pthread_self(), &attributes) == 0) + JSRuntime* runtime = JS_NewRuntime(); + if (!runtime) { - void* address{}; - size_t size{}; - if (pthread_attr_getstack(&attributes, &address, &size) == 0) - { - base = reinterpret_cast(address); - } - pthread_attr_destroy(&attributes); + throw std::runtime_error{"Failed to create QuickJS runtime"}; } -#endif - if (base == 0 || here <= base) + if (jsStackLimit != 0) { - return Fallback; // unknown: conservative, well under any plausible thread + JS_SetMaxStackSize(runtime, jsStackLimit); } - const size_t usable = here - base; - if (usable <= 2 * Margin) + + JSContext* context = JS_NewContext(runtime); + if (!context) { - return usable / 2; // a known small stack must not get a limit larger than itself + JS_FreeRuntime(runtime); + throw std::runtime_error{"Failed to create QuickJS context"}; } - return (std::min)(usable - Margin, static_cast(JS_DEFAULT_STACK_SIZE) * 8); + + { + Napi::Env env = Napi::Attach(context); + (appRuntime.*run)(env); + Napi::Detach(env); + } + + JS_FreeContext(context); + JS_FreeRuntime(runtime); } } void AppRuntime::RunEnvironmentTier(const char* /*executablePath*/) { - // Create the runtime. - JSRuntime* runtime = JS_NewRuntime(); - if (!runtime) - { - throw std::runtime_error{"Failed to create QuickJS runtime"}; - } - JS_SetMaxStackSize(runtime, JavaScriptStackLimit()); - - // Create the context. - JSContext* context = JS_NewContext(runtime); - if (!context) - { - JS_FreeRuntime(runtime); - throw std::runtime_error{"Failed to create QuickJS context"}; - } +#if defined(__ANDROID__) + // bionic gives this worker thread ~1 MiB of stack, at or below QuickJS's default 1 MiB + // recursion limit -- so deep-but-legal JS recursion faults the guard page (SIGSEGV) before + // QuickJS can raise a catchable "stack overflow", while clamping the limit below 1 MiB + // instead rejects call depths that every other engine (and desktop QuickJS on its ~8 MiB + // stack) accepts. Run the environment on a nested thread with a desktop-sized stack so + // QuickJS's raised limit sits safely below the guard page and deep recursion fits. The + // stack must be generous because connectedAndroidTest is an unoptimized Debug build, whose + // JS_CallInternal frames are several times larger than a release build's -- depth-128 + // recursion (which release QuickJS clears within the 1 MiB default) needs well over 1 MiB + // here, so QuickJS's default limit would still reject it on any thread size. + constexpr size_t NestedStackSize{8 * 1024 * 1024}; + constexpr size_t JsStackLimit{6 * 1024 * 1024}; // < NestedStackSize guard; > debug depth-128 need + std::function body{[this] { RunQuickJSEnvironment(*this, &AppRuntime::Run, JsStackLimit); }}; + std::exception_ptr thrown{}; + auto payload = std::make_pair(&body, &thrown); + auto trampoline = [](void* arg) -> void* { + auto* p = static_cast*, std::exception_ptr*>*>(arg); + try + { + (*p->first)(); + } + catch (...) + { + *p->second = std::current_exception(); + } + return nullptr; + }; - // Use the context within a scope. + pthread_attr_t attr; + if (pthread_attr_init(&attr) == 0) { - Napi::Env env = Napi::Attach(context); - - Run(env); - - Napi::Detach(env); + pthread_attr_setstacksize(&attr, NestedStackSize); + pthread_t tid{}; + const int created = pthread_create(&tid, &attr, trampoline, &payload); + pthread_attr_destroy(&attr); + if (created == 0) + { + pthread_join(tid, nullptr); + if (thrown) + { + std::rethrow_exception(thrown); + } + return; + } } - - // Destroy the context and runtime. - JS_FreeContext(context); - JS_FreeRuntime(runtime); + // Thread creation failed: fall back to the current (small) worker thread, where only + // QuickJS's default limit is safe. + RunQuickJSEnvironment(*this, &AppRuntime::Run, 0); +#else + RunQuickJSEnvironment(*this, &AppRuntime::Run, 0); +#endif } void AppRuntime::ShutdownEnvironment(Napi::Env)