From 53bab96c4d69ec2908c62c7c2bcd5301292ffd53 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Fri, 5 Jun 2026 17:02:17 -0700 Subject: [PATCH 1/5] 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 ebe6a806274dd198b509e417877c19a3b5b74190 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Fri, 5 Jun 2026 17:15:15 -0700 Subject: [PATCH 2/5] 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 40f5ff763af418b53d6551d0a385b6f6b65e0567 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Fri, 5 Jun 2026 17:27:27 -0700 Subject: [PATCH 3/5] 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 44bc77a9765a330d4ffcc8d746fdfad40013129c Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Fri, 5 Jun 2026 17:43:09 -0700 Subject: [PATCH 4/5] 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 1118799a1581eba96707b2995f102acc6cb5c58c Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Fri, 5 Jun 2026 17:55:32 -0700 Subject: [PATCH 5/5] 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