diff --git a/CMakeLists.txt b/CMakeLists.txt index 6318344e..8d11b52b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,6 +81,7 @@ option(JSRUNTIMEHOST_POLYFILL_ABORT_CONTROLLER "Include JsRuntimeHost Polyfills option(JSRUNTIMEHOST_POLYFILL_WEBSOCKET "Include JsRuntimeHost Polyfill WebSocket." ON) option(JSRUNTIMEHOST_POLYFILL_BLOB "Include JsRuntimeHost Polyfill Blob." ON) option(JSRUNTIMEHOST_POLYFILL_PERFORMANCE "Include JsRuntimeHost Polyfill Performance." ON) +option(JSRUNTIMEHOST_POLYFILL_TEXTDECODER "Include JsRuntimeHost Polyfill TextDecoder." ON) # Sanitizers option(ENABLE_SANITIZERS "Enable AddressSanitizer and UBSan" OFF) diff --git a/Polyfills/CMakeLists.txt b/Polyfills/CMakeLists.txt index a9b3f865..87e6d70a 100644 --- a/Polyfills/CMakeLists.txt +++ b/Polyfills/CMakeLists.txt @@ -28,4 +28,8 @@ endif() if(JSRUNTIMEHOST_POLYFILL_PERFORMANCE) add_subdirectory(Performance) +endif() + +if(JSRUNTIMEHOST_POLYFILL_TEXTDECODER) + add_subdirectory(TextDecoder) endif() \ No newline at end of file diff --git a/Polyfills/TextDecoder/CMakeLists.txt b/Polyfills/TextDecoder/CMakeLists.txt new file mode 100644 index 00000000..93f56ea1 --- /dev/null +++ b/Polyfills/TextDecoder/CMakeLists.txt @@ -0,0 +1,15 @@ +set(SOURCES + "Include/Babylon/Polyfills/TextDecoder.h" + "Source/TextDecoder.cpp") + +add_library(TextDecoder ${SOURCES}) +warnings_as_errors(TextDecoder) + +target_include_directories(TextDecoder PUBLIC "Include") + +target_link_libraries(TextDecoder + PUBLIC napi + PUBLIC Foundation) + +set_property(TARGET TextDecoder PROPERTY FOLDER Polyfills) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Polyfills/TextDecoder/Include/Babylon/Polyfills/TextDecoder.h b/Polyfills/TextDecoder/Include/Babylon/Polyfills/TextDecoder.h new file mode 100644 index 00000000..d2a310d9 --- /dev/null +++ b/Polyfills/TextDecoder/Include/Babylon/Polyfills/TextDecoder.h @@ -0,0 +1,9 @@ +#pragma once + +#include +#include + +namespace Babylon::Polyfills::TextDecoder +{ + void BABYLON_API Initialize(Napi::Env env); +} diff --git a/Polyfills/TextDecoder/README.md b/Polyfills/TextDecoder/README.md new file mode 100644 index 00000000..d11cb9ac --- /dev/null +++ b/Polyfills/TextDecoder/README.md @@ -0,0 +1,39 @@ +# TextDecoder Polyfill + +A C++ implementation of the [WHATWG Encoding API](https://encoding.spec.whatwg.org/) `TextDecoder` interface for use in Babylon Native JavaScript runtimes via [Napi](https://github.com/nodejs/node-addon-api). + +## Current State + +### Supported + +- Decoding `Uint8Array`, `Int8Array`, and other typed array views from a UTF-8 encoded byte sequence. +- Decoding raw `ArrayBuffer` objects. +- Constructing `TextDecoder` with no argument (defaults to `utf-8`). +- Constructing `TextDecoder` with the explicit encoding label `"utf-8"` or `"UTF-8"`. +- Calling `decode()` with no argument or `undefined` returns an empty string (matches the Web API). + +### Not Supported + +- Encodings other than UTF-8 — passing any other label (e.g. `"utf-16"`, `"iso-8859-1"`) throws a JavaScript `Error`. +- `DataView` is not accepted by `decode()` — due to missing `Napi::DataView` support in the underlying JSI layer. +- Passing a non-BufferSource value (e.g. a string or number) to `decode()` throws a `TypeError`. +- The `fatal` option: decoding errors are not detected and do not throw a `TypeError`. +- The `ignoreBOM` option: the byte order mark is not stripped. +- Streaming decode (passing `{ stream: true }` to `decode()`) — each call is stateless. +- The `encoding` property on the `TextDecoder` instance is not exposed. + +## Usage + +```javascript +const decoder = new TextDecoder(); // utf-8 +const decoder = new TextDecoder("utf-8"); // explicit, also fine + +const bytes = new Uint8Array([72, 101, 108, 108, 111]); +decoder.decode(bytes); // "Hello" +``` + +Passing an unsupported encoding throws: + +```javascript +new TextDecoder("utf-16"); // Error: TextDecoder: unsupported encoding 'utf-16', only 'utf-8' is supported +``` diff --git a/Polyfills/TextDecoder/Source/TextDecoder.cpp b/Polyfills/TextDecoder/Source/TextDecoder.cpp new file mode 100644 index 00000000..15ade200 --- /dev/null +++ b/Polyfills/TextDecoder/Source/TextDecoder.cpp @@ -0,0 +1,94 @@ +#include + +#include +#include +#include + +namespace +{ + class TextDecoder final : public Napi::ObjectWrap + { + public: + static void Initialize(Napi::Env env) + { + Napi::HandleScope scope{env}; + + static constexpr auto JS_TEXTDECODER_CONSTRUCTOR_NAME = "TextDecoder"; + if (env.Global().Get(JS_TEXTDECODER_CONSTRUCTOR_NAME).IsUndefined()) + { + Napi::Function func = DefineClass( + env, + JS_TEXTDECODER_CONSTRUCTOR_NAME, + { + InstanceMethod("decode", &TextDecoder::Decode), + }); + + env.Global().Set(JS_TEXTDECODER_CONSTRUCTOR_NAME, func); + } + } + + explicit TextDecoder(const Napi::CallbackInfo& info) + : Napi::ObjectWrap{info} + { + if (info.Length() > 0 && info[0].IsString()) + { + auto encoding = info[0].As().Utf8Value(); + if (encoding != "utf-8" && encoding != "UTF-8") + { + Napi::Error::New(info.Env(), "TextDecoder: unsupported encoding '" + encoding + "', only 'utf-8' is supported") + .ThrowAsJavaScriptException(); + } + } + } + + private: + Napi::Value Decode(const Napi::CallbackInfo& info) + { + if (info.Length() < 1 || info[0].IsUndefined()) + { + return Napi::String::New(info.Env(), ""); + } + + std::string data; + + if (info[0].IsTypedArray()) + { + auto typedArray = info[0].As(); + auto arrayBuffer = typedArray.ArrayBuffer(); + auto byteOffset = typedArray.ByteOffset(); + auto byteLength = typedArray.ByteLength(); + data.resize(byteLength); + if (byteLength > 0) + { + std::memcpy(data.data(), static_cast(arrayBuffer.Data()) + byteOffset, byteLength); + } + } + else if (info[0].IsArrayBuffer()) + { + auto arrayBuffer = info[0].As(); + auto byteLength = arrayBuffer.ByteLength(); + data.resize(byteLength); + if (byteLength > 0) + { + std::memcpy(data.data(), arrayBuffer.Data(), byteLength); + } + } + else + { + Napi::TypeError::New(info.Env(), "TextDecoder.decode: input must be a BufferSource (ArrayBuffer or TypedArray)") + .ThrowAsJavaScriptException(); + return info.Env().Undefined(); + } + + return Napi::String::New(info.Env(), data); + } + }; +} + +namespace Babylon::Polyfills::TextDecoder +{ + void BABYLON_API Initialize(Napi::Env env) + { + ::TextDecoder::Initialize(env); + } +} diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt index 7f83454e..b513a866 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -39,4 +39,5 @@ target_link_libraries(UnitTestsJNI PRIVATE WebSocket PRIVATE gtest_main PRIVATE Blob + PRIVATE TextDecoder PRIVATE Performance) diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index 0f68bc1b..e0ab9452 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -54,6 +54,7 @@ target_link_libraries(UnitTests PRIVATE Foundation PRIVATE Blob PRIVATE Performance + PRIVATE TextDecoder ${ADDITIONAL_LIBRARIES}) # See https://gitlab.kitware.com/cmake/cmake/-/issues/23543 diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 21b523fc..51128709 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -1240,6 +1240,36 @@ describe("Performance", function () { }); }); +describe("TextDecoder", function () { + it("should decode a Uint8Array to a string", function () { + const decoder = new TextDecoder(); + const encoded = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + const result = decoder.decode(encoded); + expect(result).to.equal("Hello"); + }); + + it("should decode an empty Uint8Array to an empty string", function () { + const decoder = new TextDecoder(); + const result = decoder.decode(new Uint8Array([])); + expect(result).to.equal(""); + }); + + it("should decode an ArrayBuffer to a string", function () { + const decoder = new TextDecoder(); + const buffer = new Uint8Array([87, 111, 114, 108, 100]).buffer; // "World" + const result = decoder.decode(buffer); + expect(result).to.equal("World"); + }); + + it("should decode a TypedArray subarray with non-zero byteOffset", function () { + const decoder = new TextDecoder(); + const full = new Uint8Array([88, 72, 105]); // "XHi" + const sub = full.subarray(1); // [72, 105] -> "Hi" + const result = decoder.decode(sub); + expect(result).to.equal("Hi"); + }); +}); + 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 5ed3706d..ecf63f33 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -68,6 +69,7 @@ TEST(JavaScript, All) Babylon::Polyfills::WebSocket::Initialize(env); Babylon::Polyfills::XMLHttpRequest::Initialize(env); Babylon::Polyfills::Blob::Initialize(env); + Babylon::Polyfills::TextDecoder::Initialize(env); auto setExitCodeCallback = Napi::Function::New( env, [&exitCodePromise](const Napi::CallbackInfo& info) {