diff --git a/CMakeLists.txt b/CMakeLists.txt index 481e848f..21882d3e 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/BabylonJS/UrlLib.git - GIT_TAG e86ffb34e77092266145497681efc74e0a920ffe + GIT_TAG 0c991337a1160ba7a2d062bf8e342d0a66f48dc9 EXCLUDE_FROM_ALL) FetchContent_Declare(quickjs-ng GIT_REPOSITORY https://github.com/quickjs-ng/quickjs.git 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/CMakeLists.txt b/Polyfills/Blob/CMakeLists.txt index 1b77ef6d..b8da610e 100644 --- a/Polyfills/Blob/CMakeLists.txt +++ b/Polyfills/Blob/CMakeLists.txt @@ -1,5 +1,6 @@ set(SOURCES "Include/Babylon/Polyfills/Blob.h" + "InternalInclude/Babylon/Polyfills/BlobInternal.h" "Source/Blob.cpp" "Source/Blob.h") @@ -7,9 +8,16 @@ add_library(Blob ${SOURCES}) warnings_as_errors(Blob) target_include_directories(Blob PUBLIC "Include") +target_include_directories(Blob PRIVATE "InternalInclude") target_link_libraries(Blob PUBLIC JsRuntime) set_property(TARGET Blob PROPERTY FOLDER Polyfills) source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) + +# Seam for other polyfills in this repo that need to read a Blob's contents. Deliberately not part +# of the public "Include" directory: the contract is not one we are ready to commit to for embedders. +add_library(BlobInternal INTERFACE) +target_include_directories(BlobInternal INTERFACE "InternalInclude") +target_link_libraries(BlobInternal INTERFACE Blob) diff --git a/Polyfills/Blob/InternalInclude/Babylon/Polyfills/BlobInternal.h b/Polyfills/Blob/InternalInclude/Babylon/Polyfills/BlobInternal.h new file mode 100644 index 00000000..44f6b20e --- /dev/null +++ b/Polyfills/Blob/InternalInclude/Babylon/Polyfills/BlobInternal.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace Babylon::Polyfills::Blob +{ + // The contents of a Blob JS object created by this polyfill. + struct BlobData + { + // A Blob is immutable once constructed, so this is a shared reference to the Blob's buffer + // rather than a copy. It stays valid for as long as the caller holds it, even if the JS + // object it came from is collected. + std::shared_ptr> Data; + + // The Blob's MIME type, which may be empty. + std::string Type; + }; + + // Synchronously reads the contents of `object`, which may be a Blob or a subclass such as File. + // Returns std::nullopt if `object` is not a Blob created by this polyfill. + std::optional BABYLON_API TryGetData(const Napi::Object& object); +} diff --git a/Polyfills/Blob/Source/Blob.cpp b/Polyfills/Blob/Source/Blob.cpp index 789c346c..242763fe 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -1,6 +1,7 @@ #include "Blob.h" #include #include +#include namespace Babylon::Polyfills::Internal { @@ -56,7 +57,7 @@ namespace Babylon::Polyfills::Internal Napi::Value Blob::GetSize(const Napi::CallbackInfo&) { - return Napi::Value::From(Env(), m_data.size()); + return Napi::Value::From(Env(), m_data->size()); } Napi::Value Blob::GetType(const Napi::CallbackInfo&) @@ -67,8 +68,8 @@ namespace Babylon::Polyfills::Internal Napi::Value Blob::Text(const Napi::CallbackInfo&) { // NOTE: This will not check for UTF-8 validity - const auto begin = reinterpret_cast(m_data.data()); - std::string text(begin, m_data.size()); + const auto begin = reinterpret_cast(m_data->data()); + std::string text(begin, m_data->size()); const auto deferred = Napi::Promise::Deferred::New(Env()); deferred.Resolve(Napi::String::New(Env(), text)); @@ -77,10 +78,10 @@ namespace Babylon::Polyfills::Internal Napi::Value Blob::ArrayBuffer(const Napi::CallbackInfo&) { - const auto arrayBuffer = Napi::ArrayBuffer::New(Env(), m_data.size()); - if (m_data.data()) + const auto arrayBuffer = Napi::ArrayBuffer::New(Env(), m_data->size()); + if (m_data->data()) { - std::memcpy(arrayBuffer.Data(), m_data.data(), m_data.size()); + std::memcpy(arrayBuffer.Data(), m_data->data(), m_data->size()); } const auto deferred = Napi::Promise::Deferred::New(Env()); @@ -90,12 +91,12 @@ namespace Babylon::Polyfills::Internal Napi::Value Blob::Bytes(const Napi::CallbackInfo&) { - const auto arrayBuffer = Napi::ArrayBuffer::New(Env(), m_data.size()); - if (m_data.data()) + const auto arrayBuffer = Napi::ArrayBuffer::New(Env(), m_data->size()); + if (m_data->data()) { - std::memcpy(arrayBuffer.Data(), m_data.data(), m_data.size()); + std::memcpy(arrayBuffer.Data(), m_data->data(), m_data->size()); } - const auto uint8Array = Napi::Uint8Array::New(Env(), m_data.size(), arrayBuffer, 0); + const auto uint8Array = Napi::Uint8Array::New(Env(), m_data->size(), arrayBuffer, 0); const auto deferred = Napi::Promise::Deferred::New(Env()); deferred.Resolve(uint8Array); @@ -108,27 +109,28 @@ namespace Babylon::Polyfills::Internal { const auto buffer = blobPart.As(); const auto begin = static_cast(buffer.Data()); - m_data.assign(begin, begin + buffer.ByteLength()); + m_data = std::make_shared>(begin, begin + buffer.ByteLength()); } else if (blobPart.IsTypedArray() || blobPart.IsDataView()) { const auto array = blobPart.As(); const auto buffer = array.ArrayBuffer(); const auto begin = static_cast(buffer.Data()) + array.ByteOffset(); - m_data.assign(begin, begin + array.ByteLength()); + m_data = std::make_shared>(begin, begin + array.ByteLength()); } else if (blobPart.IsString()) { const auto str = blobPart.As().Utf8Value(); const auto begin = reinterpret_cast(str.data()); - m_data.assign(begin, begin + str.length()); + m_data = std::make_shared>(begin, begin + str.length()); } else { - // Assume it's another Blob object + // Assume it's another Blob object. Blobs are immutable, so the buffer can be shared + // rather than copied. const auto obj = blobPart.As(); const auto blobObj = Napi::ObjectWrap::Unwrap(obj); - m_data.assign(blobObj->m_data.begin(), blobObj->m_data.end()); + m_data = blobObj->m_data; } } } @@ -139,4 +141,65 @@ namespace Babylon::Polyfills::Blob { Internal::Blob::Initialize(env); } + + std::optional BABYLON_API TryGetData(const Napi::Object& object) + { + 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. + // + // 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.IsFunction()) + { + return std::nullopt; + } + + const auto blobPrototype = blobConstructor.As().Get("prototype"); + if (!blobPrototype.IsObject()) + { + return std::nullopt; + } + + const auto objectConstructor = global.Get("Object"); + if (!objectConstructor.IsObject()) + { + return std::nullopt; + } + + const auto getPrototypeOf = objectConstructor.As().Get("getPrototypeOf"); + if (!getPrototypeOf.IsFunction()) + { + return std::nullopt; + } + + 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 std::nullopt; + } + + const auto* blob = Internal::Blob::Unwrap(object); + return BlobData{blob->Data(), blob->Type()}; + } } diff --git a/Polyfills/Blob/Source/Blob.h b/Polyfills/Blob/Source/Blob.h index df04e834..5d0147d0 100644 --- a/Polyfills/Blob/Source/Blob.h +++ b/Polyfills/Blob/Source/Blob.h @@ -2,6 +2,8 @@ #include +#include +#include #include #include @@ -14,6 +16,12 @@ namespace Babylon::Polyfills::Internal explicit Blob(const Napi::CallbackInfo& info); + // Synchronous accessors for internal cross-polyfill use (e.g. URL.createObjectURL). + // A Blob is immutable once constructed, so the bytes are held in a shared_ptr and can be + // shared with consumers rather than copied. Never null. + const std::shared_ptr>& Data() const { return m_data; } + const std::string& Type() const { return m_type; } + private: Napi::Value GetSize(const Napi::CallbackInfo& info); Napi::Value GetType(const Napi::CallbackInfo& info); @@ -23,7 +31,7 @@ namespace Babylon::Polyfills::Internal void ProcessBlobPart(const Napi::Value& blobPart); - std::vector m_data; + std::shared_ptr> m_data{std::make_shared>()}; std::string m_type; }; } \ No newline at end of file diff --git a/Polyfills/Fetch/Source/Fetch.cpp b/Polyfills/Fetch/Source/Fetch.cpp index 7a500df7..cf6c171e 100644 --- a/Polyfills/Fetch/Source/Fetch.cpp +++ b/Polyfills/Fetch/Source/Fetch.cpp @@ -79,13 +79,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 {}; } @@ -305,12 +301,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(); 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/CMakeLists.txt b/Polyfills/URL/CMakeLists.txt index f2ea1d58..54026df6 100644 --- a/Polyfills/URL/CMakeLists.txt +++ b/Polyfills/URL/CMakeLists.txt @@ -11,6 +11,8 @@ add_library(URL ${SOURCES}) target_include_directories(URL PUBLIC "Include") target_link_libraries(URL + PRIVATE BlobInternal + PRIVATE UrlLib PUBLIC JsRuntime) set_property(TARGET URL PROPERTY FOLDER Polyfills) diff --git a/Polyfills/URL/Readme.md b/Polyfills/URL/Readme.md index 84db8c00..8e52fc2c 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; 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/URL/Source/URL.cpp b/Polyfills/URL/Source/URL.cpp index 55e14691..3d2b9d7d 100644 --- a/Polyfills/URL/Source/URL.cpp +++ b/Polyfills/URL/Source/URL.cpp @@ -1,11 +1,150 @@ #include "URL.h" +#include +#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. Consumers never read + // this store directly: the URL polyfill registers a blob: scheme resolver with UrlLib (see + // Initialize below), so fetch, XMLHttpRequest and any other UrlLib consumer resolve blob: URLs + // uniformly through the ordinary transport path. + // + // The store is process-global rather than per-environment. No Node-API engine adapter in + // Core/Node-API/Source implements napi_add_env_cleanup_hook, so there is no portable hook on + // which to free per-environment state (tracked by #215). 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. + // + // Note the lifetime consequence of that workaround. A browser drops its blob URL store at + // unload, which corresponds to environment teardown here, not process exit. Because this store + // outlives the environment, an embedder that creates and destroys environments accumulates + // every entry that was not explicitly revoked for the life of the process. Callers should + // revoke object URLs when done with them; #215 would let the store release them automatically. + // + // Each entry owns its bytes through a shared_ptr shared with the Blob itself, so registering a + // URL does not duplicate the buffer and resolving one hands out a reference rather than a copy. + // Revoking 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 even if the URL is revoked mid-flight. + struct BlobUrlEntry + { + std::shared_ptr> data; + std::string type; + }; + + struct BlobUrlStore + { + std::mutex mutex; + std::unordered_map entries; + }; + + BlobUrlStore& GetBlobUrlStore() + { + static BlobUrlStore store; + 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. + 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}; + } + + // Registers `data` under a freshly minted blob: URL and returns it. The Blob's buffer is shared + // rather than copied, so createObjectURL does not duplicate a large blob. + std::string RegisterObjectURL(std::shared_ptr> data, std::string type) + { + BlobUrlEntry entry; + entry.data = std::move(data); + 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; + } + + // Releases the entry for `url`, if any. Unknown URLs are ignored (matching the web platform). + void RevokeObjectURL(const std::string& url) + { + auto& store = GetBlobUrlStore(); + const std::lock_guard lock{store.mutex}; + store.entries.erase(url); + } +} + namespace { // Parsed URL components @@ -321,6 +460,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( @@ -346,6 +487,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 +855,46 @@ namespace Babylon::Polyfills::Internal return info.Env().Null(); } } + + // URL.createObjectURL(blob) puts the Blob's bytes into the in-memory blob URL store and returns + // a minted blob: URL, which the store's UrlLib scheme resolver serves to any UrlLib consumer. + // 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"); + } + + auto blobData = Polyfills::Blob::TryGetData(info[0].As()); + if (!blobData.has_value()) + { + throw Napi::TypeError::New(env, "URL.createObjectURL: argument is not a Blob"); + } + + if (blobData->Type.empty()) + { + blobData->Type = "application/octet-stream"; + } + + return Napi::String::New(env, ::RegisterObjectURL(std::move(blobData->Data), std::move(blobData->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()) + { + ::RevokeObjectURL(info[0].As().Utf8Value()); + } + + return env.Undefined(); + } } namespace Babylon::Polyfills::URL 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/Source/XMLHttpRequest.cpp b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp index 495222a6..d0220d16 100644 --- a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp +++ b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include namespace Babylon::Polyfills::Internal diff --git a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h index ed35dbc9..74d2c3b9 100644 --- a/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h +++ b/Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h @@ -6,6 +6,7 @@ #include #include +#include namespace Babylon::Polyfills::Internal { diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index b286edf8..cdc9416b 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1235,6 +1235,101 @@ 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("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); + 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); + }); + + 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 describe("URLSearchParams", function () {