diff --git a/CMakeLists.txt b/CMakeLists.txt index 481e848f..c9d389c7 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 6437417e7c2886588d5656d1c1d2db28358cdb2e 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/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..5595b521 100644 --- a/Polyfills/Blob/Source/Blob.cpp +++ b/Polyfills/Blob/Source/Blob.cpp @@ -139,4 +139,69 @@ 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 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 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; + } + + 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/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..a4c588b3 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 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 476f699e..0e0c993e 100644 --- a/Polyfills/URL/Include/Babylon/Polyfills/URL.h +++ b/Polyfills/URL/Include/Babylon/Polyfills/URL.h @@ -3,7 +3,32 @@ #include #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 store is process-global (shared across all JS environments in the process); + // 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 + // 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. + 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); } 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..fdefc255 100644 --- a/Polyfills/URL/Source/URL.cpp +++ b/Polyfills/URL/Source/URL.cpp @@ -1,11 +1,120 @@ #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. 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. + // + // 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::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}; + } +} + namespace { // Parsed URL components @@ -321,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( @@ -346,6 +457,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 +825,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 +876,25 @@ 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 = std::make_shared>(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); + } } 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..f7eedb0b 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 @@ -185,15 +186,17 @@ namespace Babylon::Polyfills::Internal Napi::Value XMLHttpRequest::GetResponseHeader(const Napi::CallbackInfo& info) { const auto headerName = info[0].As().Utf8Value(); + 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()); + auto responseHeaders = m_request.GetAllResponseHeaders(); + for (auto& iter : responseHeaders) { auto key = Napi::String::New(Env(), iter.first); @@ -256,7 +259,8 @@ namespace Babylon::Polyfills::Internal try { - m_request.Open(MethodType::StringToEnum(info[0].As().Utf8Value()), m_url); + const auto method = MethodType::StringToEnum(info[0].As().Utf8Value()); + m_request.Open(method, m_url); } catch (const std::exception& e) { 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 () {