-
Notifications
You must be signed in to change notification settings - Fork 23
Add File / FileReader polyfill #169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5f4c4be
52f6338
4480ad8
ac19d77
9e7b333
35bfa81
3de01db
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| set(SOURCES | ||
| "Include/Babylon/Polyfills/File.h" | ||
| "Source/File.h" | ||
| "Source/File.cpp" | ||
| "Source/FileReader.h" | ||
| "Source/FileReader.cpp") | ||
|
|
||
| add_library(File ${SOURCES}) | ||
| warnings_as_errors(File) | ||
|
|
||
| target_include_directories(File PUBLIC "Include") | ||
|
|
||
| target_link_libraries(File | ||
| PUBLIC JsRuntime) | ||
|
|
||
| set_property(TARGET File PROPERTY FOLDER Polyfills) | ||
| source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| #pragma once | ||
|
|
||
| #include <napi/env.h> | ||
| #include <Babylon/Api.h> | ||
|
|
||
| namespace Babylon::Polyfills::File | ||
| { | ||
| void BABYLON_API Initialize(Napi::Env env); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| # File | ||
|
|
||
| Implements the `File` and `FileReader` web APIs on top of the native `Blob` | ||
| polyfill provided by JsRuntimeHost. Babylon.js GLTF/OBJ serializer | ||
| round-trip codepaths construct `new File([blob], 'scene.glb')` and read it | ||
| back via `FileReader.readAsArrayBuffer(...)`, so the runtime needs both | ||
| constructors for those tests (and any consumer code that wraps serializer | ||
| output) to work. | ||
|
|
||
| ## Behaviour | ||
|
|
||
| * No-op when the runtime already exposes a global `File` / `FileReader` | ||
| (e.g. V8 in some embeddings). | ||
| * `File` is self-contained: the constructor delegates to the global | ||
| `Blob` constructor to build the underlying byte storage, then decorates | ||
| the instance with `name` and `lastModified`. Methods (`size`, `type`, | ||
| `arrayBuffer`, `text`, `bytes`) forward to the inner `Blob`. | ||
| * `FileReader` supports `readAsArrayBuffer`, `readAsText`, and | ||
| `readAsDataURL`, plus `abort`, `addEventListener` / | ||
| `removeEventListener` / `dispatchEvent`, and the standard `onload` / | ||
| `onerror` / `onloadstart` / `onloadend` / `onprogress` / `onabort` | ||
| handler slots. `abort()` invalidates in-flight reads via a monotonic | ||
| token so late-resolving `arrayBuffer()` promises cannot dispatch a | ||
| phantom `load` event after a user-initiated abort. | ||
| * `File` extends `Blob`: the JS-visible prototype chain is wired so | ||
| `new File(...) instanceof Blob === true`. Babylon.js core branches on | ||
| `instanceof Blob` in several places (fileTools, Offline/database, | ||
| abstractEngine, thinNativeEngine). | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| `Babylon::Polyfills::Blob::Initialize(env)` (from JsRuntimeHost) must be | ||
| called before `Babylon::Polyfills::File::Initialize(env)`; if `Blob` is | ||
| missing from the global object when `File::Initialize` runs, the `File` | ||
| constructor will not be registered. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| #include "File.h" | ||
| #include "FileReader.h" | ||
|
|
||
| #include <Babylon/Polyfills/File.h> | ||
|
|
||
| #include <chrono> | ||
| #include <string> | ||
|
|
||
| namespace Babylon::Polyfills::Internal | ||
| { | ||
| namespace | ||
| { | ||
| constexpr auto JS_FILE_CONSTRUCTOR_NAME = "File"; | ||
| constexpr auto JS_BLOB_CONSTRUCTOR_NAME = "Blob"; | ||
|
|
||
| // Wire File.prototype to inherit from Blob.prototype so that | ||
| // `new File(...) instanceof Blob === true`. The shim runs entirely | ||
| // in JS so each engine's quirks are handled by the JS try/catch: | ||
| // | ||
| // - V8 / Chakra: `File.prototype` is the real prototype that | ||
| // instances use, so the direct setPrototypeOf succeeds and the | ||
| // probe path is skipped. | ||
| // - JSC: `File.prototype` aliases Object.prototype (napi_define_class | ||
| // wraps JSObjectMakeConstructor; see JsRH#172). The direct call | ||
| // throws TypeError, the catch swallows it, and the probe path | ||
| // discovers the real napi-internal prototype via | ||
| // `Object.getPrototypeOf(new File())` and sets its [[Prototype]] | ||
| // to Blob.prototype. | ||
| // | ||
| // Doing this in JS rather than via Napi::Function::Call avoids a | ||
| // JSC napi-shim quirk where setPrototypeOf on Object.prototype | ||
| // escapes as an uncaught error instead of being capturable via | ||
| // IsExceptionPending. | ||
| constexpr auto JS_PROTOTYPE_CHAIN_SHIM = R"JS( | ||
| (function() { | ||
| if (typeof File !== 'function' || typeof Blob !== 'function') return; | ||
| var blobProto = Blob.prototype; | ||
| try { Object.setPrototypeOf(File.prototype, blobProto); } catch (e) {} | ||
| try { | ||
| var probe = new File([], ''); | ||
| if (!(probe instanceof Blob)) { | ||
| Object.setPrototypeOf(Object.getPrototypeOf(probe), blobProto); | ||
| } | ||
| } catch (e) {} | ||
| })(); | ||
| )JS"; | ||
| } | ||
|
|
||
| void File::Initialize(Napi::Env env) | ||
| { | ||
| auto global = env.Global(); | ||
|
|
||
| // No-op if the runtime already provides a global File. Cheapest | ||
| // check, and the common path on platforms with a native File. | ||
| if (!global.Get(JS_FILE_CONSTRUCTOR_NAME).IsUndefined()) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| // 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. | ||
| auto blob = global.Get(JS_BLOB_CONSTRUCTOR_NAME); | ||
| if (blob.IsUndefined() || blob.IsNull()) | ||
| { | ||
| throw Napi::Error::New(env, | ||
| "File polyfill requires the Blob polyfill to be installed first."); | ||
| } | ||
|
|
||
| Napi::Function func = DefineClass( | ||
| env, | ||
| JS_FILE_CONSTRUCTOR_NAME, | ||
| { | ||
| InstanceAccessor("size", &File::GetSize, nullptr), | ||
| InstanceAccessor("type", &File::GetType, nullptr), | ||
| InstanceAccessor("name", &File::GetName, nullptr), | ||
| InstanceAccessor("lastModified", &File::GetLastModified, nullptr), | ||
| InstanceMethod("arrayBuffer", &File::ArrayBuffer), | ||
| InstanceMethod("text", &File::Text), | ||
| InstanceMethod("bytes", &File::Bytes), | ||
| }); | ||
|
|
||
| global.Set(JS_FILE_CONSTRUCTOR_NAME, func); | ||
|
|
||
| // Wire File.prototype -> Blob.prototype via a tiny JS shim. See | ||
| // the JS_PROTOTYPE_CHAIN_SHIM comment for engine-specific rationale. | ||
| Napi::Eval(env, JS_PROTOTYPE_CHAIN_SHIM, "JsRuntimeHost-File-PrototypeChainShim.js"); | ||
| if (env.IsExceptionPending()) | ||
| { | ||
| // The shim itself wraps every operation in try/catch, so this | ||
| // should never fire. Belt-and-braces: clear so Initialize stays | ||
| // best-effort and the rest of the polyfill remains installed. | ||
| env.GetAndClearPendingException(); | ||
| } | ||
| } | ||
|
|
||
| File::File(const Napi::CallbackInfo& info) | ||
| : Napi::ObjectWrap<File>{info} | ||
| { | ||
| auto env = info.Env(); | ||
|
|
||
| // The WHATWG File constructor takes (fileBits, fileName, [options]). | ||
| // Both fileBits and fileName are required (USVString without | ||
| // `optional`), so missing either is a TypeError per WebIDL bindings. | ||
| if (info.Length() < 2) | ||
| { | ||
| throw Napi::TypeError::New(env, | ||
| "Failed to construct 'File': 2 arguments required, but only " + | ||
| std::to_string(info.Length()) + " present."); | ||
| } | ||
|
|
||
| Napi::Value parts = info[0]; | ||
| Napi::Value name = info[1]; | ||
| Napi::Value options = info.Length() > 2 ? info[2] : env.Undefined(); | ||
|
|
||
| // USVString conversion: undefined -> "undefined", null -> "null", | ||
| // numbers/objects -> their .toString() representation. Napi::Value:: | ||
| // ToString() routes through napi_coerce_to_string, which matches | ||
| // these semantics on all three engines. | ||
| m_name = name.ToString().Utf8Value(); | ||
|
|
||
| // Default lastModified to the current wall clock in milliseconds, | ||
| // matching Date.now() semantics used by the JS File constructor. | ||
| m_lastModified = static_cast<double>( | ||
| std::chrono::duration_cast<std::chrono::milliseconds>( | ||
| std::chrono::system_clock::now().time_since_epoch()) | ||
| .count()); | ||
|
|
||
| auto blobOptions = Napi::Object::New(env); | ||
|
|
||
| if (options.IsObject()) | ||
| { | ||
| auto optsObj = options.As<Napi::Object>(); | ||
| if (optsObj.Has("type")) | ||
| { | ||
| blobOptions.Set("type", optsObj.Get("type")); | ||
| } | ||
| if (optsObj.Has("lastModified")) | ||
| { | ||
| auto lm = optsObj.Get("lastModified"); | ||
| if (lm.IsNumber()) | ||
| { | ||
| m_lastModified = lm.As<Napi::Number>().DoubleValue(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Napi::Value partsArray; | ||
| if (parts.IsArray()) | ||
| { | ||
| partsArray = parts; | ||
| } | ||
| else | ||
| { | ||
| partsArray = Napi::Array::New(env, 0); | ||
| } | ||
|
|
||
| // Delegate byte-buffer construction to the native Blob polyfill so | ||
| // we benefit from its existing BlobPart handling (ArrayBuffer, | ||
| // typed array, string, Blob). | ||
| auto blobCtor = env.Global().Get(JS_BLOB_CONSTRUCTOR_NAME).As<Napi::Function>(); | ||
| auto blobInstance = blobCtor.New({partsArray, blobOptions}); | ||
| m_blob = Napi::Persistent(blobInstance); | ||
| } | ||
|
|
||
| Napi::Value File::GetSize(const Napi::CallbackInfo&) | ||
| { | ||
| return m_blob.Value().Get("size"); | ||
| } | ||
|
|
||
| Napi::Value File::GetType(const Napi::CallbackInfo&) | ||
| { | ||
| return m_blob.Value().Get("type"); | ||
| } | ||
|
|
||
| Napi::Value File::GetName(const Napi::CallbackInfo& info) | ||
| { | ||
| return Napi::String::New(info.Env(), m_name); | ||
| } | ||
|
|
||
| Napi::Value File::GetLastModified(const Napi::CallbackInfo& info) | ||
| { | ||
| return Napi::Number::New(info.Env(), m_lastModified); | ||
| } | ||
|
|
||
| Napi::Value File::ArrayBuffer(const Napi::CallbackInfo&) | ||
| { | ||
| auto blob = m_blob.Value(); | ||
| return blob.Get("arrayBuffer").As<Napi::Function>().Call(blob, {}); | ||
| } | ||
|
|
||
| Napi::Value File::Text(const Napi::CallbackInfo&) | ||
| { | ||
| auto blob = m_blob.Value(); | ||
| return blob.Get("text").As<Napi::Function>().Call(blob, {}); | ||
| } | ||
|
|
||
| Napi::Value File::Bytes(const Napi::CallbackInfo&) | ||
| { | ||
| auto blob = m_blob.Value(); | ||
| return blob.Get("bytes").As<Napi::Function>().Call(blob, {}); | ||
| } | ||
| } | ||
|
|
||
| namespace Babylon::Polyfills::File | ||
| { | ||
| void BABYLON_API Initialize(Napi::Env env) | ||
| { | ||
| Internal::File::Initialize(env); | ||
| Internal::FileReader::Initialize(env); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| #pragma once | ||
|
|
||
| #include <napi/napi.h> | ||
|
|
||
| #include <string> | ||
|
|
||
| namespace Babylon::Polyfills::Internal | ||
| { | ||
| class File final : public Napi::ObjectWrap<File> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 4480ad8: wired File.prototype → Blob.prototype via Object.setPrototypeOf in File::Initialize. This matters because Babylon.js core branches on instanceof Blob in packages/dev/core/src/Misc/fileTools.pure.ts, Offline/database.pure.ts, Engines/abstractEngine.pure.ts, and Engines/thinNativeEngine.pure.ts — without the prototype chain wired up those checks silently fail for File inputs and the wrong branch is taken (the serializer round-trip path bypasses the Blob handling). Internal m_blob composition stays as the implementation detail; only the JS-visible prototype chain is wired. New test in ests.ts: it("is an instance of Blob (prototype chain wired up)", function () {
const file = new File(["x"], "x.txt");
expect(file instanceof Blob).to.equal(true);
expect(file instanceof File).to.equal(true);
}); |
||
| { | ||
| public: | ||
| static void Initialize(Napi::Env env); | ||
|
|
||
| explicit File(const Napi::CallbackInfo& info); | ||
|
|
||
| private: | ||
| Napi::Value GetSize(const Napi::CallbackInfo& info); | ||
| Napi::Value GetType(const Napi::CallbackInfo& info); | ||
| Napi::Value GetName(const Napi::CallbackInfo& info); | ||
| Napi::Value GetLastModified(const Napi::CallbackInfo& info); | ||
|
|
||
| Napi::Value ArrayBuffer(const Napi::CallbackInfo& info); | ||
| Napi::Value Text(const Napi::CallbackInfo& info); | ||
| Napi::Value Bytes(const Napi::CallbackInfo& info); | ||
|
|
||
| Napi::ObjectReference m_blob; | ||
| std::string m_name; | ||
| double m_lastModified{0.0}; | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If #172 lands first, this whole shim collapses to a direct
setPrototypeOfin C++ (no JS eval, no try/catch fallback, no probe). Recommending we land #172 first and drop the shim entirely.