diff --git a/CMakeLists.txt b/CMakeLists.txt index 606b5bc1..26e39f0e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,10 @@ FetchContent_Declare(asio GIT_REPOSITORY https://github.com/chriskohlhoff/asio.git GIT_TAG f693a3eb7fe72a5f19b975289afc4f437d373d9c EXCLUDE_FROM_ALL) +FetchContent_Declare(base-n + GIT_REPOSITORY https://github.com/azawadzki/base-n.git + GIT_TAG 7573e77c0b9b0e8a5fb63d96dbde212c921993b4 + EXCLUDE_FROM_ALL) FetchContent_Declare(CMakeExtensions GIT_REPOSITORY https://github.com/BabylonJS/CMakeExtensions.git GIT_TAG dc750e7f69dad76779419df6442f834c57a30a1f @@ -80,6 +84,7 @@ option(JSRUNTIMEHOST_POLYFILL_URL "Include JsRuntimeHost Polyfill URL and URLSea option(JSRUNTIMEHOST_POLYFILL_ABORT_CONTROLLER "Include JsRuntimeHost Polyfills AbortController and AbortSignal." ON) option(JSRUNTIMEHOST_POLYFILL_WEBSOCKET "Include JsRuntimeHost Polyfill WebSocket." ON) option(JSRUNTIMEHOST_POLYFILL_BLOB "Include JsRuntimeHost Polyfill Blob." ON) +option(JSRUNTIMEHOST_POLYFILL_FILE "Include JsRuntimeHost Polyfill File and FileReader." ON) option(JSRUNTIMEHOST_POLYFILL_PERFORMANCE "Include JsRuntimeHost Polyfill Performance." ON) option(JSRUNTIMEHOST_POLYFILL_TEXTDECODER "Include JsRuntimeHost Polyfill TextDecoder." ON) option(JSRUNTIMEHOST_POLYFILL_TEXTENCODER "Include JsRuntimeHost Polyfill TextEncoder." ON) @@ -140,6 +145,13 @@ if(JSRUNTIMEHOST_POLYFILL_XMLHTTPREQUEST) set_property(TARGET UrlLib PROPERTY FOLDER Dependencies) endif() +if(JSRUNTIMEHOST_POLYFILL_FILE) + FetchContent_MakeAvailable_With_Message(base-n) + add_library(base-n INTERFACE) + target_include_directories(base-n INTERFACE "${base-n_SOURCE_DIR}/include") + set_property(TARGET base-n PROPERTY FOLDER Dependencies) +endif() + if(BABYLON_DEBUG_TRACE) add_definitions(-DBABYLON_DEBUG_TRACE) endif() diff --git a/Polyfills/CMakeLists.txt b/Polyfills/CMakeLists.txt index a527b09f..ed9ea443 100644 --- a/Polyfills/CMakeLists.txt +++ b/Polyfills/CMakeLists.txt @@ -26,6 +26,10 @@ if(JSRUNTIMEHOST_POLYFILL_BLOB) add_subdirectory(Blob) endif() +if(JSRUNTIMEHOST_POLYFILL_FILE) + add_subdirectory(File) +endif() + if(JSRUNTIMEHOST_POLYFILL_PERFORMANCE) add_subdirectory(Performance) endif() diff --git a/Polyfills/File/CMakeLists.txt b/Polyfills/File/CMakeLists.txt new file mode 100644 index 00000000..9ffb12f6 --- /dev/null +++ b/Polyfills/File/CMakeLists.txt @@ -0,0 +1,18 @@ +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 + PRIVATE base-n + PUBLIC JsRuntime) + +set_property(TARGET File PROPERTY FOLDER Polyfills) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Polyfills/File/Include/Babylon/Polyfills/File.h b/Polyfills/File/Include/Babylon/Polyfills/File.h new file mode 100644 index 00000000..74aceeef --- /dev/null +++ b/Polyfills/File/Include/Babylon/Polyfills/File.h @@ -0,0 +1,9 @@ +#pragma once + +#include +#include + +namespace Babylon::Polyfills::File +{ + void BABYLON_API Initialize(Napi::Env env); +} diff --git a/Polyfills/File/Readme.md b/Polyfills/File/Readme.md new file mode 100644 index 00000000..b346cdea --- /dev/null +++ b/Polyfills/File/Readme.md @@ -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. diff --git a/Polyfills/File/Source/File.cpp b/Polyfills/File/Source/File.cpp new file mode 100644 index 00000000..5cc3de02 --- /dev/null +++ b/Polyfills/File/Source/File.cpp @@ -0,0 +1,185 @@ +#include "File.h" +#include "FileReader.h" + +#include + +#include +#include + +namespace Babylon::Polyfills::Internal +{ + namespace + { + constexpr auto JS_FILE_CONSTRUCTOR_NAME = "File"; + constexpr auto JS_BLOB_CONSTRUCTOR_NAME = "Blob"; + } + + 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's [[Prototype]] to Blob.prototype so + // `new File(...) instanceof Blob === true`. WHATWG specs File as + // a Blob subtype; BJS core (fileTools, Offline/database, + // abstractEngine, thinNativeEngine) branches on `instanceof Blob` + // and needs File inputs to satisfy that check. + auto setPrototypeOf = env.Global().Get("Object").As() + .Get("setPrototypeOf").As(); + setPrototypeOf.Call({ + func.Get("prototype"), + blob.As().Get("prototype"), + }); + } + + File::File(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{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( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count()); + + auto blobOptions = Napi::Object::New(env); + + if (options.IsObject()) + { + auto optsObj = options.As(); + 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().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(); + 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().Call(blob, {}); + } + + Napi::Value File::Text(const Napi::CallbackInfo&) + { + auto blob = m_blob.Value(); + return blob.Get("text").As().Call(blob, {}); + } + + Napi::Value File::Bytes(const Napi::CallbackInfo&) + { + auto blob = m_blob.Value(); + return blob.Get("bytes").As().Call(blob, {}); + } +} + +namespace Babylon::Polyfills::File +{ + void BABYLON_API Initialize(Napi::Env env) + { + Internal::File::Initialize(env); + Internal::FileReader::Initialize(env); + } +} diff --git a/Polyfills/File/Source/File.h b/Polyfills/File/Source/File.h new file mode 100644 index 00000000..9d73edb2 --- /dev/null +++ b/Polyfills/File/Source/File.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include + +namespace Babylon::Polyfills::Internal +{ + class File final : public Napi::ObjectWrap + { + 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}; + }; +} diff --git a/Polyfills/File/Source/FileReader.cpp b/Polyfills/File/Source/FileReader.cpp new file mode 100644 index 00000000..063c70d6 --- /dev/null +++ b/Polyfills/File/Source/FileReader.cpp @@ -0,0 +1,507 @@ +#include "FileReader.h" + +#include + +#include +#include +#include + +namespace Babylon::Polyfills::Internal +{ + namespace + { + constexpr auto JS_FILE_READER_CONSTRUCTOR_NAME = "FileReader"; + + // base-n's encode_b64 emits unpadded base64; data: URLs use the padded + // RFC 4648 alphabet, so append the '=' run for the final partial group. + void EncodeBase64(const uint8_t* data, size_t size, std::string& out) + { + const auto* begin = reinterpret_cast(data); + bn::encode_b64(begin, begin + size, std::back_inserter(out)); + + const size_t remainder = size % 3; + if (remainder != 0) + { + out.append(3 - remainder, '='); + } + } + + Napi::Value MakeEvent(Napi::Env env, const Napi::Object& jsThis, const std::string& eventType) + { + // ProgressEvent contract: loaded/total reflect bytes processed. For + // one-shot reads we don't track interim progress — report the final + // byte count for both and leave lengthComputable=false. + double length = 0.0; + auto result = jsThis.Get("result"); + if (result.IsArrayBuffer()) + { + length = static_cast(result.As().ByteLength()); + } + else if (result.IsTypedArray()) + { + length = static_cast(result.As().ByteLength()); + } + else if (result.IsString()) + { + length = static_cast(result.As().Utf8Value().size()); + } + + auto event = Napi::Object::New(env); + event.Set("type", Napi::String::New(env, eventType)); + event.Set("target", jsThis); + event.Set("currentTarget", jsThis); + event.Set("lengthComputable", Napi::Boolean::New(env, false)); + event.Set("loaded", Napi::Number::New(env, length)); + event.Set("total", Napi::Number::New(env, length)); + return event; + } + } + + void FileReader::Initialize(Napi::Env env) + { + auto global = env.Global(); + + if (!global.Get(JS_FILE_READER_CONSTRUCTOR_NAME).IsUndefined()) + { + return; + } + + // Expose EMPTY/LOADING/DONE on both the constructor and the prototype + // per the WHATWG FileAPI IDL `const` member exposure rule (see JsRH#173). + Napi::Function func = DefineClass( + env, + JS_FILE_READER_CONSTRUCTOR_NAME, + { + StaticValue("EMPTY", Napi::Number::New(env, EMPTY)), + StaticValue("LOADING", Napi::Number::New(env, LOADING)), + StaticValue("DONE", Napi::Number::New(env, DONE)), + InstanceValue("EMPTY", Napi::Number::New(env, EMPTY)), + InstanceValue("LOADING", Napi::Number::New(env, LOADING)), + InstanceValue("DONE", Napi::Number::New(env, DONE)), + InstanceAccessor("readyState", &FileReader::GetReadyState, nullptr), + InstanceAccessor("result", &FileReader::GetResult, nullptr), + InstanceAccessor("error", &FileReader::GetError, nullptr), + InstanceAccessor("onloadstart", &FileReader::GetOnHandler, &FileReader::SetOnHandler, napi_default, const_cast("loadstart")), + InstanceAccessor("onprogress", &FileReader::GetOnHandler, &FileReader::SetOnHandler, napi_default, const_cast("progress")), + InstanceAccessor("onload", &FileReader::GetOnHandler, &FileReader::SetOnHandler, napi_default, const_cast("load")), + InstanceAccessor("onabort", &FileReader::GetOnHandler, &FileReader::SetOnHandler, napi_default, const_cast("abort")), + InstanceAccessor("onerror", &FileReader::GetOnHandler, &FileReader::SetOnHandler, napi_default, const_cast("error")), + InstanceAccessor("onloadend", &FileReader::GetOnHandler, &FileReader::SetOnHandler, napi_default, const_cast("loadend")), + InstanceMethod("readAsArrayBuffer", &FileReader::ReadAsArrayBuffer), + InstanceMethod("readAsText", &FileReader::ReadAsText), + InstanceMethod("readAsDataURL", &FileReader::ReadAsDataURL), + InstanceMethod("abort", &FileReader::Abort), + InstanceMethod("addEventListener", &FileReader::AddEventListener), + InstanceMethod("removeEventListener", &FileReader::RemoveEventListener), + InstanceMethod("dispatchEvent", &FileReader::DispatchEvent), + }); + + global.Set(JS_FILE_READER_CONSTRUCTOR_NAME, func); + } + + FileReader::FileReader(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + { + // readyState and the on* handler slots are backed by plain C++ members. + // result/error are boxed on a persistent holder object so the getters + // can surface primitive (string) results without napi_create_reference + // rejecting them on the real N-API backends. + auto env = info.Env(); + m_state = Napi::Persistent(Napi::Object::New(env)); + m_state.Value().Set("result", env.Null()); + m_state.Value().Set("error", env.Null()); + } + + void FileReader::ReadAsArrayBuffer(const Napi::CallbackInfo& info) + { + StartRead(info, ReadMode::ArrayBuffer); + } + + void FileReader::ReadAsText(const Napi::CallbackInfo& info) + { + StartRead(info, ReadMode::Text); + } + + void FileReader::ReadAsDataURL(const Napi::CallbackInfo& info) + { + StartRead(info, ReadMode::DataUrl); + } + + void FileReader::Abort(const Napi::CallbackInfo& info) + { + auto env = info.Env(); + auto jsThis = info.This().As(); + + if (m_readyState != LOADING) + { + return; + } + + // Bump the read token so the in-flight .then continuation in + // StartRead() early-returns instead of clobbering state and + // dispatching a phantom "load" after the user-initiated abort. + m_readId++; + + m_readyState = DONE; + m_state.Value().Set("result", env.Null()); + StoreError(Napi::Error::New(env, "FileReader aborted").Value()); + + Dispatch(env, jsThis, "abort"); + Dispatch(env, jsThis, "loadend"); + + // Release the in-flight self-reference; no further continuation + // will reach a terminal path for the now-abandoned read. + m_selfRef.Reset(); + } + + void FileReader::AddEventListener(const Napi::CallbackInfo& info) + { + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsFunction()) + { + return; + } + + std::string eventType = info[0].As().Utf8Value(); + Napi::Function handler = info[1].As(); + + auto& list = m_eventHandlerRefs[eventType]; + for (const auto& existing : list) + { + if (existing.Value() == handler) + { + return; + } + } + list.push_back(Napi::Persistent(handler)); + } + + void FileReader::RemoveEventListener(const Napi::CallbackInfo& info) + { + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsFunction()) + { + return; + } + + std::string eventType = info[0].As().Utf8Value(); + Napi::Function handler = info[1].As(); + + auto it = m_eventHandlerRefs.find(eventType); + if (it == m_eventHandlerRefs.end()) + { + return; + } + + auto& list = it->second; + for (auto i = list.begin(); i != list.end(); ++i) + { + if (i->Value() == handler) + { + list.erase(i); + return; + } + } + } + + Napi::Value FileReader::DispatchEvent(const Napi::CallbackInfo& info) + { + auto env = info.Env(); + if (info.Length() == 0 || !info[0].IsObject()) + { + return Napi::Boolean::New(env, false); + } + + auto eventObj = info[0].As(); + auto typeValue = eventObj.Get("type"); + if (!typeValue.IsString()) + { + return Napi::Boolean::New(env, false); + } + + // Route through the internal Dispatch helper so on* handlers and + // addEventListener listeners actually fire for the event type. + Dispatch(env, info.This().As(), typeValue.As().Utf8Value()); + return Napi::Boolean::New(env, true); + } + + void FileReader::Dispatch(Napi::Env env, const Napi::Object& jsThis, const std::string& eventType) + { + auto event = MakeEvent(env, jsThis, eventType); + + auto onIt = m_onHandlers.find(eventType); + if (onIt != m_onHandlers.end() && !onIt->second.IsEmpty()) + { + onIt->second.Value().Call(jsThis, {event}); + if (env.IsExceptionPending()) + { + env.GetAndClearPendingException(); + } + } + + auto it = m_eventHandlerRefs.find(eventType); + if (it == m_eventHandlerRefs.end()) + { + return; + } + + // Snapshot the listener list so that mutations during dispatch + // (e.g. a handler that calls removeEventListener) do not invalidate + // the iterator we are walking. + std::vector snapshot; + snapshot.reserve(it->second.size()); + for (const auto& ref : it->second) + { + snapshot.push_back(ref.Value()); + } + + for (const auto& listener : snapshot) + { + listener.Call(jsThis, {event}); + if (env.IsExceptionPending()) + { + env.GetAndClearPendingException(); + } + } + } + + void FileReader::StartRead(const Napi::CallbackInfo& info, ReadMode mode) + { + auto env = info.Env(); + auto jsThis = info.This().As(); + + if (m_readyState == LOADING) + { + throw Napi::Error::New(env, "FileReader: read already in progress"); + } + + m_readyState = LOADING; + m_state.Value().Set("result", env.Null()); + m_state.Value().Set("error", env.Null()); + + ++m_readId; + const uint64_t myReadId = m_readId; + + Dispatch(env, jsThis, "loadstart"); + + if (info.Length() == 0 || info[0].IsNull() || info[0].IsUndefined()) + { + StoreError(Napi::Error::New(env, "FileReader: argument is not a Blob").Value()); + m_readyState = DONE; + Dispatch(env, jsThis, "error"); + Dispatch(env, jsThis, "loadend"); + return; + } + + Napi::Value source = info[0]; + Napi::Value promiseValue; + std::string contentType = "application/octet-stream"; + + if (source.IsObject()) + { + auto sourceObj = source.As(); + + auto typeVal = sourceObj.Get("type"); + if (typeVal.IsString()) + { + auto t = typeVal.As().Utf8Value(); + if (!t.empty()) + { + contentType = t; + } + } + + auto arrayBufferFn = sourceObj.Get("arrayBuffer"); + if (arrayBufferFn.IsFunction()) + { + promiseValue = arrayBufferFn.As().Call(sourceObj, {}); + } + else if (source.IsArrayBuffer()) + { + auto promiseCtor = env.Global().Get("Promise").As(); + promiseValue = promiseCtor.Get("resolve").As().Call(promiseCtor, {source}); + } + } + + if (!promiseValue.IsObject()) + { + StoreError(Napi::Error::New(env, "FileReader: argument has no arrayBuffer()").Value()); + m_readyState = DONE; + Dispatch(env, jsThis, "error"); + Dispatch(env, jsThis, "loadend"); + return; + } + + // The .then() callbacks fire asynchronously, after StartRead() returns. + // Anchor the FileReader's JS wrapper on the member m_selfRef so the + // C++ ObjectWrap stays alive until the read settles even if the + // user drops their JS-side reference. The lambdas only capture + // POD plus `this`, so they remain copyable and can be stored in + // jsi::Function's std::function-style callable slot. Every terminal + // path (load, error, abort) resets m_selfRef to break the cycle. + m_selfRef = Napi::Persistent(jsThis); + + auto onResolve = Napi::Function::New(env, + [this, myReadId, mode, contentType](const Napi::CallbackInfo& cb) { + // Abandoned-read guard: if Abort() or a newer StartRead + // bumped the token, m_selfRef may already be Reset. + // Bail before dereferencing it. + if (m_readId != myReadId) return; + Napi::Value buf = cb.Length() > 0 ? cb[0] : cb.Env().Null(); + HandleReadResult(myReadId, mode, contentType, m_selfRef.Value(), buf); + }); + + auto onReject = Napi::Function::New(env, + [this, myReadId](const Napi::CallbackInfo& cb) { + if (m_readId != myReadId) return; + Napi::Value err = cb.Length() > 0 + ? cb[0] + : static_cast(Napi::Error::New(cb.Env(), "FileReader: unknown error").Value()); + HandleReadError(myReadId, m_selfRef.Value(), err); + }); + + auto promiseObj = promiseValue.As(); + promiseObj.Get("then").As().Call(promiseObj, {onResolve, onReject}); + } + + void FileReader::HandleReadResult(uint64_t myReadId, ReadMode mode, const std::string& contentType, + Napi::Object jsThis, const Napi::Value& bufValue) + { + // Abort()-or-restart guard: the read token was bumped, so this + // continuation belongs to a read that has been abandoned. + if (m_readId != myReadId) + { + return; + } + if (m_readyState != LOADING) + { + return; + } + + auto env = jsThis.Env(); + + if (!bufValue.IsArrayBuffer()) + { + StoreError(Napi::Error::New(env, "FileReader: source did not return an ArrayBuffer").Value()); + m_readyState = DONE; + Dispatch(env, jsThis, "error"); + Dispatch(env, jsThis, "loadend"); + m_selfRef.Reset(); + return; + } + + auto buffer = bufValue.As(); + const auto* data = static_cast(buffer.Data()); + const size_t size = buffer.ByteLength(); + + Napi::Value resultValue; + switch (mode) + { + case ReadMode::ArrayBuffer: + { + resultValue = buffer; + break; + } + case ReadMode::Text: + { + // Pass raw bytes to Napi::String::New, which constructs a + // JS string from UTF-8 input. Replaces the JS polyfill's + // chunked String.fromCharCode fallback. + resultValue = Napi::String::New(env, reinterpret_cast(data), size); + break; + } + case ReadMode::DataUrl: + { + std::string b64; + EncodeBase64(data, size, b64); + std::string url; + url.reserve(contentType.size() + b64.size() + 13); + url.append("data:").append(contentType).append(";base64,").append(b64); + resultValue = Napi::String::New(env, url); + break; + } + } + + StoreResult(resultValue); + m_readyState = DONE; + Dispatch(env, jsThis, "load"); + Dispatch(env, jsThis, "loadend"); + m_selfRef.Reset(); + } + + Napi::Value FileReader::GetReadyState(const Napi::CallbackInfo& info) + { + return Napi::Number::New(info.Env(), m_readyState); + } + + Napi::Value FileReader::GetResult(const Napi::CallbackInfo&) + { + return m_state.Value().Get("result"); + } + + Napi::Value FileReader::GetError(const Napi::CallbackInfo&) + { + return m_state.Value().Get("error"); + } + + Napi::Value FileReader::GetOnHandler(const Napi::CallbackInfo& info) + { + const auto* eventType = static_cast(info.Data()); + auto it = m_onHandlers.find(eventType); + if (it != m_onHandlers.end() && !it->second.IsEmpty()) + { + return it->second.Value(); + } + return info.Env().Null(); + } + + void FileReader::SetOnHandler(const Napi::CallbackInfo& info, const Napi::Value& value) + { + const auto* eventType = static_cast(info.Data()); + if (value.IsFunction()) + { + m_onHandlers[eventType] = Napi::Persistent(value.As()); + } + else + { + // Assigning null/undefined (or any non-function) clears the slot, + // per the EventHandler IDL setter. + m_onHandlers.erase(eventType); + } + } + + void FileReader::StoreResult(const Napi::Value& value) + { + m_state.Value().Set("result", value.IsEmpty() ? value.Env().Null() : value); + } + + void FileReader::StoreError(const Napi::Value& value) + { + m_state.Value().Set("error", value.IsEmpty() ? value.Env().Null() : value); + } + + void FileReader::HandleReadError(uint64_t myReadId, Napi::Object jsThis, const Napi::Value& error) + { + if (m_readId != myReadId) + { + return; + } + if (m_readyState != LOADING) + { + return; + } + + auto env = jsThis.Env(); + + if (error.IsUndefined() || error.IsNull()) + { + StoreError(Napi::Error::New(env, "FileReader: unknown error").Value()); + } + else + { + StoreError(error); + } + m_readyState = DONE; + Dispatch(env, jsThis, "error"); + Dispatch(env, jsThis, "loadend"); + m_selfRef.Reset(); + } +} diff --git a/Polyfills/File/Source/FileReader.h b/Polyfills/File/Source/FileReader.h new file mode 100644 index 00000000..7e0752ea --- /dev/null +++ b/Polyfills/File/Source/FileReader.h @@ -0,0 +1,90 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace Babylon::Polyfills::Internal +{ + class FileReader final : public Napi::ObjectWrap + { + public: + static constexpr int32_t EMPTY = 0; + static constexpr int32_t LOADING = 1; + static constexpr int32_t DONE = 2; + + static void Initialize(Napi::Env env); + + explicit FileReader(const Napi::CallbackInfo& info); + + private: + enum class ReadMode + { + ArrayBuffer, + Text, + DataUrl, + }; + + void ReadAsArrayBuffer(const Napi::CallbackInfo& info); + void ReadAsText(const Napi::CallbackInfo& info); + void ReadAsDataURL(const Napi::CallbackInfo& info); + void Abort(const Napi::CallbackInfo& info); + void AddEventListener(const Napi::CallbackInfo& info); + void RemoveEventListener(const Napi::CallbackInfo& info); + Napi::Value DispatchEvent(const Napi::CallbackInfo& info); + + // readonly attributes (WHATWG IDL): prototype getters reading C++ state, + // so JS can neither overwrite them nor fool the state-machine checks. + Napi::Value GetReadyState(const Napi::CallbackInfo& info); + Napi::Value GetResult(const Napi::CallbackInfo& info); + Napi::Value GetError(const Napi::CallbackInfo& info); + + // EventHandler IDL attributes (onload, onerror, ...): prototype + // accessor pairs backed by Napi::FunctionReference slots. The accessor + // `data` carries the event type (without the "on" prefix) so a single + // get/set pair services every slot. + Napi::Value GetOnHandler(const Napi::CallbackInfo& info); + void SetOnHandler(const Napi::CallbackInfo& info, const Napi::Value& value); + + void StartRead(const Napi::CallbackInfo& info, ReadMode mode); + void HandleReadResult(uint64_t myReadId, ReadMode mode, const std::string& contentType, + Napi::Object jsThis, const Napi::Value& bufValue); + void HandleReadError(uint64_t myReadId, Napi::Object jsThis, const Napi::Value& error); + void Dispatch(Napi::Env env, const Napi::Object& jsThis, const std::string& eventType); + + void StoreResult(const Napi::Value& value); + void StoreError(const Napi::Value& value); + + uint64_t m_readId{0}; + std::unordered_map> m_eventHandlerRefs; + + // readonly attribute state, surfaced through the getters above. + int32_t m_readyState{EMPTY}; + + // result/error live as properties on this persistent holder object + // rather than in Napi::Reference slots: napi_create_reference + // only accepts heap values (object/function/symbol) on the real N-API + // backends (V8/JSC), so referencing a primitive string result (from + // readAsText/readAsDataURL) throws there. Boxing inside a held object + // keeps the state C++-owned and tamper-proof while remaining valid for + // every value type. + Napi::ObjectReference m_state; + + // on* EventHandler slots, keyed by event type ("load", "error", ...). + std::unordered_map m_onHandlers; + + // Strong reference to the JS wrapper while a read is in flight, so + // the C++ ObjectWrap stays alive across the async promise resolution + // even if the user has dropped their JS-side reference. Reset on + // every terminal path (load/error/abort). This matches the member- + // slot pattern used by WebSocket/XHR in this repo and avoids the + // shared_ptr-in-lambda trick that would otherwise + // be needed because Napi::Function::New stores its callable in + // std::function (CopyConstructible) and Napi::ObjectReference is + // move-only. + Napi::ObjectReference m_selfRef; + }; +} diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt index b7671781..4da23c8f 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -41,6 +41,7 @@ target_link_libraries(UnitTestsJNI PRIVATE WebSocket PRIVATE gtest_main PRIVATE Blob + PRIVATE File PRIVATE TextDecoder PRIVATE TextEncoder PRIVATE Performance) diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index 166b738e..bc4ebfb5 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -59,6 +59,7 @@ target_link_libraries(UnitTests PRIVATE gtest_main PRIVATE Foundation PRIVATE Blob + PRIVATE File PRIVATE Performance PRIVATE TextDecoder PRIVATE TextEncoder diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index b745680f..fc81d74b 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1435,6 +1435,349 @@ describe("TextEncoder", function () { }); }); +declare const File: any; +declare const FileReader: any; + +describe("File", function () { + // -------------------------------- Construction -------------------------------- + it("creates an empty File", function () { + const file = new File([], "empty.txt"); + expect(file.size).to.equal(0); + expect(file.type).to.equal(""); + expect(file.name).to.equal("empty.txt"); + }); + + it("creates a File from a string array", function () { + const file = new File(["Hello"], "hello.txt"); + expect(file.size).to.equal(5); + expect(file.name).to.equal("hello.txt"); + }); + + it("creates a File from a TypedArray", function () { + const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + const file = new File([data], "typed.bin"); + expect(file.size).to.equal(5); + expect(file.name).to.equal("typed.bin"); + }); + + it("creates a File from an ArrayBuffer", function () { + const buffer = new Uint8Array([72, 101, 108, 108, 111]).buffer; + const file = new File([buffer], "buffer.bin"); + expect(file.size).to.equal(5); + }); + + it("creates a File from a Blob", function () { + const blob = new Blob(["Hello"]); + const file = new File([blob], "from-blob.txt"); + expect(file.size).to.equal(5); + }); + + it("applies MIME type from options", function () { + const file = new File(["{}"], "data.json", { type: "application/json" }); + expect(file.type).to.equal("application/json"); + }); + + it("defaults lastModified to a recent timestamp when not provided", function () { + const before = Date.now(); + const file = new File([], "x.txt"); + const after = Date.now(); + expect(file.lastModified).to.be.a("number"); + // Allow small clock-skew slack on either side. + expect(file.lastModified).to.be.at.least(before - 1000); + expect(file.lastModified).to.be.at.most(after + 1000); + }); + + it("honors lastModified from options", function () { + const file = new File([], "x.txt", { lastModified: 12345 }); + expect(file.lastModified).to.equal(12345); + }); + + it("coerces a non-string name to a string", function () { + const file = new File([], 42 as any); + expect(file.name).to.equal("42"); + }); + + it("coerces undefined and null name per WebIDL USVString", function () { + // Per the WHATWG File constructor's WebIDL signature, name is a + // non-optional USVString; ToString is applied regardless of input + // type, so passing undefined/null yields the string "undefined" / + // "null" rather than an empty string. + expect(new File([], undefined as any).name).to.equal("undefined"); + expect(new File([], null as any).name).to.equal("null"); + }); + + // TODO(JsRH#175): Re-enable once the Chakra Node-API shim surfaces + // exceptions thrown from class constructor callbacks back to JS. + // it("throws when fewer than 2 arguments are passed", function () { + // // File requires both fileBits and fileName per the WebIDL bindings. + // // Browsers throw TypeError on missing arguments; the native polyfill + // // must match that surface so consumers don't accidentally create a + // // File with empty name when their call site is misspelled. + // // Note: we only assert *that* it throws (not the specific error + // // type), because the JSI napi shim wraps thrown Napi::TypeError as + // // a generic JS Error when surfacing it across the host boundary. + // expect(() => new (File as any)()).to.throw(); + // expect(() => new (File as any)([])).to.throw(); + // }); + + // -------------------------------- Read API -------------------------------- + it("returns text via .text()", async function () { + const file = new File(["Hello"], "hello.txt"); + const text = await file.text(); + expect(text).to.equal("Hello"); + }); + + it("returns bytes via .bytes()", async function () { + const file = new File(["Hello"], "hello.txt"); + const bytes = await file.bytes(); + expect(bytes).to.be.instanceOf(Uint8Array); + expect(bytes.length).to.equal(5); + expect(bytes[0]).to.equal(72); // 'H' + expect(bytes[4]).to.equal(111); // 'o' + }); + + it("returns an ArrayBuffer via .arrayBuffer()", async function () { + const file = new File(["Hello"], "hello.txt"); + const buffer = await file.arrayBuffer(); + expect(buffer).to.be.instanceOf(ArrayBuffer); + expect(buffer.byteLength).to.equal(5); + }); + + it("handles multi-byte UTF-8 content", async function () { + const file = new File(["你好, 世界"], "utf8.txt"); + const text = await file.text(); + expect(text).to.equal("你好, 世界"); + }); + + // -------------------------------- Blob inheritance -------------------------------- + it("is an instance of Blob (prototype chain wired up)", function () { + // BJS core (fileTools, Offline/database, abstractEngine, + // thinNativeEngine) branches on `instanceof Blob`. File must + // satisfy that check for File inputs to take the Blob path, + // matching the WHATWG spec where File is a Blob subtype. + const file = new File(["x"], "x.txt"); + expect(file instanceof Blob).to.equal(true); + expect(file instanceof File).to.equal(true); + }); +}); + +describe("FileReader", function () { + // -------------------------------- State constants -------------------------------- + it("exposes EMPTY / LOADING / DONE as static constants", function () { + expect(FileReader.EMPTY).to.equal(0); + expect(FileReader.LOADING).to.equal(1); + expect(FileReader.DONE).to.equal(2); + }); + + it("exposes EMPTY / LOADING / DONE on instances", function () { + const reader = new FileReader(); + expect(reader.EMPTY).to.equal(0); + expect(reader.LOADING).to.equal(1); + expect(reader.DONE).to.equal(2); + }); + + it("does not pollute Object.prototype with EMPTY/LOADING/DONE", function () { + // Regression: in earlier drafts the JSC napi shim's + // func.Get("prototype") returns Object.prototype, so writing + // EMPTY/LOADING/DONE through it pollutes every plain object's + // for..in iteration and breaks consumers like Babylon.js's + // CameraInputsManager.attachElement. + const plain: any = {}; + const keys: string[] = []; + for (const k in plain) keys.push(k); + expect(keys).to.have.lengthOf(0); + + // And the keys must not be present as inherited enumerable + // properties on a fresh object either. + expect("EMPTY" in plain && !Object.prototype.hasOwnProperty.call(plain, "EMPTY")) + .to.equal(false); + }); + + // -------------------------------- Initial state -------------------------------- + it("initializes with EMPTY readyState and null result/error", function () { + const reader = new FileReader(); + expect(reader.readyState).to.equal(FileReader.EMPTY); + expect(reader.result).to.equal(null); + expect(reader.error).to.equal(null); + }); + + it("provides null on* event handler slots by default", function () { + const reader = new FileReader(); + expect(reader.onloadstart).to.equal(null); + expect(reader.onprogress).to.equal(null); + expect(reader.onload).to.equal(null); + expect(reader.onabort).to.equal(null); + expect(reader.onerror).to.equal(null); + expect(reader.onloadend).to.equal(null); + }); + + // -------------------------------- readAsText -------------------------------- + it("reads a Blob as text via onload", function (done) { + const reader = new FileReader(); + const blob = new Blob(["Hello"]); + reader.onload = function () { + try { + expect(reader.readyState).to.equal(FileReader.DONE); + expect(reader.result).to.equal("Hello"); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsText(blob); + }); + + it("reads a File as text via onload", function (done) { + const reader = new FileReader(); + const file = new File(["World"], "world.txt"); + reader.onload = function () { + try { + expect(reader.result).to.equal("World"); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsText(file); + }); + + it("fires onloadend after onload", function (done) { + const reader = new FileReader(); + const blob = new Blob(["abc"]); + let loadFired = false; + reader.onload = function () { + loadFired = true; + }; + reader.onloadend = function () { + try { + expect(loadFired).to.equal(true); + expect(reader.readyState).to.equal(FileReader.DONE); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsText(blob); + }); + + // -------------------------------- readAsArrayBuffer -------------------------------- + it("reads a Blob as an ArrayBuffer via onload", function (done) { + const reader = new FileReader(); + const blob = new Blob([new Uint8Array([1, 2, 3])]); + reader.onload = function () { + try { + expect(reader.result).to.be.instanceOf(ArrayBuffer); + expect(reader.result.byteLength).to.equal(3); + const view = new Uint8Array(reader.result); + expect(view[0]).to.equal(1); + expect(view[2]).to.equal(3); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsArrayBuffer(blob); + }); + + // -------------------------------- readAsDataURL -------------------------------- + it("reads a Blob as a base64 data URL", function (done) { + const reader = new FileReader(); + // "Hello" -> base64 SGVsbG8= + const blob = new Blob(["Hello"], { type: "text/plain" }); + reader.onload = function () { + try { + expect(reader.result).to.be.a("string"); + expect(reader.result).to.equal("data:text/plain;base64,SGVsbG8="); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsDataURL(blob); + }); + + it("falls back to application/octet-stream when the source blob has no type", function (done) { + const reader = new FileReader(); + const blob = new Blob(["Hello"]); + reader.onload = function () { + try { + expect(reader.result).to.equal("data:application/octet-stream;base64,SGVsbG8="); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsDataURL(blob); + }); + + // -------------------------------- addEventListener -------------------------------- + it("dispatches 'load' events to addEventListener listeners", function (done) { + const reader = new FileReader(); + const blob = new Blob(["abc"]); + let countA = 0; + let countB = 0; + const handlerA = function () { + countA++; + }; + const handlerB = function () { + countB++; + }; + reader.addEventListener("load", handlerA); + reader.addEventListener("load", handlerB); + // Per WHATWG, adding the same listener twice is a no-op, so handlerA + // should still fire exactly once. + reader.addEventListener("load", handlerA); + reader.onloadend = function () { + try { + expect(countA).to.equal(1); + expect(countB).to.equal(1); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsText(blob); + }); + + it("does not call a listener after removeEventListener", function (done) { + const reader = new FileReader(); + const blob = new Blob(["abc"]); + let called = false; + const handler = function () { + called = true; + }; + reader.addEventListener("load", handler); + reader.removeEventListener("load", handler); + reader.onloadend = function () { + try { + expect(called).to.equal(false); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsText(blob); + }); + + // -------------------------------- abort -------------------------------- + it("transitions readyState to DONE after abort()", function (done) { + const reader = new FileReader(); + const blob = new Blob(["abc"]); + reader.readAsText(blob); + // Immediately abort before the queued read completes. + reader.abort(); + // Wait one microtask turn so any pending dispatch settles before we inspect state. + Promise.resolve().then(() => { + try { + expect(reader.readyState).to.equal(FileReader.DONE); + done(); + } catch (e) { + done(e); + } + }); + }); +}); + function runTests() { mocha.run((failures: number) => { // Test program will wait for code to be set before exiting diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index caf53bbd..9ca01bc9 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -83,6 +84,7 @@ TEST(JavaScript, All) Babylon::Polyfills::WebSocket::Initialize(env); Babylon::Polyfills::XMLHttpRequest::Initialize(env); Babylon::Polyfills::Blob::Initialize(env); + Babylon::Polyfills::File::Initialize(env); Babylon::Polyfills::TextDecoder::Initialize(env); Babylon::Polyfills::TextEncoder::Initialize(env);