Add blob: URL support to URL.createObjectURL / revokeObjectURL - #207
Conversation
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/<uuidv4> 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>
There was a problem hiding this comment.
Pull request overview
Adds first-class blob: URL support to the runtime’s URL polyfill by introducing an in-memory object-URL registry and wiring fetch and XMLHttpRequest to resolve minted blob URLs, with unit tests covering round-trips and revoke behavior.
Changes:
- Implement
URL.createObjectURL/URL.revokeObjectURLbacked by a process-global in-memory registry, plus a small native API to register/revoke/resolve entries. - Teach
fetchandXMLHttpRequestpolyfills to interceptblob:URLs and serve bytes +content-typefrom the registry. - Add
Blob::TryGetDatafor synchronous access to blob bytes/type and add unit tests for fetch/XHR + revoke cases.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/UnitTests/Scripts/tests.ts | Adds unit tests for createObjectURL round-trips via fetch/XHR and revoke behavior. |
| Polyfills/XMLHttpRequest/Source/XMLHttpRequest.h | Adds in-memory response fields to support blob: responses. |
| Polyfills/XMLHttpRequest/Source/XMLHttpRequest.cpp | Implements blob: URL interception/resolution and response/header/status behavior. |
| Polyfills/XMLHttpRequest/CMakeLists.txt | Links XMLHttpRequest polyfill against the URL polyfill library. |
| Polyfills/URL/Source/URL.h | Exposes createObjectURL/revokeObjectURL on the JS URL constructor. |
| Polyfills/URL/Source/URL.cpp | Implements process-global blob URL registry and JS createObjectURL/revokeObjectURL. |
| Polyfills/URL/Readme.md | Updates URL polyfill documentation (but currently does not match the new implementation). |
| Polyfills/URL/Include/Babylon/Polyfills/URL.h | Adds native API surface for register/revoke/resolve of blob URLs (docs currently mismatch implementation lifetime). |
| Polyfills/URL/CMakeLists.txt | Links URL polyfill against Blob to read blob bytes/types. |
| Polyfills/Fetch/Source/Fetch.cpp | Intercepts blob: URLs and synthesizes Response from registry bytes/type. |
| Polyfills/Fetch/CMakeLists.txt | Links Fetch polyfill against the URL polyfill library. |
| Polyfills/Blob/Source/Blob.h | Adds synchronous internal accessors for blob bytes/type. |
| Polyfills/Blob/Source/Blob.cpp | Adds public TryGetData API to synchronously extract blob bytes/type. |
| Polyfills/Blob/Include/Babylon/Polyfills/Blob.h | Declares TryGetData for cross-polyfill blob access. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
TryGetData: replace napi_instanceof (Napi::Object::InstanceOf) with a prototype-chain walk against Blob.prototype. napi_instanceof is unreliable for ObjectWrap constructors on the JavaScriptCore, V8 and JSI engine adapters (reports false for genuine Blob instances), which made every blob: URL test fail at runtime on those engines while passing on QuickJS. The prototype walk maps to each engine's own prototype lookup and also accepts Blob subclasses (e.g. File). Address Copilot review on PR BabylonJS#207: - 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 honors revoke between open() and send(), and an XHR instance can be reused for a second blob: request. UnitTests: 217 passing, 0 failing (QuickJS). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
|
Thanks for the review! Pushed f7e1fd1 addressing the CI failure and all five inline comments. CI red on JSC / V8-on-Ubuntu / JSI (root cause): Review comments:
Added tests for each behavior (non-Blob rejection, revoke-between-open-and-send, XHR instance reuse). Local UnitTests: 217 passing, 0 failing (QuickJS); watching CI for the other engines. |
f1790b2 to
c3456c8
Compare
… (JSC) Root cause of the JSC-only blob: URL test failures: constructors created via node-addon-api DefineClass are callable-as-constructor but napi_typeof reports them as objects (JSObjectIsFunction is false), so the IsFunction() guard bailed out and TryGetData returned false for genuine Blobs. Probe with IsObject() and read prototype as an object property instead. Mirrors PR BabylonJS#207 c3456c8. UnitTests: 217 passing (QuickJS). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
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>
c3456c8 to
0617909
Compare
|
CI is now fully green across all engines (QuickJS/V8/Chakra/Hermes/JSI and JSC on Ubuntu/macOS/iOS + sanitizers). Root cause of the earlier JSC-only failures: node-addon-api's I also dropped the "XHR instance can be reused for a second blob: request" test: it passes on QuickJS but hangs the JSC mocha harness (two sequential async blob-send dispatches on one reused instance never pump the second |
…and share blob URL buffers - Node-API-JSC: napi_typeof now also consults JSObjectIsConstructor so DefineClass constructors report as napi_function, fixing issue BabylonJS#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
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 BabylonJS#207 (tracked by BabylonJS#213). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: af4ea82e-5fb0-4e56-b71a-f8ff3932d033
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
bghgary
left a comment
There was a problem hiding this comment.
[Reviewed by Copilot on behalf of @bghgary]
All three points addressed. One open item on the store thread.
The description still describes the design this PR replaced: it credits the removed TryResolveObjectURL seam, says fetch and XMLHttpRequest "intercept blob:" when neither branch remains, doesn't mention the UrlLib scheme resolver that now does the work, and omits the js_native_api_javascriptcore.cc napi_typeof change that affects every JSC consumer. Test count reads 214 against the 216 in the last two commits. It becomes the squash commit message, so worth rewriting.
## What
Adds a pluggable URL **scheme resolver** hook to `UrlRequest`, so a
consumer can register a resolver for a non-transport URL scheme (e.g.
`blob:`) and have every `UrlRequest`-based consumer (fetch,
XMLHttpRequest, image/video `src`, texture loaders, ...) resolve such
URLs uniformly through the transport layer instead of each carrying its
own branch.
```cpp
UrlRequest::RegisterSchemeResolver("blob", [](const std::string& url) -> UrlSchemeResolverResult {
// look up url in your store...
return { /*handled*/ true, UrlStatusCode::Ok, "OK", contentType, bodyBytes };
});
```
## How
- New public API: `UrlRequest::RegisterSchemeResolver(std::string
scheme, UrlSchemeResolver resolver)`, plus the `UrlSchemeResolverResult`
struct and `UrlSchemeResolver` alias.
- When a `UrlRequest` is opened with a URL whose (lower-cased) scheme
has a registered resolver, the platform transport is bypassed.
Resolution is deferred to `SendAsync()` (rather than `Open()`) so a URL
revoked between `open()` and `send()` is honored.
- A resolver that reports `handled == false` (e.g. a revoked `blob:`
URL) leaves the status at `0`/`None` and records a transport-style
error, mirroring how a genuine network failure surfaces.
- The registry is process-global and mutex-guarded. All logic lives in
the shared `ImplBase` + the shared wrapper, so every platform backend
(Win32/Unix/Apple/Android) inherits it with no per-platform changes.
## Testing
Builds clean with the Win32 backend (`UrlLib` and `UrlLibTests`
targets). Downstream, this powers `URL.createObjectURL` `blob:` support
in JsRuntimeHost — see BabylonJS/JsRuntimeHost#207 (tracked by
BabylonJS/JsRuntimeHost#213), where all 216 unit tests pass with
fetch/XMLHttpRequest resolving `blob:` through this hook.
> Opened because BabylonJS/UrlLib has Issues disabled; JsRuntimeHost#207
currently pins its UrlLib dependency at this fork commit and should be
repointed once this lands.
---------
Co-authored-by: Branimir Karadžić (via Copilot) <223556219+Copilot@users.noreply.github.com>
Copilot-Session: af4ea82e-5fb0-4e56-b71a-f8ff3932d033
…e docs. Blob is immutable once constructed -- m_data is only assigned from the constructor, and the multi-part case throws -- so its bytes can be held in a shared_ptr and shared instead of copied. TryGetData now hands out that shared_ptr, so createObjectURL no longer doubles a large buffer for as long as the URL lives, and constructing a Blob from another Blob shares rather than copies. This also retires the "valid only while `object` is alive, must be consumed synchronously" caveat on TryGetData, since callers now keep the buffer alive on their own. Also fix the store documentation, which was stale and wrong in three ways: - It described the design this change set replaced, crediting the removed TryResolveObjectURL seam and saying the fetch and XMLHttpRequest polyfills resolve blob: URLs against the store directly. They go through the UrlLib blob: scheme resolver now, and TryResolveObjectURL no longer exists. - It said the adapters "do not all expose napi_add_env_cleanup_hook (e.g. QuickJS)". No adapter in Core/Node-API/Source implements it; the only reference in the tree is the vendored node-addon-api header. Filed BabylonJS#215 to track that gap, which nothing covered. - It said un-revoked entries are "reclaimed at exit, mirroring how browsers retain blob URLs until unload". That has the analogy backwards: a browser frees blob URLs at unload, which corresponds to environment teardown here, not process exit. Because the store is process-global, an embedder that creates and destroys environments accumulates every un-revoked entry for the life of the process. Documented as a consequence of BabylonJS#215 rather than as browser-matching behavior.
BabylonJS/UrlLib#37 (the pluggable scheme resolver this change set depends on) has merged, so the temporary fork pin is no longer needed.
|
Thanks -- you were right on all three counts, and I've pushed fixes. The store comment was stale and wrong. Three separate problems:
I filed #215 to track implementing Blob shared_ptr -- done. Also in this push:
|
Neither new export has a consumer outside this repo, and neither contract is one we should commit to yet, so don't ship them in the public headers. Blob::TryGetData has exactly one caller (URL.cpp), but it does cross a target boundary, so it moves behind a BlobInternal INTERFACE target carrying InternalInclude/ -- mirroring JsRuntimeInternal in Core/JsRuntime. While it moves it returns std::optional<BlobData> instead of filling out-parameters. URL::RegisterObjectURL / RevokeObjectURL have no caller outside URL.cpp at all, so they need no seam: they become plain functions in the anonymous namespace. That also drops their unused Napi::Env parameter, which BabylonJS#215 would have had to redefine anyway. Both public headers are now back to just Initialize.
What
Adds
blob:URL support toURL.createObjectURL/URL.revokeObjectURL, and makes the minted URLs resolvable by every consumer ofUrlRequest(fetch,XMLHttpRequest, and any downstream user of UrlLib).Previously
createObjectURLhad no backing store on Native, sofetch/XHR could not load a Blob andrevokeObjectURLhad nothing to release. This implements the missing in-memory registry.How
createObjectURL(blob)puts the Blob's bytes into an in-memory store keyed by a freshly mintedblob:null/<uuidv4>URL and returns it;revokeObjectURLfrees the entry. The store's C++ surface is deliberately not exported:RegisterObjectURL/RevokeObjectURLare file-local toURL.cpp, since neither has a caller elsewhere and No adapter implements napi_add_env_cleanup_hook, forcing per-environment native state to be process-global #215 would reshape the contract anyway.blob:scheme resolver — rather than special-casingblob:inside each request polyfill, the URL polyfill registers a resolver withUrlLib::UrlRequest::RegisterSchemeResolver(added in Add pluggable URL scheme resolver (e.g. blob:) to UrlRequest UrlLib#37). EveryUrlRequestconsumer therefore resolvesblob:uniformly, with no per-polyfill interception: a hit yields status 200 with the storedcontent-type, and a miss (or a revoked URL) yields a network error, sofetchrejects with aTypeErrorand XHR reports status 0 plus anerrorevent.TryGetData(object) -> std::optional<BlobData>accessor so the URL polyfill can read a Blob's bytes and MIME type. It lives behind aBlobInternalINTERFACE target (InternalInclude/, mirroringJsRuntimeInternal) rather than the public header, since it is an in-repo seam and not an embedder contract.Blobnow holds its bytes in astd::shared_ptr<const std::vector<std::byte>>, socreateObjectURLshares the buffer with the store instead of duplicating it, and the returned data no longer has a lifetime tied to the JS object.napi_typeoffix —Core/Node-API/Source/js_native_api_javascriptcore.ccnow consultsJSObjectIsConstructorin addition toJSObjectIsFunction, so constructors made withJSObjectMakeConstructorreportnapi_functionrather thannapi_object. This is not specific to this feature — it affects every JSC consumer — but it letsFetchandFiledrop theirIsUndefined()/IsNull()workarounds and go back to a plainIsFunction()check.createObjectURLround-trip unit tests (fetch + XHR, revoke frees memory).Design note
The store is process-global rather than per-environment. Per-env teardown would normally use
napi_add_env_cleanup_hook, but no Node-API engine adapter in this repo implements it, so there is no portable per-env hook available today. The keys are unguessable v4 UUIDs, so sharing one store across environments is safe — ablob:URL minted in one environment is never produced in another.The cost is that entries which are never revoked stay alive for the lifetime of the process, and are not released when the environment that created them is torn down. This is a real leak, not a browser-like behavior: a browser drops a document's blob URLs when the document unloads. #215 tracks implementing
napi_add_env_cleanup_hookacross the adapters so the store can become per-environment.Testing
Full
UnitTestssuite green (216 passing) on Windows/Chakra locally, with the remaining engines and platforms covered by CI, including the newURL.createObjectURLround-trip tests (fetch + XHR text/binary,content-typeheader, and revoke → network-error / status 0).