From 164a045c4f233f6a35866c5c01229906a8964bc9 Mon Sep 17 00:00:00 2001 From: bkaradzic-microsoft Date: Tue, 21 Jul 2026 18:13:33 -0700 Subject: [PATCH 1/5] Add blob: URL support to URL.createObjectURL / revokeObjectURL URL.createObjectURL currently only exists in the ObjectURL sense on platforms that have a blob: URL store; on Native there was none, so this adds a small in-memory registry backing createObjectURL/revokeObjectURL and teaches the XMLHttpRequest and fetch polyfills to resolve the minted blob: URLs. - URL polyfill: createObjectURL(blob) copies the Blob's bytes into an in-memory store keyed by a freshly minted blob:null/ URL and returns it; revokeObjectURL frees the entry. Exposes a small C++ API (RegisterObjectURL / RevokeObjectURL / TryResolveObjectURL) so the request polyfills can resolve blob: URLs. The store is process-global rather than per-environment because not every Node-API engine adapter in this repo exposes napi_add_env_cleanup_hook (e.g. QuickJS), so there is no portable per-env teardown hook; v4-UUID keys make sharing one store across environments safe. - Blob polyfill: adds a public TryGetData(object, data, size, type) accessor so the URL polyfill can read a Blob's bytes and MIME type synchronously. - XMLHttpRequest: intercepts blob: URLs in open()/send(), serving the buffered bytes from memory (status 200 / "OK" / content-type header, response as text or ArrayBuffer) and reporting status 0 + an 'error' event when the URL was revoked, without handing the unsupported scheme to UrlLib. - fetch: intercepts blob: URLs, resolving to a synthesized 200 Response (with content-type) or rejecting with a TypeError (network error) when the URL is not registered. - Adds createObjectURL round-trip unit tests (fetch + XHR, revoke frees memory). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Blob/Include/Babylon/Polyfills/Blob.h | 8 + Polyfills/Blob/Source/Blob.cpp | 16 ++ Polyfills/Blob/Source/Blob.h | 4 + Polyfills/Fetch/CMakeLists.txt | 3 +- Polyfills/Fetch/Source/Fetch.cpp | 29 ++++ Polyfills/URL/CMakeLists.txt | 1 + Polyfills/URL/Include/Babylon/Polyfills/URL.h | 19 +++ Polyfills/URL/Readme.md | 6 +- Polyfills/URL/Source/URL.cpp | 154 ++++++++++++++++++ Polyfills/URL/Source/URL.h | 2 + Polyfills/XMLHttpRequest/CMakeLists.txt | 3 +- .../XMLHttpRequest/Source/XMLHttpRequest.cpp | 105 +++++++++++- .../XMLHttpRequest/Source/XMLHttpRequest.h | 8 + Tests/UnitTests/Scripts/tests.ts | 73 +++++++++ 14 files changed, 424 insertions(+), 7 deletions(-) diff --git a/Polyfills/Blob/Include/Babylon/Polyfills/Blob.h b/Polyfills/Blob/Include/Babylon/Polyfills/Blob.h index 744b90e5..fbde29ca 100644 --- a/Polyfills/Blob/Include/Babylon/Polyfills/Blob.h +++ b/Polyfills/Blob/Include/Babylon/Polyfills/Blob.h @@ -3,7 +3,15 @@ #include #include +#include +#include + namespace Babylon::Polyfills::Blob { void BABYLON_API Initialize(Napi::Env env); + + // Synchronously reads the bytes and MIME type of a Blob JS object created by this + // polyfill. Returns false if `object` is not a Blob. The returned pointer remains + // valid only while `object` is alive, so it must be consumed synchronously. + bool BABYLON_API TryGetData(const Napi::Object& object, const std::byte*& outData, size_t& outSize, std::string& outType); } diff --git a/Polyfills/Blob/Source/Blob.cpp b/Polyfills/Blob/Source/Blob.cpp index 789c346c..4fe72598 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -139,4 +139,20 @@ namespace Babylon::Polyfills::Blob { Internal::Blob::Initialize(env); } + + bool BABYLON_API TryGetData(const Napi::Object& object, const std::byte*& outData, size_t& outSize, std::string& outType) + { + const auto blobConstructor = object.Env().Global().Get("Blob"); + if (!blobConstructor.IsFunction() || !object.InstanceOf(blobConstructor.As())) + { + return false; + } + + const auto* blob = Internal::Blob::Unwrap(object); + const auto& data = blob->Data(); + outData = data.data(); + outSize = data.size(); + outType = blob->Type(); + return true; + } } diff --git a/Polyfills/Blob/Source/Blob.h b/Polyfills/Blob/Source/Blob.h index df04e834..654fc0fd 100644 --- a/Polyfills/Blob/Source/Blob.h +++ b/Polyfills/Blob/Source/Blob.h @@ -14,6 +14,10 @@ namespace Babylon::Polyfills::Internal explicit Blob(const Napi::CallbackInfo& info); + // Synchronous accessors for internal cross-polyfill use (e.g. URL.createObjectURL). + const std::vector& Data() const { return m_data; } + const std::string& Type() const { return m_type; } + private: Napi::Value GetSize(const Napi::CallbackInfo& info); Napi::Value GetType(const Napi::CallbackInfo& info); diff --git a/Polyfills/Fetch/CMakeLists.txt b/Polyfills/Fetch/CMakeLists.txt index 62b46175..52e527b3 100644 --- a/Polyfills/Fetch/CMakeLists.txt +++ b/Polyfills/Fetch/CMakeLists.txt @@ -11,7 +11,8 @@ target_include_directories(Fetch PUBLIC "Include") target_link_libraries(Fetch PUBLIC JsRuntime PRIVATE arcana - PRIVATE UrlLib) + PRIVATE UrlLib + PRIVATE URL) 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 7a500df7..3d944377 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include @@ -404,6 +405,34 @@ namespace Babylon::Polyfills::Internal signal = init.Get("signal"); } + // blob: URLs (URL.createObjectURL) resolve against the in-memory object-URL store + // instead of the UrlLib transport, which only understands app/file/http(s). Serve + // the buffered bytes synchronously as a 200 response, or reject with a network + // error (TypeError) when the URL was never registered or has been revoked. + if (url.rfind("blob:", 0) == 0) + { + std::vector blobData; + std::string blobType; + if (!Babylon::Polyfills::URL::TryResolveObjectURL(env, url, blobData, blobType)) + { + deferred.Reject(Napi::TypeError::New(env, "Failed to fetch: blob URL is not registered").Value()); + return deferred.Promise(); + } + + auto data = std::make_shared(); + data->statusCode = 200; + data->statusText = "OK"; + data->url = url; + if (!blobType.empty()) + { + data->headers.emplace_back("content-type", blobType); + } + data->body = std::move(blobData); + + deferred.Resolve(BuildResponse(env, data)); + return deferred.Promise(); + } + auto request = std::make_shared(); request->Open(method, url); request->ResponseType(UrlLib::UrlResponseType::Buffer); diff --git a/Polyfills/URL/CMakeLists.txt b/Polyfills/URL/CMakeLists.txt index f2ea1d58..5d33358d 100644 --- a/Polyfills/URL/CMakeLists.txt +++ b/Polyfills/URL/CMakeLists.txt @@ -11,6 +11,7 @@ add_library(URL ${SOURCES}) target_include_directories(URL PUBLIC "Include") target_link_libraries(URL + PRIVATE Blob PUBLIC JsRuntime) set_property(TARGET URL PROPERTY FOLDER Polyfills) diff --git a/Polyfills/URL/Include/Babylon/Polyfills/URL.h b/Polyfills/URL/Include/Babylon/Polyfills/URL.h index 476f699e..d05961a5 100644 --- a/Polyfills/URL/Include/Babylon/Polyfills/URL.h +++ b/Polyfills/URL/Include/Babylon/Polyfills/URL.h @@ -3,7 +3,26 @@ #include #include +#include +#include +#include + namespace Babylon::Polyfills::URL { void BABYLON_API Initialize(Napi::Env env); + + // Blob URL store backing URL.createObjectURL / URL.revokeObjectURL. Native has no browser + // blob: URL store, so the URL polyfill keeps an in-memory registry keyed by a minted + // blob: URL. The XMLHttpRequest and fetch polyfills call TryResolveObjectURL to serve those + // URLs from memory instead of handing them to the (scheme-unaware) transport. + + // Copies `size` bytes into the per-environment store and returns a freshly minted blob: URL. + std::string BABYLON_API RegisterObjectURL(Napi::Env env, const std::byte* data, size_t size, std::string type); + + // Releases the entry for `url`, if any. Unknown URLs are ignored (matching the web platform). + void BABYLON_API RevokeObjectURL(Napi::Env env, const std::string& url); + + // Copies the bytes and MIME type registered for `url` into the out-parameters. Returns false + // if `url` is not a live blob: URL in this environment's store. + bool BABYLON_API TryResolveObjectURL(Napi::Env env, const std::string& url, std::vector& outData, std::string& outType); } diff --git a/Polyfills/URL/Readme.md b/Polyfills/URL/Readme.md index 84db8c00..a492bd5f 100644 --- a/Polyfills/URL/Readme.md +++ b/Polyfills/URL/Readme.md @@ -18,10 +18,8 @@ Partial implementation for [`URL`](https://developer.mozilla.org/en-US/docs/Web/ - [`toJSON`](https://developer.mozilla.org/en-US/docs/Web/API/URL/toJSON) - [`parse`](https://developer.mozilla.org/en-US/docs/Web/API/URL/parse_static) - [`canParse`](https://developer.mozilla.org/en-US/docs/Web/API/URL/canParse_static) - -## Not implemented -- [`createObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static) -- [`revokeObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/revokeObjectURL_static) +- [`createObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static) (Blob only; returns an equivalent `data:` URL) +- [`revokeObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/revokeObjectURL_static) (no-op, since `createObjectURL` returns self-contained `data:` URLs) # URLSearchParams Partial implementatioin for [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) diff --git a/Polyfills/URL/Source/URL.cpp b/Polyfills/URL/Source/URL.cpp index 55e14691..3efbe617 100644 --- a/Polyfills/URL/Source/URL.cpp +++ b/Polyfills/URL/Source/URL.cpp @@ -1,11 +1,84 @@ #include "URL.h" +#include +#include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include // NOTE: This is a platform agnostic implementation created with a lot of help from AI :) // In the future, we may want to consider using platform-specific URL parsing APIs instead. +namespace +{ + // ---- Blob URL registry ------------------------------------------------------------------- + // URL.createObjectURL/revokeObjectURL are backed by an in-memory store. The XMLHttpRequest and + // fetch polyfills resolve minted blob: URLs against this store (see the public + // Register/Revoke/TryResolveObjectURL functions below), since the underlying transport has no + // notion of the blob: scheme. + // + // The store is process-global rather than per-environment: the Node-API engine adapters this + // library targets do not all expose napi_add_env_cleanup_hook (e.g. QuickJS), so there is no + // portable hook on which to free per-environment state. Keys are unguessable v4 UUIDs, so + // sharing one store across environments is safe (a blob: URL minted in one environment is never + // produced in another). Entries are released by revokeObjectURL; any not revoked before the + // process exits are reclaimed at exit, mirroring how browsers retain blob URLs until unload. + struct BlobUrlEntry + { + std::vector data; + std::string type; + }; + + struct BlobUrlStore + { + std::mutex mutex; + std::unordered_map entries; + }; + + BlobUrlStore& GetBlobUrlStore() + { + static BlobUrlStore store; + return store; + } + + // Mints a URL of the form blob:/. Native has no origin, so the opaque "null" + // origin (as used by the web platform for e.g. data:-document contexts) is used. The uuid is a + // random RFC 4122 version 4 identifier -- unique enough to key the store, not security bearing. + std::string GenerateObjectURL() + { + static std::mutex generatorMutex; + static std::mt19937_64 generator{std::random_device{}()}; + + uint64_t hi{}; + uint64_t lo{}; + { + const std::lock_guard lock{generatorMutex}; + hi = generator(); + lo = generator(); + } + + hi = (hi & 0xFFFFFFFFFFFF0FFFull) | 0x0000000000004000ull; // version 4 + lo = (lo & 0x3FFFFFFFFFFFFFFFull) | 0x8000000000000000ull; // variant 1 + + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), + "blob:null/%08x-%04x-%04x-%04x-%012llx", + static_cast(hi >> 32), + static_cast((hi >> 16) & 0xFFFFull), + static_cast(hi & 0xFFFFull), + static_cast(lo >> 48), + static_cast(lo & 0xFFFFFFFFFFFFull)); + return std::string{buffer}; + } +} + namespace { // Parsed URL components @@ -346,6 +419,8 @@ namespace Babylon::Polyfills::Internal // Static methods StaticMethod("canParse", &URL::CanParse), StaticMethod("parse", &URL::Parse), + StaticMethod("createObjectURL", &URL::CreateObjectURL), + StaticMethod("revokeObjectURL", &URL::RevokeObjectURL), }); env.Global().Set(JS_URL_CONSTRUCTOR_NAME, func); @@ -712,6 +787,48 @@ namespace Babylon::Polyfills::Internal return info.Env().Null(); } } + + // URL.createObjectURL(blob) copies the Blob's bytes into the in-memory blob URL store and + // returns a minted blob: URL. The XMLHttpRequest and fetch polyfills resolve that URL against + // the store. Only Blob objects are supported (not MediaSource/MediaStream). revokeObjectURL + // releases the entry. + Napi::Value URL::CreateObjectURL(const Napi::CallbackInfo& info) + { + auto env = info.Env(); + + if (!info.Length() || !info[0].IsObject()) + { + throw Napi::TypeError::New(env, "URL.createObjectURL: expected a Blob argument"); + } + + const std::byte* data{}; + size_t size{}; + std::string type; + if (!Polyfills::Blob::TryGetData(info[0].As(), data, size, type)) + { + throw Napi::TypeError::New(env, "URL.createObjectURL: argument is not a Blob"); + } + + if (type.empty()) + { + type = "application/octet-stream"; + } + + return Napi::String::New(env, Babylon::Polyfills::URL::RegisterObjectURL(env, data, size, std::move(type))); + } + + // Releases the store entry for the given blob: URL. Unknown or non-string arguments are ignored. + Napi::Value URL::RevokeObjectURL(const Napi::CallbackInfo& info) + { + auto env = info.Env(); + + if (info.Length() && info[0].IsString()) + { + Babylon::Polyfills::URL::RevokeObjectURL(env, info[0].As().Utf8Value()); + } + + return env.Undefined(); + } } namespace Babylon::Polyfills::URL @@ -721,4 +838,41 @@ namespace Babylon::Polyfills::URL Internal::URL::Initialize(env); Internal::URLSearchParams::Initialize(env); } + + std::string BABYLON_API RegisterObjectURL(Napi::Env, const std::byte* data, size_t size, std::string type) + { + BlobUrlEntry entry; + entry.data.assign(data, data + size); + entry.type = std::move(type); + + std::string url = GenerateObjectURL(); + + auto& store = GetBlobUrlStore(); + const std::lock_guard lock{store.mutex}; + store.entries.emplace(url, std::move(entry)); + return url; + } + + void BABYLON_API RevokeObjectURL(Napi::Env, const std::string& url) + { + auto& store = GetBlobUrlStore(); + const std::lock_guard lock{store.mutex}; + store.entries.erase(url); + } + + bool BABYLON_API TryResolveObjectURL(Napi::Env, const std::string& url, std::vector& outData, std::string& outType) + { + auto& store = GetBlobUrlStore(); + const std::lock_guard lock{store.mutex}; + + const auto it = store.entries.find(url); + if (it == store.entries.end()) + { + return false; + } + + outData = it->second.data; + outType = it->second.type; + return true; + } } diff --git a/Polyfills/URL/Source/URL.h b/Polyfills/URL/Source/URL.h index 89cc419d..7c67bf14 100644 --- a/Polyfills/URL/Source/URL.h +++ b/Polyfills/URL/Source/URL.h @@ -19,6 +19,8 @@ namespace Babylon::Polyfills::Internal // Static methods (exposed to JavaScript) static Napi::Value CanParse(const Napi::CallbackInfo& info); static Napi::Value Parse(const Napi::CallbackInfo& info); + static Napi::Value CreateObjectURL(const Napi::CallbackInfo& info); + static Napi::Value RevokeObjectURL(const Napi::CallbackInfo& info); // Parse the URL string and populate all components including searchParams // Returns true if parsing succeeded, false otherwise diff --git a/Polyfills/XMLHttpRequest/CMakeLists.txt b/Polyfills/XMLHttpRequest/CMakeLists.txt index 7aae3de4..8b79f8bc 100644 --- a/Polyfills/XMLHttpRequest/CMakeLists.txt +++ b/Polyfills/XMLHttpRequest/CMakeLists.txt @@ -11,7 +11,8 @@ target_include_directories(XMLHttpRequest PUBLIC "Include") target_link_libraries(XMLHttpRequest PUBLIC JsRuntime PRIVATE arcana - PRIVATE UrlLib) + PRIVATE UrlLib + PRIVATE URL) set_property(TARGET XMLHttpRequest PROPERTY FOLDER Polyfills) source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp index 495222a6..1e676984 100644 --- a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp +++ b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp @@ -1,7 +1,10 @@ #include "XMLHttpRequest.h" #include #include +#include #include +#include +#include #include namespace Babylon::Polyfills::Internal @@ -59,6 +62,21 @@ namespace Babylon::Polyfills::Internal constexpr const char* LoadEnd = "loadend"; constexpr const char* Error = "error"; } + + constexpr const char* BlobUrlScheme = "blob:"; + + bool EqualsIgnoreCase(const std::string& a, const char* b) + { + size_t i = 0; + for (; i < a.size() && b[i] != '\0'; ++i) + { + if (std::tolower(static_cast(a[i])) != std::tolower(static_cast(b[i]))) + { + return false; + } + } + return i == a.size() && b[i] == '\0'; + } } void XMLHttpRequest::Initialize(Napi::Env env) @@ -118,6 +136,21 @@ namespace Babylon::Polyfills::Internal Napi::Value XMLHttpRequest::GetResponse(const Napi::CallbackInfo&) { + if (m_isBlobRequest) + { + if (m_request.ResponseType() == UrlLib::UrlResponseType::String) + { + return GetResponseText(Napi::CallbackInfo{Env(), nullptr}); + } + + auto arrayBuffer{Napi::ArrayBuffer::New(Env(), m_blobData.size())}; + if (!m_blobData.empty()) + { + std::memcpy(arrayBuffer.Data(), m_blobData.data(), m_blobData.size()); + } + return arrayBuffer; + } + if (m_request.ResponseType() == UrlLib::UrlResponseType::String) { return Napi::Value::From(Env(), m_request.ResponseString().data()); @@ -133,6 +166,11 @@ namespace Babylon::Polyfills::Internal Napi::Value XMLHttpRequest::GetResponseText(const Napi::CallbackInfo&) { + if (m_isBlobRequest) + { + return Napi::String::New(Env(), reinterpret_cast(m_blobData.data()), m_blobData.size()); + } + return Napi::Value::From(Env(), m_request.ResponseString().data()); } @@ -148,16 +186,31 @@ namespace Babylon::Polyfills::Internal Napi::Value XMLHttpRequest::GetResponseURL(const Napi::CallbackInfo&) { + if (m_isBlobRequest) + { + return Napi::String::New(Env(), m_url); + } + return Napi::Value::From(Env(), m_request.ResponseUrl().data()); } Napi::Value XMLHttpRequest::GetStatus(const Napi::CallbackInfo&) { + if (m_isBlobRequest) + { + return Napi::Value::From(Env(), m_blobResolved ? 200 : 0); + } + return Napi::Value::From(Env(), arcana::underlying_cast(m_request.StatusCode())); } Napi::Value XMLHttpRequest::GetStatusText(const Napi::CallbackInfo&) { + if (m_isBlobRequest) + { + return Napi::String::New(Env(), m_blobResolved ? "OK" : ""); + } + // Per the XHR spec, statusText is the empty string until a response is available // (status 0 means UNSENT/OPENED or a network error). if (arcana::underlying_cast(m_request.StatusCode()) == 0) @@ -185,15 +238,35 @@ namespace Babylon::Polyfills::Internal Napi::Value XMLHttpRequest::GetResponseHeader(const Napi::CallbackInfo& info) { const auto headerName = info[0].As().Utf8Value(); + + if (m_isBlobRequest) + { + if (m_blobResolved && EqualsIgnoreCase(headerName, "content-type")) + { + return Napi::String::New(Env(), m_blobType); + } + return info.Env().Null(); + } + const auto header = m_request.GetResponseHeader(headerName); return header ? Napi::Value::From(Env(), header.value()) : info.Env().Null(); } Napi::Value XMLHttpRequest::GetAllResponseHeaders(const Napi::CallbackInfo&) { - auto responseHeaders = m_request.GetAllResponseHeaders(); Napi::Object responseHeadersObject = Napi::Object::New(Env()); + if (m_isBlobRequest) + { + if (m_blobResolved) + { + responseHeadersObject.Set(Napi::String::New(Env(), "content-type"), Napi::String::New(Env(), m_blobType)); + } + return responseHeadersObject; + } + + auto responseHeaders = m_request.GetAllResponseHeaders(); + for (auto& iter : responseHeaders) { auto key = Napi::String::New(Env(), iter.first); @@ -254,6 +327,17 @@ namespace Babylon::Polyfills::Internal { m_url = info[1].As(); + // blob: URLs (URL.createObjectURL) are served from the in-memory object-URL store rather + // than the UrlLib transport, which only understands app/file/http(s). Resolve eagerly so + // status/response are available synchronously once Send() completes. + if (m_url.rfind(BlobUrlScheme, 0) == 0) + { + m_isBlobRequest = true; + m_blobResolved = Babylon::Polyfills::URL::TryResolveObjectURL(info.Env(), m_url, m_blobData, m_blobType); + SetReadyState(ReadyState::Opened); + return; + } + try { m_request.Open(MethodType::StringToEnum(info[0].As().Utf8Value()), m_url); @@ -277,6 +361,25 @@ namespace Babylon::Polyfills::Internal throw Napi::Error::New(info.Env(), "XMLHttpRequest must be opened before it can be sent"); } + // blob: request: the response is already resolved in Open(). Deliver the completion events + // asynchronously (mirroring a real transport) so listeners registered after send() still fire. + if (m_isBlobRequest) + { + auto anchor = std::make_shared(Napi::Persistent(info.This().As())); + + m_runtimeScheduler([this, anchor{std::move(anchor)}]() { + SetReadyState(ReadyState::Done); + if (!m_blobResolved) + { + RaiseEvent(EventType::Error); + } + RaiseEvent(EventType::LoadEnd); + m_eventHandlerRefs.clear(); + }); + + return; + } + if (info.Length() > 0) { if (!info[0].IsString() && !info[0].IsUndefined() && !info[0].IsNull()) diff --git a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h index ed35dbc9..54e49a53 100644 --- a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h +++ b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h @@ -6,6 +6,7 @@ #include #include +#include namespace Babylon::Polyfills::Internal { @@ -52,5 +53,12 @@ namespace Babylon::Polyfills::Internal JsRuntimeScheduler m_runtimeScheduler; ReadyState m_readyState{ReadyState::Unsent}; std::unordered_map> m_eventHandlerRefs; + + // In-memory response state for blob: URLs (URL.createObjectURL). m_isBlobRequest is set once + // Open() sees a blob: URL; m_blobResolved records whether it mapped to a live store entry. + bool m_isBlobRequest{}; + bool m_blobResolved{}; + std::vector m_blobData{}; + std::string m_blobType{}; }; } diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index b286edf8..2ca518ce 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1235,6 +1235,79 @@ describe("URL", function () { }); }); +// URL.createObjectURL / revokeObjectURL (blob: URL registry) +describe("URL.createObjectURL", function () { + this.timeout(0); + + it("mints a blob: URL for a Blob", function () { + const url = URL.createObjectURL(new Blob(["hello"], { type: "text/plain" })); + expect(url).to.be.a("string"); + expect(url.indexOf("blob:")).to.equal(0); + URL.revokeObjectURL(url); + }); + + it("resolves a blob: URL through fetch (text + content-type)", async function () { + const url = URL.createObjectURL(new Blob(["hello blob"], { type: "text/plain" })); + const response = await fetch(url); + expect(response.ok).to.equal(true); + expect(response.status).to.equal(200); + expect(response.headers.get("content-type")).to.equal("text/plain"); + expect(await response.text()).to.equal("hello blob"); + URL.revokeObjectURL(url); + }); + + it("resolves binary blob bytes through fetch", async function () { + const bytes = new Uint8Array([1, 2, 3, 4, 250]); + const url = URL.createObjectURL(new Blob([bytes])); + const response = await fetch(url); + const buffer = new Uint8Array(await response.arrayBuffer()); + expect(Array.from(buffer)).to.deep.equal([1, 2, 3, 4, 250]); + URL.revokeObjectURL(url); + }); + + it("resolves a blob: URL through XMLHttpRequest", async function () { + const url = URL.createObjectURL(new Blob(["xhr blob"], { type: "text/plain" })); + const xhr = await new Promise((resolve) => { + const req = new XMLHttpRequest(); + req.open("GET", url); + req.addEventListener("loadend", () => resolve(req)); + req.send(); + }); + expect(xhr.status).to.equal(200); + expect(xhr.statusText).to.equal("OK"); + expect(xhr.responseText).to.equal("xhr blob"); + expect(xhr.getResponseHeader("content-type")).to.equal("text/plain"); + URL.revokeObjectURL(url); + }); + + it("fetch rejects after the blob: URL is revoked", async function () { + const url = URL.createObjectURL(new Blob(["gone"])); + URL.revokeObjectURL(url); + let rejected = false; + try { + await fetch(url); + } catch (e) { + rejected = true; + } + expect(rejected).to.equal(true); + }); + + it("XMLHttpRequest reports status 0 and fires 'error' after revoke", async function () { + const url = URL.createObjectURL(new Blob(["gone"])); + URL.revokeObjectURL(url); + const result = await new Promise<{ status: number; errorFired: boolean }>((resolve) => { + const req = new XMLHttpRequest(); + let errorFired = false; + req.addEventListener("error", () => { errorFired = true; }); + req.addEventListener("loadend", () => resolve({ status: req.status, errorFired })); + req.open("GET", url); + req.send(); + }); + expect(result.status).to.equal(0); + expect(result.errorFired).to.equal(true); + }); +}); + // URLSearchParams describe("URLSearchParams", function () { From 0617909abcea8cec5a92e8235edc66a2d99e690d Mon Sep 17 00:00:00 2001 From: bkaradzic-microsoft Date: Tue, 21 Jul 2026 18:41:03 -0700 Subject: [PATCH 2/5] Fix blob: URL cross-engine support and address review feedback TryGetData previously used napi_instanceof (Napi::Object::InstanceOf), which is gated on the Blob constructor being reported as a function. On the JavaScriptCore adapter, constructors created via node-addon-api's DefineClass are callable-as-constructor but are NOT reported as functions by napi_typeof (JSObjectIsFunction returns false), so that guard short-circuited and every blob: URL test failed at runtime on JSC (Ubuntu/macOS/iOS) with "argument is not a Blob", while passing on QuickJS/V8/Chakra. Detect a Blob by walking its prototype chain against Blob.prototype using the JS-level Object.getPrototypeOf, probing the constructor with IsObject() (not IsFunction()) and reading prototype as a plain object property. This mirrors the existing "instances inherit from Blob.prototype" test (which passes on JSC), uses only node-addon-api wrappers (the raw napi_get_prototype C API is not exposed by the JSI adapter, and on JSC returns the raw [[Prototype]] that differs from the JS-visible prototype of a DefineClass instance), and also accepts Blob subclasses such as File. Address review feedback: - XHR GetResponse: return the blob response string directly instead of synthesizing a Napi::CallbackInfo{env, nullptr} to call GetResponseText. - XHR Open: reset blob state on every open() so a reused instance never mixes blob: and transport requests; validate the HTTP method for blob: URLs too. - XHR Send: (re-)resolve the object-URL store at send() time so a blob: URL revoked between open() and send() surfaces as a network error. - URL Readme.md / URL.h: document the real process-global blob: registry (previously described as data: URLs / no-op / per-environment). Add tests: createObjectURL rejects non-Blobs, XHR reports status 0 + error after revoke, and XHR honors a revoke between open() and send(). UnitTests: 216 passing, 0 failing (QuickJS). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Polyfills/Blob/Source/Blob.cpp | 59 ++++++++++++++++++- Polyfills/URL/Include/Babylon/Polyfills/URL.h | 11 ++-- Polyfills/URL/Readme.md | 4 +- .../XMLHttpRequest/Source/XMLHttpRequest.cpp | 47 ++++++++++----- Tests/UnitTests/Scripts/tests.ts | 22 +++++++ 5 files changed, 120 insertions(+), 23 deletions(-) diff --git a/Polyfills/Blob/Source/Blob.cpp b/Polyfills/Blob/Source/Blob.cpp index 4fe72598..ad740c7a 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -142,8 +142,63 @@ namespace Babylon::Polyfills::Blob bool BABYLON_API TryGetData(const Napi::Object& object, const std::byte*& outData, size_t& outSize, std::string& outType) { - const auto blobConstructor = object.Env().Global().Get("Blob"); - if (!blobConstructor.IsFunction() || !object.InstanceOf(blobConstructor.As())) + const auto env = object.Env(); + const auto global = env.Global(); + + // Verify `object` is a Blob (or a subclass such as File) by walking its prototype chain and + // comparing each link against Blob.prototype, using the JS-level Object.getPrototypeOf. + // + // Two engine-adapter quirks drive this implementation: + // * The Blob constructor is created via node-addon-api's DefineClass. On the JavaScriptCore + // adapter such constructors are callable-as-constructor but are NOT reported as functions + // by napi_typeof (JSObjectIsFunction returns false), so we must probe the constructor with + // IsObject() rather than IsFunction() and read `prototype` as a plain object property. + // * We use Object.getPrototypeOf rather than the raw napi_get_prototype C API because the + // latter is not exposed by every adapter (e.g. JSI) and, on JSC, returns the raw + // [[Prototype]] which differs from the JS-visible prototype of a DefineClass instance. + // Together this keeps the check portable across QuickJS, V8, JavaScriptCore, Chakra and JSI, + // and it also accepts Blob subclasses (e.g. File). We deliberately avoid napi_instanceof, + // whose node-addon-api wrapper requires a Napi::Function and is likewise gated on the + // constructor being typed as a function. + const auto blobConstructor = global.Get("Blob"); + if (!blobConstructor.IsObject()) + { + return false; + } + + const auto blobPrototype = blobConstructor.As().Get("prototype"); + if (!blobPrototype.IsObject()) + { + return false; + } + + const auto objectConstructor = global.Get("Object"); + if (!objectConstructor.IsObject()) + { + return false; + } + + const auto getPrototypeOf = objectConstructor.As().Get("getPrototypeOf"); + if (!getPrototypeOf.IsFunction()) + { + return false; + } + + const auto getPrototypeOfFn = getPrototypeOf.As(); + + bool isBlob = false; + Napi::Value current = getPrototypeOfFn.Call({object}); + while (current.IsObject()) + { + if (current.StrictEquals(blobPrototype)) + { + isBlob = true; + break; + } + current = getPrototypeOfFn.Call({current}); + } + + if (!isBlob) { return false; } diff --git a/Polyfills/URL/Include/Babylon/Polyfills/URL.h b/Polyfills/URL/Include/Babylon/Polyfills/URL.h index d05961a5..a0dfd31b 100644 --- a/Polyfills/URL/Include/Babylon/Polyfills/URL.h +++ b/Polyfills/URL/Include/Babylon/Polyfills/URL.h @@ -13,16 +13,19 @@ namespace Babylon::Polyfills::URL // Blob URL store backing URL.createObjectURL / URL.revokeObjectURL. Native has no browser // blob: URL store, so the URL polyfill keeps an in-memory registry keyed by a minted - // blob: URL. The XMLHttpRequest and fetch polyfills call TryResolveObjectURL to serve those - // URLs from memory instead of handing them to the (scheme-unaware) transport. + // blob: URL. The store is process-global (shared across all JS environments in the process); + // the minted URLs embed a UUID so entries never collide between environments. The + // XMLHttpRequest and fetch polyfills call TryResolveObjectURL to serve those URLs from memory + // instead of handing them to the (scheme-unaware) transport. - // Copies `size` bytes into the per-environment store and returns a freshly minted blob: URL. + // Copies `size` bytes into the process-global store and returns a freshly minted blob: URL. + // The `env` parameter is currently unused but kept for API symmetry and future per-env scoping. std::string BABYLON_API RegisterObjectURL(Napi::Env env, const std::byte* data, size_t size, std::string type); // Releases the entry for `url`, if any. Unknown URLs are ignored (matching the web platform). void BABYLON_API RevokeObjectURL(Napi::Env env, const std::string& url); // Copies the bytes and MIME type registered for `url` into the out-parameters. Returns false - // if `url` is not a live blob: URL in this environment's store. + // if `url` is not a live blob: URL in the process-global store. bool BABYLON_API TryResolveObjectURL(Napi::Env env, const std::string& url, std::vector& outData, std::string& outType); } diff --git a/Polyfills/URL/Readme.md b/Polyfills/URL/Readme.md index a492bd5f..8e52fc2c 100644 --- a/Polyfills/URL/Readme.md +++ b/Polyfills/URL/Readme.md @@ -18,8 +18,8 @@ Partial implementation for [`URL`](https://developer.mozilla.org/en-US/docs/Web/ - [`toJSON`](https://developer.mozilla.org/en-US/docs/Web/API/URL/toJSON) - [`parse`](https://developer.mozilla.org/en-US/docs/Web/API/URL/parse_static) - [`canParse`](https://developer.mozilla.org/en-US/docs/Web/API/URL/canParse_static) -- [`createObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static) (Blob only; returns an equivalent `data:` URL) -- [`revokeObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/revokeObjectURL_static) (no-op, since `createObjectURL` returns self-contained `data:` URLs) +- [`createObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static) (Blob only; mints a `blob:` URL backed by an in-memory, process-global object-URL store) +- [`revokeObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/revokeObjectURL_static) (releases the stored bytes for a `blob:` URL previously returned by `createObjectURL`; subsequent fetch/XMLHttpRequest against that URL fail as a network error) # URLSearchParams Partial implementatioin for [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) diff --git a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp index 1e676984..251d7501 100644 --- a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp +++ b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp @@ -140,7 +140,7 @@ namespace Babylon::Polyfills::Internal { if (m_request.ResponseType() == UrlLib::UrlResponseType::String) { - return GetResponseText(Napi::CallbackInfo{Env(), nullptr}); + return Napi::String::New(Env(), reinterpret_cast(m_blobData.data()), m_blobData.size()); } auto arrayBuffer{Napi::ArrayBuffer::New(Env(), m_blobData.size())}; @@ -325,22 +325,33 @@ namespace Babylon::Polyfills::Internal void XMLHttpRequest::Open(const Napi::CallbackInfo& info) { - m_url = info[1].As(); + // Clear any state left over from a previous use of this instance so that reusing a single + // XMLHttpRequest across multiple requests never mixes blob: and transport request state + // (e.g. a later non-blob request being mistaken for a blob request). + m_isBlobRequest = false; + m_blobResolved = false; + m_blobData.clear(); + m_blobType.clear(); - // blob: URLs (URL.createObjectURL) are served from the in-memory object-URL store rather - // than the UrlLib transport, which only understands app/file/http(s). Resolve eagerly so - // status/response are available synchronously once Send() completes. - if (m_url.rfind(BlobUrlScheme, 0) == 0) - { - m_isBlobRequest = true; - m_blobResolved = Babylon::Polyfills::URL::TryResolveObjectURL(info.Env(), m_url, m_blobData, m_blobType); - SetReadyState(ReadyState::Opened); - return; - } + m_url = info[1].As(); try { - m_request.Open(MethodType::StringToEnum(info[0].As().Utf8Value()), m_url); + // Validate the HTTP method for every request, including blob: URLs, so unsupported + // verbs are rejected consistently regardless of the URL scheme. + const auto method = MethodType::StringToEnum(info[0].As().Utf8Value()); + + // blob: URLs (URL.createObjectURL) are served from the in-memory object-URL store + // rather than the UrlLib transport, which only understands app/file/http(s). The store + // is (re-)resolved at Send() time so revoke-after-open is honored. + if (m_url.rfind(BlobUrlScheme, 0) == 0) + { + m_isBlobRequest = true; + } + else + { + m_request.Open(method, m_url); + } } catch (const std::exception& e) { @@ -361,10 +372,16 @@ namespace Babylon::Polyfills::Internal throw Napi::Error::New(info.Env(), "XMLHttpRequest must be opened before it can be sent"); } - // blob: request: the response is already resolved in Open(). Deliver the completion events - // asynchronously (mirroring a real transport) so listeners registered after send() still fire. + // blob: request: resolve the object-URL store now (at send() time, not open()) so a blob: + // URL revoked between open() and send() is reported as a network error (status 0 + error), + // matching browser revoke semantics. Deliver the completion events asynchronously (mirroring + // a real transport) so listeners registered after send() still fire. if (m_isBlobRequest) { + m_blobData.clear(); + m_blobType.clear(); + m_blobResolved = Babylon::Polyfills::URL::TryResolveObjectURL(info.Env(), m_url, m_blobData, m_blobType); + auto anchor = std::make_shared(Napi::Persistent(info.This().As())); m_runtimeScheduler([this, anchor{std::move(anchor)}]() { diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 2ca518ce..cdc9416b 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1246,6 +1246,11 @@ describe("URL.createObjectURL", function () { URL.revokeObjectURL(url); }); + it("throws when createObjectURL is given a non-Blob", function () { + expect(() => URL.createObjectURL({} as any)).to.throw(); + expect(() => URL.createObjectURL("not a blob" as any)).to.throw(); + }); + it("resolves a blob: URL through fetch (text + content-type)", async function () { const url = URL.createObjectURL(new Blob(["hello blob"], { type: "text/plain" })); const response = await fetch(url); @@ -1306,6 +1311,23 @@ describe("URL.createObjectURL", function () { expect(result.status).to.equal(0); expect(result.errorFired).to.equal(true); }); + + it("XMLHttpRequest honors a revoke between open() and send()", async function () { + const url = URL.createObjectURL(new Blob(["late revoke"])); + const result = await new Promise<{ status: number; errorFired: boolean }>((resolve) => { + const req = new XMLHttpRequest(); + let errorFired = false; + req.addEventListener("error", () => { errorFired = true; }); + req.addEventListener("loadend", () => resolve({ status: req.status, errorFired })); + req.open("GET", url); + // Revoked after open() but before send(): the store is re-checked at send() time, so + // this must surface as a network error rather than serving stale bytes. + URL.revokeObjectURL(url); + req.send(); + }); + expect(result.status).to.equal(0); + expect(result.errorFired).to.equal(true); + }); }); // URLSearchParams From 2fc473d49b5f9a5ac7b77dbb8c639492bf1ab1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:16:30 -0700 Subject: [PATCH 3/5] Address review: fix napi_typeof for JSC constructors (#194) and share blob URL buffers - Node-API-JSC: napi_typeof now also consults JSObjectIsConstructor so DefineClass constructors report as napi_function, fixing issue #194 at the adapter instead of per-polyfill workarounds. - Blob/File/Fetch: drop the IsObject()/IsUndefined() napi_typeof workarounds and use IsFunction() now that the adapter reports constructors correctly. - URL blob store: hold the bytes in a shared_ptr and hand a shared reference out of TryResolveObjectURL instead of copying on every resolve; XMLHttpRequest shares the buffer rather than copying it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: af4ea82e-5fb0-4e56-b71a-f8ff3932d033 --- .../Source/js_native_api_javascriptcore.cc | 7 ++++++- Polyfills/Blob/Source/Blob.cpp | 20 +++++++----------- Polyfills/Fetch/Source/Fetch.cpp | 21 +++++++------------ Polyfills/File/Source/File.cpp | 8 ++----- Polyfills/URL/Include/Babylon/Polyfills/URL.h | 14 ++++++++++--- Polyfills/URL/Source/URL.cpp | 16 ++++++++------ .../XMLHttpRequest/Source/XMLHttpRequest.cpp | 21 ++++++++++++------- .../XMLHttpRequest/Source/XMLHttpRequest.h | 4 +++- 8 files changed, 59 insertions(+), 52 deletions(-) diff --git a/Core/Node-API/Source/js_native_api_javascriptcore.cc b/Core/Node-API/Source/js_native_api_javascriptcore.cc index 2c9e8e80..6d1ade9c 100644 --- a/Core/Node-API/Source/js_native_api_javascriptcore.cc +++ b/Core/Node-API/Source/js_native_api_javascriptcore.cc @@ -1547,7 +1547,12 @@ napi_status napi_typeof(napi_env env, napi_value value, napi_valuetype* result) case kJSTypeSymbol: *result = napi_symbol; break; default: JSObjectRef object{ToJSObject(env, value)}; - if (JSObjectIsFunction(env->context, object)) { + // Consult JSObjectIsConstructor in addition to JSObjectIsFunction: some JSC builds (e.g. + // libjavascriptcoregtk) report constructors created via JSObjectMakeConstructor -- such as + // node-addon-api DefineClass constructors -- as not-a-function, which would otherwise be + // classified as napi_object and make Napi IsFunction() reject otherwise-valid constructors + // (see issue #194). + if (JSObjectIsFunction(env->context, object) || JSObjectIsConstructor(env->context, object)) { *result = napi_function; } else { NativeInfo* info = NativeInfo::Get(object); diff --git a/Polyfills/Blob/Source/Blob.cpp b/Polyfills/Blob/Source/Blob.cpp index ad740c7a..5595b521 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -148,20 +148,14 @@ namespace Babylon::Polyfills::Blob // Verify `object` is a Blob (or a subclass such as File) by walking its prototype chain and // comparing each link against Blob.prototype, using the JS-level Object.getPrototypeOf. // - // Two engine-adapter quirks drive this implementation: - // * The Blob constructor is created via node-addon-api's DefineClass. On the JavaScriptCore - // adapter such constructors are callable-as-constructor but are NOT reported as functions - // by napi_typeof (JSObjectIsFunction returns false), so we must probe the constructor with - // IsObject() rather than IsFunction() and read `prototype` as a plain object property. - // * We use Object.getPrototypeOf rather than the raw napi_get_prototype C API because the - // latter is not exposed by every adapter (e.g. JSI) and, on JSC, returns the raw - // [[Prototype]] which differs from the JS-visible prototype of a DefineClass instance. - // Together this keeps the check portable across QuickJS, V8, JavaScriptCore, Chakra and JSI, - // and it also accepts Blob subclasses (e.g. File). We deliberately avoid napi_instanceof, - // whose node-addon-api wrapper requires a Napi::Function and is likewise gated on the - // constructor being typed as a function. + // We use Object.getPrototypeOf rather than the raw napi_get_prototype C API because the + // latter is not exposed by every adapter (e.g. JSI) and, on JSC, returns the raw + // [[Prototype]] which differs from the JS-visible prototype of a DefineClass instance. + // This keeps the check portable across QuickJS, V8, JavaScriptCore, Chakra and JSI, and it + // also accepts Blob subclasses (e.g. File). We deliberately avoid napi_instanceof, whose + // node-addon-api wrapper requires a Napi::Function. const auto blobConstructor = global.Get("Blob"); - if (!blobConstructor.IsObject()) + if (!blobConstructor.IsFunction()) { return false; } diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp index 3d944377..7ebe8236 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -80,13 +80,9 @@ namespace Babylon::Polyfills::Internal // when an error is thrown) -- in that case the rejection simply carries no synthetic frames. std::string CaptureCallSiteStack(Napi::Env env) { - // Detect the global Error constructor with IsUndefined()/IsNull() rather than - // IsFunction(): some JavaScriptCore/JSI builds classify constructor functions as - // typeof 'object', so napi_typeof reports napi_object and IsFunction() would - // incorrectly skip stack capture even though Error is callable (see the Blob check - // below for the same rationale). Error is always present, so this guard is defensive. + // Error is always present and callable; this guard is defensive. const Napi::Value errorCtor = env.Global().Get("Error"); - if (errorCtor.IsUndefined() || errorCtor.IsNull()) + if (!errorCtor.IsFunction()) { return {}; } @@ -306,12 +302,9 @@ 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. + // Require the Blob polyfill to be installed. const auto blobConstructor = env.Global().Get("Blob"); - if (blobConstructor.IsUndefined() || blobConstructor.IsNull()) + if (!blobConstructor.IsFunction()) { deferred.Reject(Napi::Error::New(env, "fetch: Blob is not available in this environment").Value()); return deferred.Promise(); @@ -411,9 +404,9 @@ namespace Babylon::Polyfills::Internal // error (TypeError) when the URL was never registered or has been revoked. if (url.rfind("blob:", 0) == 0) { - std::vector blobData; std::string blobType; - if (!Babylon::Polyfills::URL::TryResolveObjectURL(env, url, blobData, blobType)) + const auto blobData = Babylon::Polyfills::URL::TryResolveObjectURL(env, url, blobType); + if (blobData == nullptr) { deferred.Reject(Napi::TypeError::New(env, "Failed to fetch: blob URL is not registered").Value()); return deferred.Promise(); @@ -427,7 +420,7 @@ namespace Babylon::Polyfills::Internal { data->headers.emplace_back("content-type", blobType); } - data->body = std::move(blobData); + data->body = *blobData; deferred.Resolve(BuildResponse(env, data)); return deferred.Promise(); diff --git a/Polyfills/File/Source/File.cpp b/Polyfills/File/Source/File.cpp index 5cc3de02..75261e79 100644 --- a/Polyfills/File/Source/File.cpp +++ b/Polyfills/File/Source/File.cpp @@ -27,13 +27,9 @@ namespace Babylon::Polyfills::Internal // Require the native Blob polyfill: File delegates byte storage to // a Blob, so without it the constructor cannot produce useful - // instances. Use IsUndefined() rather than IsFunction() because - // some JavaScriptCore builds (notably libjavascriptcoregtk on - // Linux) classify constructors created via JSObjectMakeConstructor - // as typeof 'object', not 'function', so napi_typeof returns - // napi_object for them. + // instances. auto blob = global.Get(JS_BLOB_CONSTRUCTOR_NAME); - if (blob.IsUndefined() || blob.IsNull()) + if (!blob.IsFunction()) { throw Napi::Error::New(env, "File polyfill requires the Blob polyfill to be installed first."); diff --git a/Polyfills/URL/Include/Babylon/Polyfills/URL.h b/Polyfills/URL/Include/Babylon/Polyfills/URL.h index a0dfd31b..881b89e7 100644 --- a/Polyfills/URL/Include/Babylon/Polyfills/URL.h +++ b/Polyfills/URL/Include/Babylon/Polyfills/URL.h @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -17,6 +18,11 @@ namespace Babylon::Polyfills::URL // the minted URLs embed a UUID so entries never collide between environments. The // XMLHttpRequest and fetch polyfills call TryResolveObjectURL to serve those URLs from memory // instead of handing them to the (scheme-unaware) transport. + // + // The store holds the Blob's bytes in a shared_ptr, so resolving a blob: URL hands out a + // reference to the immutable buffer rather than copying it. Bytes are released once the entry + // is revoked and every outstanding resolver has dropped its shared_ptr, matching how a browser + // Blob's bytes stay valid for an in-flight read even if the URL is revoked mid-flight. // Copies `size` bytes into the process-global store and returns a freshly minted blob: URL. // The `env` parameter is currently unused but kept for API symmetry and future per-env scoping. @@ -25,7 +31,9 @@ namespace Babylon::Polyfills::URL // Releases the entry for `url`, if any. Unknown URLs are ignored (matching the web platform). void BABYLON_API RevokeObjectURL(Napi::Env env, const std::string& url); - // Copies the bytes and MIME type registered for `url` into the out-parameters. Returns false - // if `url` is not a live blob: URL in the process-global store. - bool BABYLON_API TryResolveObjectURL(Napi::Env env, const std::string& url, std::vector& outData, std::string& outType); + // Returns a shared handle to the immutable bytes registered for `url`, and writes the MIME type + // into `outType`. Returns nullptr if `url` is not a live blob: URL in the process-global store. + // The returned buffer is shared (not copied) and remains valid for as long as the caller holds + // the shared_ptr, even if the entry is revoked in the meantime. + std::shared_ptr> BABYLON_API TryResolveObjectURL(Napi::Env env, const std::string& url, std::string& outType); } diff --git a/Polyfills/URL/Source/URL.cpp b/Polyfills/URL/Source/URL.cpp index 3efbe617..88028057 100644 --- a/Polyfills/URL/Source/URL.cpp +++ b/Polyfills/URL/Source/URL.cpp @@ -30,9 +30,14 @@ namespace // sharing one store across environments is safe (a blob: URL minted in one environment is never // produced in another). Entries are released by revokeObjectURL; any not revoked before the // process exits are reclaimed at exit, mirroring how browsers retain blob URLs until unload. + // + // Each entry owns its bytes through a shared_ptr, so TryResolveObjectURL hands out a reference + // to the immutable buffer instead of copying it on every resolve. Revoking (or process exit) + // drops the store's reference; the bytes are freed once any outstanding resolver has also + // released its shared_ptr, matching how a browser Blob's bytes stay valid for an in-flight read. struct BlobUrlEntry { - std::vector data; + std::shared_ptr> data; std::string type; }; @@ -842,7 +847,7 @@ namespace Babylon::Polyfills::URL std::string BABYLON_API RegisterObjectURL(Napi::Env, const std::byte* data, size_t size, std::string type) { BlobUrlEntry entry; - entry.data.assign(data, data + size); + entry.data = std::make_shared>(data, data + size); entry.type = std::move(type); std::string url = GenerateObjectURL(); @@ -860,7 +865,7 @@ namespace Babylon::Polyfills::URL store.entries.erase(url); } - bool BABYLON_API TryResolveObjectURL(Napi::Env, const std::string& url, std::vector& outData, std::string& outType) + std::shared_ptr> BABYLON_API TryResolveObjectURL(Napi::Env, const std::string& url, std::string& outType) { auto& store = GetBlobUrlStore(); const std::lock_guard lock{store.mutex}; @@ -868,11 +873,10 @@ namespace Babylon::Polyfills::URL const auto it = store.entries.find(url); if (it == store.entries.end()) { - return false; + return nullptr; } - outData = it->second.data; outType = it->second.type; - return true; + return it->second.data; } } diff --git a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp index 251d7501..b2aaa5dc 100644 --- a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp +++ b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp @@ -138,15 +138,18 @@ namespace Babylon::Polyfills::Internal { if (m_isBlobRequest) { + const auto* bytes = m_blobData ? reinterpret_cast(m_blobData->data()) : nullptr; + const size_t count = m_blobData ? m_blobData->size() : 0; + if (m_request.ResponseType() == UrlLib::UrlResponseType::String) { - return Napi::String::New(Env(), reinterpret_cast(m_blobData.data()), m_blobData.size()); + return Napi::String::New(Env(), bytes, count); } - auto arrayBuffer{Napi::ArrayBuffer::New(Env(), m_blobData.size())}; - if (!m_blobData.empty()) + auto arrayBuffer{Napi::ArrayBuffer::New(Env(), count)}; + if (count != 0) { - std::memcpy(arrayBuffer.Data(), m_blobData.data(), m_blobData.size()); + std::memcpy(arrayBuffer.Data(), bytes, count); } return arrayBuffer; } @@ -168,7 +171,9 @@ namespace Babylon::Polyfills::Internal { if (m_isBlobRequest) { - return Napi::String::New(Env(), reinterpret_cast(m_blobData.data()), m_blobData.size()); + const auto* bytes = m_blobData ? reinterpret_cast(m_blobData->data()) : nullptr; + const size_t count = m_blobData ? m_blobData->size() : 0; + return Napi::String::New(Env(), bytes, count); } return Napi::Value::From(Env(), m_request.ResponseString().data()); @@ -330,7 +335,7 @@ namespace Babylon::Polyfills::Internal // (e.g. a later non-blob request being mistaken for a blob request). m_isBlobRequest = false; m_blobResolved = false; - m_blobData.clear(); + m_blobData.reset(); m_blobType.clear(); m_url = info[1].As(); @@ -378,9 +383,9 @@ namespace Babylon::Polyfills::Internal // a real transport) so listeners registered after send() still fire. if (m_isBlobRequest) { - m_blobData.clear(); m_blobType.clear(); - m_blobResolved = Babylon::Polyfills::URL::TryResolveObjectURL(info.Env(), m_url, m_blobData, m_blobType); + m_blobData = Babylon::Polyfills::URL::TryResolveObjectURL(info.Env(), m_url, m_blobType); + m_blobResolved = (m_blobData != nullptr); auto anchor = std::make_shared(Napi::Persistent(info.This().As())); diff --git a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h index 54e49a53..7e6f2e0e 100644 --- a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h +++ b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -56,9 +57,10 @@ namespace Babylon::Polyfills::Internal // In-memory response state for blob: URLs (URL.createObjectURL). m_isBlobRequest is set once // Open() sees a blob: URL; m_blobResolved records whether it mapped to a live store entry. + // m_blobData shares the store's immutable buffer (null when unresolved) instead of copying. bool m_isBlobRequest{}; bool m_blobResolved{}; - std::vector m_blobData{}; + std::shared_ptr> m_blobData{}; std::string m_blobType{}; }; } From fa65ef6641a5b920ec6c96cfe710d59c87162f33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:57:25 -0700 Subject: [PATCH 4/5] Resolve blob: URLs through UrlLib instead of per-polyfill branches Bump the UrlLib pin to the fork commit that adds UrlRequest scheme resolvers, then register a process-global "blob" resolver from the URL polyfill backed by the object-URL store. fetch and XMLHttpRequest now serve blob: URLs through the ordinary UrlLib transport path, so their blob-specific branches (and the URL::TryResolveObjectURL seam that fed them) are removed. A revoked/unknown blob: URL surfaces as a status-0 network error, exactly as before. All 216 unit tests pass. Addresses bghgary review feedback on #207 (tracked by #213). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: af4ea82e-5fb0-4e56-b71a-f8ff3932d033 --- CMakeLists.txt | 4 +- Polyfills/Fetch/CMakeLists.txt | 3 +- Polyfills/Fetch/Source/Fetch.cpp | 29 ----- Polyfills/URL/CMakeLists.txt | 1 + Polyfills/URL/Include/Babylon/Polyfills/URL.h | 13 +- Polyfills/URL/Source/URL.cpp | 48 ++++--- Polyfills/XMLHttpRequest/CMakeLists.txt | 3 +- .../XMLHttpRequest/Source/XMLHttpRequest.cpp | 123 +----------------- .../XMLHttpRequest/Source/XMLHttpRequest.h | 9 -- 9 files changed, 43 insertions(+), 190 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 481e848f..62d8dfe9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,8 +48,8 @@ FetchContent_Declare(llhttp URL "https://github.com/nodejs/llhttp/archive/refs/tags/release/v8.1.0.tar.gz" EXCLUDE_FROM_ALL) FetchContent_Declare(UrlLib - GIT_REPOSITORY https://github.com/BabylonJS/UrlLib.git - GIT_TAG e86ffb34e77092266145497681efc74e0a920ffe + GIT_REPOSITORY https://github.com/bkaradzic-microsoft/UrlLib.git + GIT_TAG 109e373a2cb26815bea70af4ca04f9ca4b546577 EXCLUDE_FROM_ALL) FetchContent_Declare(quickjs-ng GIT_REPOSITORY https://github.com/quickjs-ng/quickjs.git diff --git a/Polyfills/Fetch/CMakeLists.txt b/Polyfills/Fetch/CMakeLists.txt index 52e527b3..62b46175 100644 --- a/Polyfills/Fetch/CMakeLists.txt +++ b/Polyfills/Fetch/CMakeLists.txt @@ -11,8 +11,7 @@ target_include_directories(Fetch PUBLIC "Include") target_link_libraries(Fetch PUBLIC JsRuntime PRIVATE arcana - PRIVATE UrlLib - PRIVATE URL) + PRIVATE UrlLib) 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 7ebe8236..cf6c171e 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include @@ -398,34 +397,6 @@ namespace Babylon::Polyfills::Internal signal = init.Get("signal"); } - // blob: URLs (URL.createObjectURL) resolve against the in-memory object-URL store - // instead of the UrlLib transport, which only understands app/file/http(s). Serve - // the buffered bytes synchronously as a 200 response, or reject with a network - // error (TypeError) when the URL was never registered or has been revoked. - if (url.rfind("blob:", 0) == 0) - { - std::string blobType; - const auto blobData = Babylon::Polyfills::URL::TryResolveObjectURL(env, url, blobType); - if (blobData == nullptr) - { - deferred.Reject(Napi::TypeError::New(env, "Failed to fetch: blob URL is not registered").Value()); - return deferred.Promise(); - } - - auto data = std::make_shared(); - data->statusCode = 200; - data->statusText = "OK"; - data->url = url; - if (!blobType.empty()) - { - data->headers.emplace_back("content-type", blobType); - } - data->body = *blobData; - - deferred.Resolve(BuildResponse(env, data)); - return deferred.Promise(); - } - auto request = std::make_shared(); request->Open(method, url); request->ResponseType(UrlLib::UrlResponseType::Buffer); diff --git a/Polyfills/URL/CMakeLists.txt b/Polyfills/URL/CMakeLists.txt index 5d33358d..a4c588b3 100644 --- a/Polyfills/URL/CMakeLists.txt +++ b/Polyfills/URL/CMakeLists.txt @@ -12,6 +12,7 @@ target_include_directories(URL PUBLIC "Include") target_link_libraries(URL PRIVATE Blob + PRIVATE UrlLib PUBLIC JsRuntime) set_property(TARGET URL PROPERTY FOLDER Polyfills) diff --git a/Polyfills/URL/Include/Babylon/Polyfills/URL.h b/Polyfills/URL/Include/Babylon/Polyfills/URL.h index 881b89e7..0e0c993e 100644 --- a/Polyfills/URL/Include/Babylon/Polyfills/URL.h +++ b/Polyfills/URL/Include/Babylon/Polyfills/URL.h @@ -15,9 +15,10 @@ namespace Babylon::Polyfills::URL // Blob URL store backing URL.createObjectURL / URL.revokeObjectURL. Native has no browser // blob: URL store, so the URL polyfill keeps an in-memory registry keyed by a minted // blob: URL. The store is process-global (shared across all JS environments in the process); - // the minted URLs embed a UUID so entries never collide between environments. The - // XMLHttpRequest and fetch polyfills call TryResolveObjectURL to serve those URLs from memory - // instead of handing them to the (scheme-unaware) transport. + // the minted URLs embed a UUID so entries never collide between environments. Consumers do not + // resolve these URLs directly: the URL polyfill registers a process-global blob: resolver with + // UrlLib, so fetch, XMLHttpRequest, and any other UrlLib consumer serve blob: URLs uniformly + // through the transport layer. // // The store holds the Blob's bytes in a shared_ptr, so resolving a blob: URL hands out a // reference to the immutable buffer rather than copying it. Bytes are released once the entry @@ -30,10 +31,4 @@ namespace Babylon::Polyfills::URL // Releases the entry for `url`, if any. Unknown URLs are ignored (matching the web platform). void BABYLON_API RevokeObjectURL(Napi::Env env, const std::string& url); - - // Returns a shared handle to the immutable bytes registered for `url`, and writes the MIME type - // into `outType`. Returns nullptr if `url` is not a live blob: URL in the process-global store. - // The returned buffer is shared (not copied) and remains valid for as long as the caller holds - // the shared_ptr, even if the entry is revoked in the meantime. - std::shared_ptr> BABYLON_API TryResolveObjectURL(Napi::Env env, const std::string& url, std::string& outType); } diff --git a/Polyfills/URL/Source/URL.cpp b/Polyfills/URL/Source/URL.cpp index 88028057..fdefc255 100644 --- a/Polyfills/URL/Source/URL.cpp +++ b/Polyfills/URL/Source/URL.cpp @@ -1,6 +1,7 @@ #include "URL.h" #include #include +#include #include #include #include @@ -53,6 +54,36 @@ namespace return store; } + // Registers the process-global blob: resolver with UrlLib exactly once. This lets every UrlLib + // consumer (fetch, XMLHttpRequest, and any future image/texture loader) resolve blob: URLs + // 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. + void EnsureBlobSchemeResolverRegistered() + { + static std::once_flag onceFlag; + std::call_once(onceFlag, [] { + UrlLib::UrlRequest::RegisterSchemeResolver("blob", [](const std::string& url) { + UrlLib::UrlSchemeResolverResult result; + + auto& store = GetBlobUrlStore(); + const std::lock_guard lock{store.mutex}; + const auto it = store.entries.find(url); + if (it == store.entries.end()) + { + return result; // handled stays false -> surfaced as a network error + } + + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + result.statusText = "OK"; + result.contentType = it->second.type; + result.body = it->second.data; + return result; + }); + }); + } + // Mints a URL of the form blob:/. Native has no origin, so the opaque "null" // origin (as used by the web platform for e.g. data:-document contexts) is used. The uuid is a // random RFC 4122 version 4 identifier -- unique enough to key the store, not security bearing. @@ -399,6 +430,8 @@ namespace Babylon::Polyfills::Internal void URL::Initialize(Napi::Env env) { + EnsureBlobSchemeResolverRegistered(); + if (env.Global().Get(JS_URL_CONSTRUCTOR_NAME).IsUndefined()) { Napi::Function func = DefineClass( @@ -864,19 +897,4 @@ namespace Babylon::Polyfills::URL const std::lock_guard lock{store.mutex}; store.entries.erase(url); } - - std::shared_ptr> BABYLON_API TryResolveObjectURL(Napi::Env, const std::string& url, std::string& outType) - { - auto& store = GetBlobUrlStore(); - const std::lock_guard lock{store.mutex}; - - const auto it = store.entries.find(url); - if (it == store.entries.end()) - { - return nullptr; - } - - outType = it->second.type; - return it->second.data; - } } diff --git a/Polyfills/XMLHttpRequest/CMakeLists.txt b/Polyfills/XMLHttpRequest/CMakeLists.txt index 8b79f8bc..7aae3de4 100644 --- a/Polyfills/XMLHttpRequest/CMakeLists.txt +++ b/Polyfills/XMLHttpRequest/CMakeLists.txt @@ -11,8 +11,7 @@ target_include_directories(XMLHttpRequest PUBLIC "Include") target_link_libraries(XMLHttpRequest PUBLIC JsRuntime PRIVATE arcana - PRIVATE UrlLib - PRIVATE URL) + PRIVATE UrlLib) set_property(TARGET XMLHttpRequest PROPERTY FOLDER Polyfills) source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp index b2aaa5dc..f7eedb0b 100644 --- a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp +++ b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp @@ -1,9 +1,7 @@ #include "XMLHttpRequest.h" #include #include -#include #include -#include #include #include @@ -62,21 +60,6 @@ namespace Babylon::Polyfills::Internal constexpr const char* LoadEnd = "loadend"; constexpr const char* Error = "error"; } - - constexpr const char* BlobUrlScheme = "blob:"; - - bool EqualsIgnoreCase(const std::string& a, const char* b) - { - size_t i = 0; - for (; i < a.size() && b[i] != '\0'; ++i) - { - if (std::tolower(static_cast(a[i])) != std::tolower(static_cast(b[i]))) - { - return false; - } - } - return i == a.size() && b[i] == '\0'; - } } void XMLHttpRequest::Initialize(Napi::Env env) @@ -136,24 +119,6 @@ namespace Babylon::Polyfills::Internal Napi::Value XMLHttpRequest::GetResponse(const Napi::CallbackInfo&) { - if (m_isBlobRequest) - { - const auto* bytes = m_blobData ? reinterpret_cast(m_blobData->data()) : nullptr; - const size_t count = m_blobData ? m_blobData->size() : 0; - - if (m_request.ResponseType() == UrlLib::UrlResponseType::String) - { - return Napi::String::New(Env(), bytes, count); - } - - auto arrayBuffer{Napi::ArrayBuffer::New(Env(), count)}; - if (count != 0) - { - std::memcpy(arrayBuffer.Data(), bytes, count); - } - return arrayBuffer; - } - if (m_request.ResponseType() == UrlLib::UrlResponseType::String) { return Napi::Value::From(Env(), m_request.ResponseString().data()); @@ -169,13 +134,6 @@ namespace Babylon::Polyfills::Internal Napi::Value XMLHttpRequest::GetResponseText(const Napi::CallbackInfo&) { - if (m_isBlobRequest) - { - const auto* bytes = m_blobData ? reinterpret_cast(m_blobData->data()) : nullptr; - const size_t count = m_blobData ? m_blobData->size() : 0; - return Napi::String::New(Env(), bytes, count); - } - return Napi::Value::From(Env(), m_request.ResponseString().data()); } @@ -191,31 +149,16 @@ namespace Babylon::Polyfills::Internal Napi::Value XMLHttpRequest::GetResponseURL(const Napi::CallbackInfo&) { - if (m_isBlobRequest) - { - return Napi::String::New(Env(), m_url); - } - return Napi::Value::From(Env(), m_request.ResponseUrl().data()); } Napi::Value XMLHttpRequest::GetStatus(const Napi::CallbackInfo&) { - if (m_isBlobRequest) - { - return Napi::Value::From(Env(), m_blobResolved ? 200 : 0); - } - return Napi::Value::From(Env(), arcana::underlying_cast(m_request.StatusCode())); } Napi::Value XMLHttpRequest::GetStatusText(const Napi::CallbackInfo&) { - if (m_isBlobRequest) - { - return Napi::String::New(Env(), m_blobResolved ? "OK" : ""); - } - // Per the XHR spec, statusText is the empty string until a response is available // (status 0 means UNSENT/OPENED or a network error). if (arcana::underlying_cast(m_request.StatusCode()) == 0) @@ -244,15 +187,6 @@ namespace Babylon::Polyfills::Internal { const auto headerName = info[0].As().Utf8Value(); - if (m_isBlobRequest) - { - if (m_blobResolved && EqualsIgnoreCase(headerName, "content-type")) - { - return Napi::String::New(Env(), m_blobType); - } - return info.Env().Null(); - } - const auto header = m_request.GetResponseHeader(headerName); return header ? Napi::Value::From(Env(), header.value()) : info.Env().Null(); } @@ -261,15 +195,6 @@ namespace Babylon::Polyfills::Internal { Napi::Object responseHeadersObject = Napi::Object::New(Env()); - if (m_isBlobRequest) - { - if (m_blobResolved) - { - responseHeadersObject.Set(Napi::String::New(Env(), "content-type"), Napi::String::New(Env(), m_blobType)); - } - return responseHeadersObject; - } - auto responseHeaders = m_request.GetAllResponseHeaders(); for (auto& iter : responseHeaders) @@ -330,33 +255,12 @@ namespace Babylon::Polyfills::Internal void XMLHttpRequest::Open(const Napi::CallbackInfo& info) { - // Clear any state left over from a previous use of this instance so that reusing a single - // XMLHttpRequest across multiple requests never mixes blob: and transport request state - // (e.g. a later non-blob request being mistaken for a blob request). - m_isBlobRequest = false; - m_blobResolved = false; - m_blobData.reset(); - m_blobType.clear(); - m_url = info[1].As(); try { - // Validate the HTTP method for every request, including blob: URLs, so unsupported - // verbs are rejected consistently regardless of the URL scheme. const auto method = MethodType::StringToEnum(info[0].As().Utf8Value()); - - // blob: URLs (URL.createObjectURL) are served from the in-memory object-URL store - // rather than the UrlLib transport, which only understands app/file/http(s). The store - // is (re-)resolved at Send() time so revoke-after-open is honored. - if (m_url.rfind(BlobUrlScheme, 0) == 0) - { - m_isBlobRequest = true; - } - else - { - m_request.Open(method, m_url); - } + m_request.Open(method, m_url); } catch (const std::exception& e) { @@ -377,31 +281,6 @@ namespace Babylon::Polyfills::Internal throw Napi::Error::New(info.Env(), "XMLHttpRequest must be opened before it can be sent"); } - // blob: request: resolve the object-URL store now (at send() time, not open()) so a blob: - // URL revoked between open() and send() is reported as a network error (status 0 + error), - // matching browser revoke semantics. Deliver the completion events asynchronously (mirroring - // a real transport) so listeners registered after send() still fire. - if (m_isBlobRequest) - { - m_blobType.clear(); - m_blobData = Babylon::Polyfills::URL::TryResolveObjectURL(info.Env(), m_url, m_blobType); - m_blobResolved = (m_blobData != nullptr); - - auto anchor = std::make_shared(Napi::Persistent(info.This().As())); - - m_runtimeScheduler([this, anchor{std::move(anchor)}]() { - SetReadyState(ReadyState::Done); - if (!m_blobResolved) - { - RaiseEvent(EventType::Error); - } - RaiseEvent(EventType::LoadEnd); - m_eventHandlerRefs.clear(); - }); - - return; - } - if (info.Length() > 0) { if (!info[0].IsString() && !info[0].IsUndefined() && !info[0].IsNull()) diff --git a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h index 7e6f2e0e..74d2c3b9 100644 --- a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h +++ b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h @@ -5,7 +5,6 @@ #include #include -#include #include #include @@ -54,13 +53,5 @@ namespace Babylon::Polyfills::Internal JsRuntimeScheduler m_runtimeScheduler; ReadyState m_readyState{ReadyState::Unsent}; std::unordered_map> m_eventHandlerRefs; - - // In-memory response state for blob: URLs (URL.createObjectURL). m_isBlobRequest is set once - // Open() sees a blob: URL; m_blobResolved records whether it mapped to a live store entry. - // m_blobData shares the store's immutable buffer (null when unresolved) instead of copying. - bool m_isBlobRequest{}; - bool m_blobResolved{}; - std::shared_ptr> m_blobData{}; - std::string m_blobType{}; }; } From e0109f97bf34699be0451169a10db3c69ce32c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:05:01 -0700 Subject: [PATCH 5/5] Bump UrlLib pin to pick up scheme-resolver review fixes Picks up UrlLib 6437417: throwing resolvers are contained and reported as a transport-style error instead of escaping SendAsync(), and scheme-resolver removal is now the explicit UnregisterSchemeResolver call. The URL polyfill registers a non-null resolver and never unregisters, so no polyfill changes are needed. All 216 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: af4ea82e-5fb0-4e56-b71a-f8ff3932d033 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 62d8dfe9..c9d389c7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,7 +49,7 @@ FetchContent_Declare(llhttp EXCLUDE_FROM_ALL) FetchContent_Declare(UrlLib GIT_REPOSITORY https://github.com/bkaradzic-microsoft/UrlLib.git - GIT_TAG 109e373a2cb26815bea70af4ca04f9ca4b546577 + GIT_TAG 6437417e7c2886588d5656d1c1d2db28358cdb2e EXCLUDE_FROM_ALL) FetchContent_Declare(quickjs-ng GIT_REPOSITORY https://github.com/quickjs-ng/quickjs.git