From adc467aff85f82acb93c19a92ae27feed1cc7016 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Fri, 5 Jun 2026 17:02:17 -0700 Subject: [PATCH 1/6] Add native fetch() polyfill Implements the WHATWG fetch() API as a native polyfill under Polyfills/Fetch/, mirroring the XMLHttpRequest polyfill layout. Like XMLHttpRequest, it is built on top of the platform-specific transports in UrlLib, so libcurl/WinHTTP/etc. behavior is identical between the two. fetch(input, init) returns a Promise that resolves to a Response-like object exposing ok/status/statusText/url/redirected/type/bodyUsed, a case-insensitive headers accessor (get/has/forEach), the body accessors text()/arrayBuffer()/json()/blob(), and clone(). Per the fetch spec, the promise only rejects on transport-level failures; a completed request with a non-2xx status (e.g. 404) still resolves with ok === false. The body is fully buffered before the promise resolves, so the body accessors may be called more than once (a deliberate lenient deviation from the spec's single-use semantics). Methods are limited to GET/POST and request bodies to strings, matching the underlying UrlLib transport. Wired behind the JSRUNTIMEHOST_POLYFILL_FETCH option (UrlLib is now fetched when either XMLHttpRequest or Fetch is enabled) and covered by new unit tests. Closes #98. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CMakeLists.txt | 3 +- Polyfills/CMakeLists.txt | 4 + Polyfills/Fetch/CMakeLists.txt | 17 + .../Fetch/Include/Babylon/Polyfills/Fetch.h | 9 + Polyfills/Fetch/Readme.md | 30 ++ Polyfills/Fetch/Source/Fetch.cpp | 396 ++++++++++++++++++ Polyfills/Fetch/Source/Fetch.h | 11 + Tests/UnitTests/Assets/sample.json | 8 + Tests/UnitTests/CMakeLists.txt | 1 + Tests/UnitTests/Scripts/tests.ts | 86 ++++ Tests/UnitTests/Shared/Shared.cpp | 2 + 11 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 Polyfills/Fetch/CMakeLists.txt create mode 100644 Polyfills/Fetch/Include/Babylon/Polyfills/Fetch.h create mode 100644 Polyfills/Fetch/Readme.md create mode 100644 Polyfills/Fetch/Source/Fetch.cpp create mode 100644 Polyfills/Fetch/Source/Fetch.h create mode 100644 Tests/UnitTests/Assets/sample.json diff --git a/CMakeLists.txt b/CMakeLists.txt index d830dd88..b30bd502 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,6 +80,7 @@ option(JSRUNTIMEHOST_CORE_SCRIPTLOADER "Include JsRuntimeHost Core ScriptLoader" option(JSRUNTIMEHOST_POLYFILL_CONSOLE "Include JsRuntimeHost Polyfill Console." ON) option(JSRUNTIMEHOST_POLYFILL_SCHEDULING "Include JsRuntimeHost Polyfill Scheduling." ON) option(JSRUNTIMEHOST_POLYFILL_XMLHTTPREQUEST "Include JsRuntimeHost Polyfill XMLHttpRequest." ON) +option(JSRUNTIMEHOST_POLYFILL_FETCH "Include JsRuntimeHost Polyfill fetch." ON) option(JSRUNTIMEHOST_POLYFILL_URL "Include JsRuntimeHost Polyfill URL and URLSearchParams." ON) option(JSRUNTIMEHOST_POLYFILL_ABORT_CONTROLLER "Include JsRuntimeHost Polyfills AbortController and AbortSignal." ON) option(JSRUNTIMEHOST_POLYFILL_WEBSOCKET "Include JsRuntimeHost Polyfill WebSocket." ON) @@ -140,7 +141,7 @@ endif() FetchContent_MakeAvailable_With_Message(arcana.cpp) set_property(TARGET arcana PROPERTY FOLDER Dependencies) -if(JSRUNTIMEHOST_POLYFILL_XMLHTTPREQUEST) +if(JSRUNTIMEHOST_POLYFILL_XMLHTTPREQUEST OR JSRUNTIMEHOST_POLYFILL_FETCH) FetchContent_MakeAvailable_With_Message(UrlLib) set_property(TARGET UrlLib PROPERTY FOLDER Dependencies) endif() diff --git a/Polyfills/CMakeLists.txt b/Polyfills/CMakeLists.txt index ed9ea443..a44765fb 100644 --- a/Polyfills/CMakeLists.txt +++ b/Polyfills/CMakeLists.txt @@ -10,6 +10,10 @@ if(JSRUNTIMEHOST_POLYFILL_XMLHTTPREQUEST) add_subdirectory(XMLHttpRequest) endif() +if(JSRUNTIMEHOST_POLYFILL_FETCH) + add_subdirectory(Fetch) +endif() + if(JSRUNTIMEHOST_POLYFILL_URL) add_subdirectory(URL) endif() diff --git a/Polyfills/Fetch/CMakeLists.txt b/Polyfills/Fetch/CMakeLists.txt new file mode 100644 index 00000000..62b46175 --- /dev/null +++ b/Polyfills/Fetch/CMakeLists.txt @@ -0,0 +1,17 @@ +set(SOURCES + "Include/Babylon/Polyfills/Fetch.h" + "Source/Fetch.h" + "Source/Fetch.cpp") + +add_library(Fetch ${SOURCES}) +warnings_as_errors(Fetch) + +target_include_directories(Fetch PUBLIC "Include") + +target_link_libraries(Fetch + PUBLIC JsRuntime + PRIVATE arcana + PRIVATE UrlLib) + +set_property(TARGET Fetch PROPERTY FOLDER Polyfills) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Polyfills/Fetch/Include/Babylon/Polyfills/Fetch.h b/Polyfills/Fetch/Include/Babylon/Polyfills/Fetch.h new file mode 100644 index 00000000..e11fae9c --- /dev/null +++ b/Polyfills/Fetch/Include/Babylon/Polyfills/Fetch.h @@ -0,0 +1,9 @@ +#pragma once + +#include +#include + +namespace Babylon::Polyfills::Fetch +{ + void BABYLON_API Initialize(Napi::Env env); +} diff --git a/Polyfills/Fetch/Readme.md b/Polyfills/Fetch/Readme.md new file mode 100644 index 00000000..5528774a --- /dev/null +++ b/Polyfills/Fetch/Readme.md @@ -0,0 +1,30 @@ +# 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. + +```js +const response = await fetch("https://example.com/data.json"); +if (response.ok) { + const data = await response.json(); +} +``` + +## 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()` + +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. + +`blob()` requires the `Blob` polyfill to be initialized; otherwise the returned promise rejects. + +## Local files +Like `XMLHttpRequest`, `fetch()` supports loading local resources: +* `file:///` loads from an absolute path +* `app:///` loads from a path relative to the current program or package depending on platform + +## Other things to be aware of +* Only `GET` and `POST` methods are supported (a `UrlLib` limitation shared with `XMLHttpRequest`). +* Only string request bodies are supported. +* Consistent with the fetch spec, the promise rejects only on transport-level failures. A completed request with a non-`2xx` status (e.g. `404`) still resolves, with `response.ok === false`. diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp new file mode 100644 index 00000000..4a72faa1 --- /dev/null +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -0,0 +1,396 @@ +#include "Fetch.h" + +#include +#include +#include + +#include + +#include +#include +#include +#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 url; + std::vector> headers; + std::shared_ptr> body; + }; + + std::string ToLower(std::string value) + { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; + } + + // fetch only resolves for GET and POST because the underlying UrlLib transport supports nothing else. + UrlLib::UrlMethod ParseMethod(const std::string& method) + { + const std::string upper = [&]() { + std::string result = method; + std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return static_cast(std::toupper(c)); }); + return result; + }(); + + if (upper == "GET") + { + return UrlLib::UrlMethod::Get; + } + if (upper == "POST") + { + return UrlLib::UrlMethod::Post; + } + + throw std::runtime_error{"Unsupported fetch method: " + method + " (only GET and POST are supported)"}; + } + + const char* StatusText(int statusCode) + { + switch (statusCode) + { + case 200: return "OK"; + case 201: return "Created"; + case 202: return "Accepted"; + case 204: return "No Content"; + case 206: return "Partial Content"; + case 301: return "Moved Permanently"; + case 302: return "Found"; + case 304: return "Not Modified"; + case 307: return "Temporary Redirect"; + case 308: return "Permanent Redirect"; + case 400: return "Bad Request"; + case 401: return "Unauthorized"; + case 403: return "Forbidden"; + case 404: return "Not Found"; + case 405: return "Method Not Allowed"; + case 408: return "Request Timeout"; + case 409: return "Conflict"; + case 429: return "Too Many Requests"; + case 500: return "Internal Server Error"; + case 502: return "Bad Gateway"; + case 503: return "Service Unavailable"; + case 504: return "Gateway Timeout"; + default: return ""; + } + } + + std::optional FindHeader(const ResponseData& data, const std::string& name) + { + const std::string lowerName = ToLower(name); + for (const auto& header : data.headers) + { + if (ToLower(header.first) == lowerName) + { + return header.second; + } + } + return std::nullopt; + } + + void ApplyRequestHeaders(UrlLib::UrlRequest& request, const Napi::Value& headers) + { + if (headers.IsUndefined() || headers.IsNull()) + { + return; + } + + Napi::Env env = headers.Env(); + + // Array of [name, value] pairs. + if (headers.IsArray()) + { + 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; + } + + if (headers.IsObject()) + { + const auto object = headers.As(); + + // Headers / Map instances expose forEach((value, key) => ...). + const auto forEach = object.Get("forEach"); + if (forEach.IsFunction()) + { + 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 BuildResponse(Napi::Env env, const std::shared_ptr& data); + + 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)}); + } + return env.Undefined(); + }, "forEach")); + + return headers; + } + + Napi::Object BuildResponse(Napi::Env env, const std::shared_ptr& data) + { + 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, StatusText(data->statusCode))); + 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); + + 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); + + const auto parts = Napi::Array::New(env, 1); + parts.Set(0u, bytes); + + const auto 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; + } + } + + namespace Fetch + { + void Initialize(Napi::Env env) + { + static constexpr auto JS_FETCH_NAME = "fetch"; + + auto fetchFunction = Napi::Function::New(env, [](const Napi::CallbackInfo& info) -> Napi::Value { + Napi::Env env = info.Env(); + const auto deferred = Napi::Promise::Deferred::New(env); + + try + { + if (info.Length() < 1) + { + throw std::runtime_error{"fetch requires at least 1 argument"}; + } + + // Resolve the request URL from a string, a Request-like object with a 'url', or anything stringifiable. + std::string url; + const Napi::Value input = info[0]; + if (input.IsString()) + { + url = input.As().Utf8Value(); + } + else if (input.IsObject() && input.As().Get("url").IsString()) + { + url = input.As().Get("url").As().Utf8Value(); + } + else + { + url = input.ToString().Utf8Value(); + } + + UrlLib::UrlMethod method = UrlLib::UrlMethod::Get; + std::optional body; + Napi::Value headers = env.Undefined(); + + if (info.Length() > 1 && info[1].IsObject()) + { + const auto init = info[1].As(); + + const auto methodValue = init.Get("method"); + if (methodValue.IsString()) + { + method = ParseMethod(methodValue.As().Utf8Value()); + } + + const auto bodyValue = init.Get("body"); + if (bodyValue.IsString()) + { + body = bodyValue.As().Utf8Value(); + } + else if (!bodyValue.IsUndefined() && !bodyValue.IsNull()) + { + throw std::runtime_error{"fetch: only string request bodies are supported"}; + } + + headers = init.Get("headers"); + } + + auto request = std::make_shared(); + request->Open(method, url); + request->ResponseType(UrlLib::UrlResponseType::Buffer); + ApplyRequestHeaders(*request, headers); + if (body) + { + request->SetRequestBody(std::move(*body)); + } + + // arcana::task::then binds the scheduler and cancellation by non-const reference, so they must be lvalues. + JsRuntimeScheduler scheduler{JsRuntime::GetFromJavaScript(env)}; + request->SendAsync().then(scheduler, arcana::cancellation::none(), + [deferred, request, env](const arcana::expected& result) { + const int status = static_cast(request->StatusCode()); + + // Per the WHATWG fetch spec, only transport-level failures reject. A completed + // request with a non-2xx status (e.g. 404) still resolves with response.ok === false. + // A status of 0 indicates the transport never produced a response (network error). + if (result.has_error() || status == 0) + { + deferred.Reject(Napi::Error::New(env, "fetch: network request failed").Value()); + return; + } + + auto data = std::make_shared(); + data->statusCode = status; + data->url = std::string{request->ResponseUrl()}; + for (const auto& header : request->GetAllResponseHeaders()) + { + data->headers.emplace_back(header.first, header.second); + } + const auto responseBuffer = request->ResponseBuffer(); + data->body = std::make_shared>(responseBuffer.begin(), responseBuffer.end()); + + deferred.Resolve(BuildResponse(env, data)); + }); + } + catch (const Napi::Error& error) + { + deferred.Reject(error.Value()); + } + catch (const std::exception& error) + { + deferred.Reject(Napi::Error::New(env, std::string{"fetch: "} + error.what()).Value()); + } + + return deferred.Promise(); + }, JS_FETCH_NAME); + + if (env.Global().Get(JS_FETCH_NAME).IsUndefined()) + { + env.Global().Set(JS_FETCH_NAME, fetchFunction); + } + + JsRuntime::NativeObject::GetFromJavaScript(env).Set(JS_FETCH_NAME, fetchFunction); + } + } +} + +namespace Babylon::Polyfills::Fetch +{ + void BABYLON_API Initialize(Napi::Env env) + { + Internal::Fetch::Initialize(env); + } +} diff --git a/Polyfills/Fetch/Source/Fetch.h b/Polyfills/Fetch/Source/Fetch.h new file mode 100644 index 00000000..2a025f3c --- /dev/null +++ b/Polyfills/Fetch/Source/Fetch.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace Babylon::Polyfills::Internal +{ + namespace Fetch + { + void Initialize(Napi::Env env); + } +} diff --git a/Tests/UnitTests/Assets/sample.json b/Tests/UnitTests/Assets/sample.json new file mode 100644 index 00000000..6b798733 --- /dev/null +++ b/Tests/UnitTests/Assets/sample.json @@ -0,0 +1,8 @@ +{ + "name": "fetch-polyfill-test", + "value": 42, + "nested": { + "enabled": true, + "items": [1, 2, 3] + } +} diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index bc4ebfb5..bfba4bd3 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -55,6 +55,7 @@ target_link_libraries(UnitTests PRIVATE URL PRIVATE UrlLib PRIVATE XMLHttpRequest + PRIVATE Fetch PRIVATE WebSocket PRIVATE gtest_main PRIVATE Foundation diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index fc81d74b..70ae367d 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -238,6 +238,92 @@ describe("XMLHTTPRequest", function () { }); }); +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.ok).to.equal(true); + expect(response.status).to.equal(200); + }); + + it("should resolve (not reject) with ok=false and status=404 for a resource that does not exist", async function () { + const response = await fetch("https://github.com/babylonJS/BabylonNative404"); + expect(response.ok).to.equal(false); + expect(response.status).to.equal(404); + }); + + it("should expose statusText", async function () { + const response = await fetch("https://github.com/babylonJS/BabylonNative404"); + expect(response.statusText).to.equal("Not Found"); + }); + + it("text() should return the body as a string", async function () { + const response = await fetch("app:///Scripts/symlink_target.js"); + expect(await response.text()).to.equal("var symlink_target_js = true;"); + }); + + 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))); + expect(new Uint8Array(await response.arrayBuffer())).to.eql(expected); + }); + + it("json() should parse a JSON body", async function () { + const response = await fetch("app:///Assets/sample.json"); + const json = await response.json(); + expect(json.name).to.equal("fetch-polyfill-test"); + expect(json.value).to.equal(42); + expect(json.nested.items).to.eql([1, 2, 3]); + }); + + it("json() should reject when the body is not valid JSON", async function () { + const response = await fetch("app:///Scripts/symlink_target.js"); + let rejected = false; + try { + await response.json(); + } catch { + rejected = true; + } + expect(rejected).to.equal(true); + }); + + it("blob() should return a Blob with the body bytes", async function () { + const response = await fetch("app:///Scripts/symlink_target.js"); + const blob = await response.blob(); + expect(blob.size).to.equal("var symlink_target_js = true;".length); + expect(await blob.text()).to.equal("var symlink_target_js = true;"); + }); + + it("headers.get() should be case-insensitive and headers.has() should work", async function () { + const response = await fetch("https://github.com/"); + expect(response.headers.has("Content-Type")).to.equal(true); + expect(response.headers.get("CONTENT-TYPE")).to.equal(response.headers.get("content-type")); + }); + + it("clone() should produce an independently readable response", async function () { + const response = await fetch("app:///Scripts/symlink_target.js"); + const clone = response.clone(); + expect(await response.text()).to.equal("var symlink_target_js = true;"); + expect(await clone.text()).to.equal("var symlink_target_js = true;"); + }); + + it("should accept a method in the init object", async function () { + const response = await fetch("https://github.com/", { method: "GET" }); + expect(response.status).to.equal(200); + }); + + it("should reject when no arguments are provided", async function () { + let rejected = false; + try { + await (fetch as any)(); + } catch { + rejected = true; + } + expect(rejected).to.equal(true); + }); +}); + describe("setTimeout", function () { this.timeout(1000); diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index 9ca01bc9..b6488368 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -83,6 +84,7 @@ 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::Blob::Initialize(env); Babylon::Polyfills::File::Initialize(env); Babylon::Polyfills::TextDecoder::Initialize(env); From 83b72298306df29ad648136599ce61a18e3b02d0 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Fri, 5 Jun 2026 17:15:15 -0700 Subject: [PATCH 2/6] Fetch: fix blob() on JSC/JSI and add include - blob() now detects the Blob polyfill via IsUndefined()/IsNull() instead of IsFunction(). Some JavaScriptCore/JSI builds classify constructor functions as typeof 'object', so IsFunction() incorrectly rejected even when the Blob polyfill was installed, failing the blob() test across all JSC/JSI CI jobs (matches the existing File polyfill workaround). - Add for std::tolower/std::toupper used by ToLower/StatusText, which were relying on transitive includes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Polyfills/Fetch/Source/Fetch.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp index 4a72faa1..9f8f9860 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -235,8 +236,12 @@ namespace Babylon::Polyfills::Internal Napi::Env env = info.Env(); const auto deferred = Napi::Promise::Deferred::New(env); + // Use IsUndefined()/IsNull() rather than IsFunction() to detect the Blob + // polyfill: some JavaScriptCore/JSI builds classify constructor functions as + // typeof 'object', so napi_typeof reports napi_object and IsFunction() would + // incorrectly reject even when the Blob polyfill is installed. const auto blobConstructor = env.Global().Get("Blob"); - if (!blobConstructor.IsFunction()) + if (blobConstructor.IsUndefined() || blobConstructor.IsNull()) { deferred.Reject(Napi::Error::New(env, "fetch: Blob is not available in this environment").Value()); return deferred.Promise(); From 03fd91f846ba98a155777352345154896801ae25 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Fri, 5 Jun 2026 17:27:27 -0700 Subject: [PATCH 3/6] Fetch: fix dangling scheduler reference crashing async completion arcana's task::then() captures the scheduler by reference and invokes it on the UrlLib worker thread when the request completes, which happens after the synchronous fetch() call has already returned. The previous stack-local JsRuntimeScheduler was therefore destroyed before the continuation ran, leaving arcana with a dangling reference. On Windows/Chakra this happened to survive, but on clang/libc++, JSC, JSI and Android it dereferenced freed memory and aborted with 'std::system_error: Invalid argument' inside JsRuntime::Dispatch (caught by ASAN as a stack-use-after-return). Heap-allocate the scheduler in a shared_ptr and co-own it from the continuation callable so it stays alive until the request completes, mirroring how XMLHttpRequest keeps its scheduler alive as a member. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Polyfills/Fetch/Source/Fetch.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp index 9f8f9860..0c8b7778 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -342,10 +342,13 @@ namespace Babylon::Polyfills::Internal request->SetRequestBody(std::move(*body)); } - // arcana::task::then binds the scheduler and cancellation by non-const reference, so they must be lvalues. - JsRuntimeScheduler scheduler{JsRuntime::GetFromJavaScript(env)}; - request->SendAsync().then(scheduler, arcana::cancellation::none(), - [deferred, request, env](const arcana::expected& result) { + // arcana::task::then captures the scheduler by reference (see arcana task.h) and + // invokes it on the worker thread when the request completes -- after this fetch() + // call has returned. A stack-local scheduler would therefore dangle. Heap-allocate + // it and co-own it from the continuation so it stays alive until the request finishes. + auto scheduler = std::make_shared(JsRuntime::GetFromJavaScript(env)); + request->SendAsync().then(*scheduler, arcana::cancellation::none(), + [deferred, request, env, scheduler](const arcana::expected& result) { const int status = static_cast(request->StatusCode()); // Per the WHATWG fetch spec, only transport-level failures reject. A completed From 47eca742b269fc5a0f4cf17b5d23a807a45b0d89 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Fri, 5 Jun 2026 17:43:09 -0700 Subject: [PATCH 4/6] Fetch: make blob() Napi parts/options objects non-const for JSI Node-API-JSI declares Napi::Object::Set / Napi::Array::Set as non-const member functions, so calling Set() on 'const auto' instances failed to compile (C2663) on the *_JSI and Android targets while compiling fine under Chakra/V8. Declare the blob() 'parts' array and 'options' object as non-const, matching the other builder objects in this file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Polyfills/Fetch/Source/Fetch.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp index 0c8b7778..32381132 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -254,10 +254,10 @@ namespace Babylon::Polyfills::Internal } const auto bytes = Napi::Uint8Array::New(env, data->body->size(), arrayBuffer, 0); - const auto parts = Napi::Array::New(env, 1); + Napi::Array parts = Napi::Array::New(env, 1); parts.Set(0u, bytes); - const auto options = Napi::Object::New(env); + Napi::Object options = Napi::Object::New(env); const auto contentType = FindHeader(*data, "content-type"); options.Set("type", Napi::String::New(env, contentType.value_or(""))); From fbe94db236d8486c3dfc988058018291e97b0f8c Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Fri, 5 Jun 2026 17:55:32 -0700 Subject: [PATCH 5/6] Fetch: link Fetch into the Android UnitTestsJNI target The Android UnitTests app uses its own CMakeLists with an explicit polyfill link list, separate from the desktop Tests/UnitTests target. Without Fetch there, Shared.cpp failed to find on Android_JSC and Android_V8. Add 'PRIVATE Fetch' to mirror the desktop test target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt index 4da23c8f..0af5caa8 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -38,6 +38,7 @@ target_link_libraries(UnitTestsJNI PRIVATE URL PRIVATE UrlLib PRIVATE XMLHttpRequest + PRIVATE Fetch PRIVATE WebSocket PRIVATE gtest_main PRIVATE Blob From ac2019fb742fdbe8a772a7d922c827d437eef467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87?= Date: Tue, 9 Jun 2026 14:15:02 -0700 Subject: [PATCH 6/6] Fetch: settle promise on continuation throw; non-allocating header/method compares Address review feedback on the native fetch() polyfill: - Fix a hang: a throw inside the SendAsync continuation (e.g. from BuildResponse) was captured into the discarded task result and dropped, so the promise never settled and await fetch(...) hung. Split into two chained .then() continuations -- the first does the work and resolves, the second surfaces any error as a promise rejection. The scheduler is co-owned by the second continuation so it outlives the request. - Replace ToLower with a non-allocating EqualsIgnoreCase comparator used by both ParseMethod and FindHeader (removes per-header allocations). - Make ResponseData::body a plain std::vector instead of a shared_ptr; the struct is always held by shared_ptr, so the inner pointer only added an allocation and falsely implied clones get independent bodies. - Remove the unnecessary BuildResponse forward declaration. - Collapse the two fetch() catch blocks into a single catch (...) via the exception_ptr Error overload, removing the message-prefix asymmetry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Polyfills/Fetch/Source/Fetch.cpp | 114 +++++++++++++++---------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp index 32381132..618dab8c 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -25,29 +26,24 @@ namespace Babylon::Polyfills::Internal int statusCode{}; std::string url; std::vector> headers; - std::shared_ptr> body; + std::vector body; }; - std::string ToLower(std::string value) + bool EqualsIgnoreCase(std::string_view a, std::string_view b) { - std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); - return value; + return std::equal(a.begin(), a.end(), b.begin(), b.end(), [](unsigned char l, unsigned char r) { + return std::tolower(l) == std::tolower(r); + }); } // fetch only resolves for GET and POST because the underlying UrlLib transport supports nothing else. UrlLib::UrlMethod ParseMethod(const std::string& method) { - const std::string upper = [&]() { - std::string result = method; - std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return static_cast(std::toupper(c)); }); - return result; - }(); - - if (upper == "GET") + if (EqualsIgnoreCase(method, "GET")) { return UrlLib::UrlMethod::Get; } - if (upper == "POST") + if (EqualsIgnoreCase(method, "POST")) { return UrlLib::UrlMethod::Post; } @@ -85,12 +81,11 @@ namespace Babylon::Polyfills::Internal } } - std::optional FindHeader(const ResponseData& data, const std::string& name) + std::optional FindHeader(const ResponseData& data, std::string_view name) { - const std::string lowerName = ToLower(name); for (const auto& header : data.headers) { - if (ToLower(header.first) == lowerName) + if (EqualsIgnoreCase(header.first, name)) { return header.second; } @@ -151,8 +146,6 @@ namespace Babylon::Polyfills::Internal } } - Napi::Object BuildResponse(Napi::Env env, const std::shared_ptr& data); - Napi::Object BuildHeaders(Napi::Env env, const std::shared_ptr& data) { Napi::Object headers = Napi::Object::New(env); @@ -198,7 +191,7 @@ namespace Babylon::Polyfills::Internal 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()}; + std::string text{reinterpret_cast(data->body.data()), data->body.size()}; deferred.Resolve(Napi::String::New(env, text)); return deferred.Promise(); }, "text")); @@ -206,10 +199,10 @@ namespace Babylon::Polyfills::Internal 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()) + const auto arrayBuffer = Napi::ArrayBuffer::New(env, data->body.size()); + if (!data->body.empty()) { - std::memcpy(arrayBuffer.Data(), data->body->data(), data->body->size()); + std::memcpy(arrayBuffer.Data(), data->body.data(), data->body.size()); } deferred.Resolve(arrayBuffer); return deferred.Promise(); @@ -218,7 +211,7 @@ namespace Babylon::Polyfills::Internal 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()}; + 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 @@ -247,12 +240,12 @@ namespace Babylon::Polyfills::Internal return deferred.Promise(); } - const auto arrayBuffer = Napi::ArrayBuffer::New(env, data->body->size()); - if (!data->body->empty()) + const auto arrayBuffer = Napi::ArrayBuffer::New(env, data->body.size()); + if (!data->body.empty()) { - std::memcpy(arrayBuffer.Data(), data->body->data(), data->body->size()); + std::memcpy(arrayBuffer.Data(), data->body.data(), data->body.size()); } - const auto bytes = Napi::Uint8Array::New(env, data->body->size(), arrayBuffer, 0); + const auto bytes = Napi::Uint8Array::New(env, data->body.size(), arrayBuffer, 0); Napi::Array parts = Napi::Array::New(env, 1); parts.Set(0u, bytes); @@ -347,39 +340,46 @@ namespace Babylon::Polyfills::Internal // call has returned. A stack-local scheduler would therefore dangle. Heap-allocate // it and co-own it from the continuation so it stays alive until the request finishes. auto scheduler = std::make_shared(JsRuntime::GetFromJavaScript(env)); - request->SendAsync().then(*scheduler, arcana::cancellation::none(), - [deferred, request, env, scheduler](const arcana::expected& result) { - const int status = static_cast(request->StatusCode()); - - // Per the WHATWG fetch spec, only transport-level failures reject. A completed - // request with a non-2xx status (e.g. 404) still resolves with response.ok === false. - // A status of 0 indicates the transport never produced a response (network error). - if (result.has_error() || status == 0) - { - deferred.Reject(Napi::Error::New(env, "fetch: network request failed").Value()); - return; - } - - auto data = std::make_shared(); - data->statusCode = status; - data->url = std::string{request->ResponseUrl()}; - for (const auto& header : request->GetAllResponseHeaders()) - { - data->headers.emplace_back(header.first, header.second); - } - const auto responseBuffer = request->ResponseBuffer(); - data->body = std::make_shared>(responseBuffer.begin(), responseBuffer.end()); - - deferred.Resolve(BuildResponse(env, data)); - }); - } - catch (const Napi::Error& error) - { - deferred.Reject(error.Value()); + request->SendAsync() + .then(*scheduler, arcana::cancellation::none(), + [deferred, request, env](const arcana::expected& result) { + const int status = static_cast(request->StatusCode()); + + // Per the WHATWG fetch spec, only transport-level failures reject. A completed + // request with a non-2xx status (e.g. 404) still resolves with response.ok === false. + // A status of 0 indicates the transport never produced a response (network error). + if (result.has_error() || status == 0) + { + throw std::runtime_error{"fetch: network request failed"}; + } + + auto data = std::make_shared(); + data->statusCode = status; + data->url = std::string{request->ResponseUrl()}; + for (const auto& header : request->GetAllResponseHeaders()) + { + data->headers.emplace_back(header.first, header.second); + } + const auto responseBuffer = request->ResponseBuffer(); + data->body.assign(responseBuffer.begin(), responseBuffer.end()); + + deferred.Resolve(BuildResponse(env, data)); + }) + .then(*scheduler, arcana::cancellation::none(), + [deferred, env, scheduler](const arcana::expected& result) { + // A throw from the continuation above (e.g. a network failure or a JS + // exception while building the response) lands here as an error result; + // surface it as a promise rejection so await fetch(...) settles. The + // scheduler is co-owned here so it outlives the in-flight request. + if (result.has_error()) + { + deferred.Reject(Napi::Error::New(env, result.error()).Value()); + } + }); } - catch (const std::exception& error) + catch (...) { - deferred.Reject(Napi::Error::New(env, std::string{"fetch: "} + error.what()).Value()); + deferred.Reject(Napi::Error::New(env, std::current_exception()).Value()); } return deferred.Promise();