From c8f4bd8ac9c5deb0f748d2555ccf4c34169d0836 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 24 Aug 2026 17:00:29 -0300 Subject: [PATCH 1/5] feat(runtime): TextEncoder, TextDecoder, atob and btoa as lazy globals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a lazy-global tier and the first four globals on it. LazyGlobals registers each name on the global template as a lazy data property, so the builtin behind it is not compiled, run or allocated until app code first reads the name; V8 then replaces the property with a plain data property. Sibling names share one run per isolate through a Caches state slot, and the metadata interceptor declines every name the tier owns. TextEncoder/TextDecoder follow Node's split: text-encoding.js owns the WebIDL shapes and TextEncoding.cpp the bytes — the complete WHATWG label sets for utf-8, utf-16le, utf-16be and windows-1252, a hand-rolled utf-8 decode state machine with per-maximal-subpart replacement, the shared utf-16 decoder, BOM handling and full streaming. Per-decoder state is a Uint8Array the builtin owns, so no instance needs a native handle. atob/btoa sit on the WHATWG forgiving-base64 codec in Base64.cpp and, with no DOMException in the runtime yet, fail with the name-patched Error stand-in the other builtins use. encodeInto registers a v8::CFunction fast-call overload behind NATIVESCRIPT_ENABLE_FAST_API. It is inert on iOS, which runs V8 jitless. --- NativeScript/runtime/Base64.cpp | 180 ++++++ NativeScript/runtime/Base64.h | 18 + NativeScript/runtime/Helpers.h | 7 + NativeScript/runtime/LazyGlobals.cpp | 115 ++++ NativeScript/runtime/LazyGlobals.h | 35 ++ NativeScript/runtime/MetadataBuilder.mm | 6 +- NativeScript/runtime/Runtime.mm | 2 + NativeScript/runtime/TextEncoding.cpp | 663 +++++++++++++++++++++++ NativeScript/runtime/TextEncoding.h | 22 + NativeScript/runtime/js/README.md | 33 +- NativeScript/runtime/js/base64.js | 46 ++ NativeScript/runtime/js/primordials.js | 2 + NativeScript/runtime/js/text-encoding.js | 172 ++++++ tools/js2c-inputs.xcfilelist | 2 + v8ios.xcodeproj/project.pbxproj | 24 + 15 files changed, 1322 insertions(+), 5 deletions(-) create mode 100644 NativeScript/runtime/Base64.cpp create mode 100644 NativeScript/runtime/Base64.h create mode 100644 NativeScript/runtime/LazyGlobals.cpp create mode 100644 NativeScript/runtime/LazyGlobals.h create mode 100644 NativeScript/runtime/TextEncoding.cpp create mode 100644 NativeScript/runtime/TextEncoding.h create mode 100644 NativeScript/runtime/js/base64.js create mode 100644 NativeScript/runtime/js/text-encoding.js diff --git a/NativeScript/runtime/Base64.cpp b/NativeScript/runtime/Base64.cpp new file mode 100644 index 00000000..41ea6789 --- /dev/null +++ b/NativeScript/runtime/Base64.cpp @@ -0,0 +1,180 @@ +#include "Base64.h" + +#include + +#include "Helpers.h" + +using namespace v8; + +namespace tns { + +namespace { + +constexpr char kAlphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +// 6-bit value per ASCII byte; 0xFF marks everything outside the alphabet. +constexpr uint8_t kInvalid = 0xFF; + +uint8_t SixBits(uint8_t c) { + if (c >= 'A' && c <= 'Z') { + return static_cast(c - 'A'); + } + if (c >= 'a' && c <= 'z') { + return static_cast(c - 'a' + 26); + } + if (c >= '0' && c <= '9') { + return static_cast(c - '0' + 52); + } + if (c == '+') { + return 62; + } + if (c == '/') { + return 63; + } + return kInvalid; +} + +bool IsAsciiWhitespace(uint8_t c) { + return c == '\t' || c == '\n' || c == '\f' || c == '\r' || c == ' '; +} + +// The string's code units as bytes. Fails when any unit is above U+00FF, +// which neither op can represent. +bool GetLatin1Bytes(Isolate* isolate, Local value, + std::vector* out) { + if (!value->IsString()) { + return false; + } + Local str = value.As(); + if (!str->ContainsOnlyOneByte()) { + return false; + } + const int length = str->Length(); + out->resize(static_cast(length)); + if (length > 0) { + str->WriteOneByteV2(isolate, 0, static_cast(length), out->data()); + } + return true; +} + +// btoa: base64-encode the input's code units. +void BtoaCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + std::vector input; + if (!GetLatin1Bytes(isolate, info[0], &input)) { + info.GetReturnValue().SetNull(); + return; + } + + std::vector out; + out.reserve((input.size() + 2) / 3 * 4); + size_t i = 0; + for (; i + 3 <= input.size(); i += 3) { + const uint32_t group = (static_cast(input[i]) << 16) | + (static_cast(input[i + 1]) << 8) | + input[i + 2]; + out.push_back(kAlphabet[(group >> 18) & 0x3F]); + out.push_back(kAlphabet[(group >> 12) & 0x3F]); + out.push_back(kAlphabet[(group >> 6) & 0x3F]); + out.push_back(kAlphabet[group & 0x3F]); + } + const size_t remaining = input.size() - i; + if (remaining == 1) { + const uint32_t group = static_cast(input[i]) << 16; + out.push_back(kAlphabet[(group >> 18) & 0x3F]); + out.push_back(kAlphabet[(group >> 12) & 0x3F]); + out.push_back('='); + out.push_back('='); + } else if (remaining == 2) { + const uint32_t group = (static_cast(input[i]) << 16) | + (static_cast(input[i + 1]) << 8); + out.push_back(kAlphabet[(group >> 18) & 0x3F]); + out.push_back(kAlphabet[(group >> 12) & 0x3F]); + out.push_back(kAlphabet[(group >> 6) & 0x3F]); + out.push_back('='); + } + + if (out.empty()) { + info.GetReturnValue().Set(v8::String::Empty(isolate)); + return; + } + Local result; + if (v8::String::NewFromOneByte(isolate, out.data(), NewStringType::kNormal, + static_cast(out.size())) + .ToLocal(&result)) { + info.GetReturnValue().Set(result); + } +} + +// atob: forgiving-base64 decode +// (https://infra.spec.whatwg.org/#forgiving-base64-decode). +void AtobCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + std::vector raw; + if (!GetLatin1Bytes(isolate, info[0], &raw)) { + info.GetReturnValue().SetNull(); + return; + } + + std::vector data; + data.reserve(raw.size()); + for (uint8_t c : raw) { + if (!IsAsciiWhitespace(c)) { + data.push_back(c); + } + } + + if (data.size() % 4 == 0) { + size_t strip = 0; + while (strip < 2 && !data.empty() && data.back() == '=') { + data.pop_back(); + strip++; + } + } + if (data.size() % 4 == 1) { + info.GetReturnValue().SetNull(); + return; + } + + std::vector out; + out.reserve(data.size() / 4 * 3 + 2); + uint32_t accumulator = 0; + uint32_t bits = 0; + for (uint8_t c : data) { + const uint8_t value = SixBits(c); + if (value == kInvalid) { + info.GetReturnValue().SetNull(); + return; + } + accumulator = (accumulator << 6) | value; + bits += 6; + if (bits >= 8) { + bits -= 8; + out.push_back(static_cast((accumulator >> bits) & 0xFF)); + } + } + + if (out.empty()) { + info.GetReturnValue().Set(v8::String::Empty(isolate)); + return; + } + Local result; + if (v8::String::NewFromOneByte(isolate, out.data(), NewStringType::kNormal, + static_cast(out.size())) + .ToLocal(&result)) { + info.GetReturnValue().Set(result); + } +} + +} // namespace + +Local Base64::CreateBinding(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local binding = Object::New(isolate); + tns::SetMethodNoSideEffect(context, binding, "btoa", BtoaCallback); + tns::SetMethodNoSideEffect(context, binding, "atob", AtobCallback); + return binding; +} + +} // namespace tns diff --git a/NativeScript/runtime/Base64.h b/NativeScript/runtime/Base64.h new file mode 100644 index 00000000..100008a7 --- /dev/null +++ b/NativeScript/runtime/Base64.h @@ -0,0 +1,18 @@ +#ifndef Base64_h +#define Base64_h + +#include "Common.h" + +namespace tns { + +// Native ops behind the base64 builtin (internal/base64.js): the WHATWG +// forgiving-base64 codec backing the atob / btoa globals. Both ops answer +// null instead of throwing, so the builtin owns the error shape. +class Base64 { + public: + static v8::Local CreateBinding(v8::Local context); +}; + +} // namespace tns + +#endif /* Base64_h */ diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index a7ef57e9..1e55f422 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -525,6 +525,13 @@ void SetMethod(v8::Local context, v8::Local that, const // Similar to SetProtoMethod but without receiver signature checks. void SetMethod(v8::Isolate* isolate, v8::Local that, const char* name, v8::FunctionCallback callback, v8::Local data = v8::Local()); +// Whether the runtime registers v8::CFunction fast-call overloads next to the +// slow callbacks. A registered overload is inert wherever the optimizing tiers +// are absent — iOS ships V8 in lite mode, which implies jitless, so every call +// there goes through the slow callback — and only fires on JIT-enabled embeds. +#ifndef NATIVESCRIPT_ENABLE_FAST_API +#define NATIVESCRIPT_ENABLE_FAST_API 1 +#endif void SetFastMethod(v8::Isolate* isolate, v8::Local that, const char* name, v8::FunctionCallback slow_callback, const v8::CFunction* c_function, v8::Local data = v8::Local()); diff --git a/NativeScript/runtime/LazyGlobals.cpp b/NativeScript/runtime/LazyGlobals.cpp new file mode 100644 index 00000000..eebb129a --- /dev/null +++ b/NativeScript/runtime/LazyGlobals.cpp @@ -0,0 +1,115 @@ +#include "LazyGlobals.h" + +#include "Base64.h" +#include "BuiltinLoader.h" +#include "Caches.h" +#include "Helpers.h" +#include "TextEncoding.h" + +using namespace v8; + +namespace tns { + +namespace { + +using BindingFactory = Local (*)(Local); + +struct LazyGlobalEntry { + const char* name; + BuiltinId builtin; + const char* exportName; // key of `name` in the builtin's module.exports + BindingFactory binding; // natives the builtin needs, null if it needs none +}; + +constexpr LazyGlobalEntry kLazyGlobals[] = { + {"TextEncoder", BuiltinId::kTextEncoding, "TextEncoder", + TextEncoding::CreateBinding}, + {"TextDecoder", BuiltinId::kTextEncoding, "TextDecoder", + TextEncoding::CreateBinding}, + {"atob", BuiltinId::kBase64, "atob", Base64::CreateBinding}, + {"btoa", BuiltinId::kBase64, "btoa", Base64::CreateBinding}, +}; + +// One entry per builtin this tier can run, so two globals from the same file +// cost one run. +struct LazyGlobalsState { + Persistent exports[static_cast(BuiltinId::kCount)]; +}; + +MaybeLocal GetExports(Local context, + const LazyGlobalEntry& entry) { + Isolate* isolate = v8::Isolate::GetCurrent(); + auto* state = Caches::StateFor(isolate); + if (state == nullptr) { + return MaybeLocal(); + } + + const unsigned index = static_cast(entry.builtin); + if (!state->exports[index].IsEmpty()) { + return state->exports[index].Get(isolate); + } + + Local binding; + if (entry.binding != nullptr) { + binding = entry.binding(context); + } + + Local result; + if (!BuiltinLoader::RunBuiltin(context, entry.builtin, binding) + .ToLocal(&result) || + !result->IsObject()) { + return MaybeLocal(); + } + + Local exports = result.As(); + state->exports[index].Reset(isolate, exports); + return exports; +} + +void LazyGlobalGetter(Local property, + const PropertyCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + const auto* entry = static_cast( + info.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); + Local context = isolate->GetCurrentContext(); + + Local exports; + if (!GetExports(context, *entry).ToLocal(&exports)) { + return; + } + Local value; + if (!exports->Get(context, tns::ToV8String(isolate, entry->exportName)) + .ToLocal(&value)) { + return; + } + info.GetReturnValue().Set(value); +} + +} // namespace + +void LazyGlobals::Init(Isolate* isolate, Local globalTemplate) { + for (const LazyGlobalEntry& entry : kLazyGlobals) { + Local data = + External::New(isolate, const_cast(&entry), + v8::kExternalPointerTypeTagDefault); + // SetLazyDataProperty, not a getter that rewrites the property itself: + // defining over an API accessor reads its current descriptor, which calls + // the getter again and recurses. V8 does the rewrite from the outside, and + // gives a setter-less accessor its ReconfigureToDataProperty setter, so an + // assignment landing before the first read replaces the global as well. + globalTemplate->SetLazyDataProperty(tns::ToV8String(isolate, entry.name), + LazyGlobalGetter, data, + PropertyAttribute::DontEnum); + } +} + +bool LazyGlobals::IsLazyGlobal(const std::string& name) { + for (const LazyGlobalEntry& entry : kLazyGlobals) { + if (name == entry.name) { + return true; + } + } + return false; +} + +} // namespace tns diff --git a/NativeScript/runtime/LazyGlobals.h b/NativeScript/runtime/LazyGlobals.h new file mode 100644 index 00000000..60b57d7a --- /dev/null +++ b/NativeScript/runtime/LazyGlobals.h @@ -0,0 +1,35 @@ +#ifndef LazyGlobals_h +#define LazyGlobals_h + +#include + +#include "Common.h" + +namespace tns { + +// Globals whose implementation is a runtime builtin that must not run until +// someone actually reaches for the name. Each entry is registered on the +// global template as a lazy data property; the first read runs the builtin +// once per isolate and caches its exports, so sibling names (TextEncoder and +// TextDecoder) share the run, and V8 then replaces the property with a plain +// data property so later reads cost nothing. +// +// A builtin behind this tier runs at an arbitrary point in the isolate's life +// rather than during init, so it may only consume `internals` keys published +// by eager builtins (see NativeScript/runtime/js/README.md). +class LazyGlobals { + public: + // Registers every lazy global. Must run before Context::New, on the same + // template the eager globals use. + static void Init(v8::Isolate* isolate, + v8::Local globalTemplate); + + // Whether `name` is one of this tier's globals. The global named-property + // interceptor declines these so an ObjC metadata symbol sharing a name can + // never resolve ahead of the runtime's own global. + static bool IsLazyGlobal(const std::string& name); +}; + +} // namespace tns + +#endif /* LazyGlobals_h */ diff --git a/NativeScript/runtime/MetadataBuilder.mm b/NativeScript/runtime/MetadataBuilder.mm index ce9f6417..604e4933 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -6,6 +6,7 @@ #include "Helpers.h" #include "InlineFunctions.h" #include "Interop.h" +#include "LazyGlobals.h" #include "NativeScriptException.h" #include "ObjectManager.h" #include "Runtime.h" @@ -52,7 +53,10 @@ NamedPropertyHandlerConfiguration config(MetadataBuilder::GlobalPropertyGetter, return v8::Intercepted::kNo; } - if (InlineFunctions::IsGlobalFunction(propName)) { + // Globals the runtime owns: the eager ones inline-functions.js installs and + // the lazy tier's, which are only accessors until first read and so would + // otherwise lose the name to a metadata symbol that shares it. + if (InlineFunctions::IsGlobalFunction(propName) || LazyGlobals::IsLazyGlobal(propName)) { return v8::Intercepted::kNo; } diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index 863d762a..a5d7a58f 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -12,6 +12,7 @@ #include "InlineFunctions.h" #include "Interop.h" #include "IsolateTracked.h" +#include "LazyGlobals.h" #include "NativeScriptException.h" #include "NativeScriptPlatform.h" #include "ObjectManager.h" @@ -401,6 +402,7 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { globalTemplate->Set(tns::ToV8String(isolate, "queueMicrotask"), qmtTemplate); } ObjectManager::Init(isolate, globalTemplate); + LazyGlobals::Init(isolate, globalTemplate); MetadataBuilder::RegisterConstantsOnGlobalObject(isolate, globalTemplate, isWorker); isolate->SetCaptureStackTraceForUncaughtExceptions(true, 100, StackTrace::kOverview); diff --git a/NativeScript/runtime/TextEncoding.cpp b/NativeScript/runtime/TextEncoding.cpp new file mode 100644 index 00000000..0600affe --- /dev/null +++ b/NativeScript/runtime/TextEncoding.cpp @@ -0,0 +1,663 @@ +#include "TextEncoding.h" + +#include +#include + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdocumentation" +#include "v8-fast-api-calls.h" +#pragma clang diagnostic pop + +#include "Helpers.h" + +using namespace v8; + +namespace tns { + +namespace { + +// Encoding ids shared with text-encoding.js, which maps them back to the +// canonical names through its kEncodingNames array — keep the two in step. +enum Encoding : uint32_t { + kUtf8 = 0, + kUtf16le = 1, + kUtf16be = 2, + kWindows1252 = 3, +}; + +// Decode option bits, mirrored by kFlag* in text-encoding.js. +constexpr uint32_t kFlagFatal = 1; +constexpr uint32_t kFlagIgnoreBOM = 2; +constexpr uint32_t kFlagStream = 4; + +constexpr uint16_t kReplacementCharacter = 0xFFFD; + +struct EncodingLabel { + const char* label; + Encoding encoding; +}; + +// The complete label set of the four encodings the runtime supports +// (https://encoding.spec.whatwg.org/#names-and-labels), sorted by label so +// lookup is a binary search over a table with no runtime setup cost. +constexpr EncodingLabel kLabels[] = { + {"ansi_x3.4-1968", kWindows1252}, + {"ascii", kWindows1252}, + {"cp1252", kWindows1252}, + {"cp819", kWindows1252}, + {"csisolatin1", kWindows1252}, + {"csunicode", kUtf16le}, + {"ibm819", kWindows1252}, + {"iso-10646-ucs-2", kUtf16le}, + {"iso-8859-1", kWindows1252}, + {"iso-ir-100", kWindows1252}, + {"iso8859-1", kWindows1252}, + {"iso88591", kWindows1252}, + {"iso_8859-1", kWindows1252}, + {"iso_8859-1:1987", kWindows1252}, + {"l1", kWindows1252}, + {"latin1", kWindows1252}, + {"ucs-2", kUtf16le}, + {"unicode", kUtf16le}, + {"unicode-1-1-utf-8", kUtf8}, + {"unicode11utf8", kUtf8}, + {"unicode20utf8", kUtf8}, + {"unicodefeff", kUtf16le}, + {"unicodefffe", kUtf16be}, + {"us-ascii", kWindows1252}, + {"utf-16", kUtf16le}, + {"utf-16be", kUtf16be}, + {"utf-16le", kUtf16le}, + {"utf-8", kUtf8}, + {"utf8", kUtf8}, + {"windows-1252", kWindows1252}, + {"x-cp1252", kWindows1252}, + {"x-unicode20utf8", kUtf8}, +}; + +// windows-1252 index, pointers 0x80-0x9F. Everything outside that block is +// Latin-1 (identity), including the C1 controls this table maps to +// themselves. +constexpr uint16_t kWindows1252Index[32] = { + 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, + 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, + 0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, + 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178}; + +bool IsAsciiWhitespace(char c) { + return c == '\t' || c == '\n' || c == '\f' || c == '\r' || c == ' '; +} + +int32_t LookupEncoding(const std::string& rawLabel) { + size_t begin = 0; + size_t end = rawLabel.size(); + while (begin < end && IsAsciiWhitespace(rawLabel[begin])) { + begin++; + } + while (end > begin && IsAsciiWhitespace(rawLabel[end - 1])) { + end--; + } + + std::string label = rawLabel.substr(begin, end - begin); + for (char& c : label) { + if (c >= 'A' && c <= 'Z') { + c = static_cast(c - 'A' + 'a'); + } + } + + size_t lo = 0; + size_t hi = sizeof(kLabels) / sizeof(kLabels[0]); + while (lo < hi) { + size_t mid = lo + (hi - lo) / 2; + int cmp = label.compare(kLabels[mid].label); + if (cmp == 0) { + return static_cast(kLabels[mid].encoding); + } + if (cmp < 0) { + hi = mid; + } else { + lo = mid + 1; + } + } + return -1; +} + +// Everything a decoder must remember between streaming calls. It lives in a +// Uint8Array the builtin allocates per TextDecoder, so the native side stays +// stateless and no instance needs a finalizer. +constexpr int kDecoderStateBytes = 10; // bytes Store writes +static_assert(kDecoderStateBytes <= TextEncoding::kDecoderStateSize, + "the builtin allocates too little decoder state"); + +struct DecoderState { + bool bomSeen = false; + uint8_t utf8PendingLength = 0; + uint8_t utf8Pending[3] = {0, 0, 0}; + bool hasLeadByte = false; + uint8_t leadByte = 0; + bool hasLeadSurrogate = false; + uint16_t leadSurrogate = 0; + + void Load(const uint8_t* raw) { + bomSeen = (raw[0] & 1) != 0; + utf8PendingLength = raw[1] > 3 ? 0 : raw[1]; + utf8Pending[0] = raw[2]; + utf8Pending[1] = raw[3]; + utf8Pending[2] = raw[4]; + hasLeadByte = raw[5] != 0; + leadByte = raw[6]; + hasLeadSurrogate = raw[7] != 0; + leadSurrogate = static_cast(raw[8] | (raw[9] << 8)); + } + + void Store(uint8_t* raw) const { + raw[0] = bomSeen ? 1 : 0; + raw[1] = utf8PendingLength; + raw[2] = utf8Pending[0]; + raw[3] = utf8Pending[1]; + raw[4] = utf8Pending[2]; + raw[5] = hasLeadByte ? 1 : 0; + raw[6] = leadByte; + raw[7] = hasLeadSurrogate ? 1 : 0; + raw[8] = static_cast(leadSurrogate & 0xFF); + raw[9] = static_cast(leadSurrogate >> 8); + } + + void Reset() { *this = DecoderState(); } +}; + +// Collects decoded UTF-16 code units and tracks whether they all fit in one +// byte, so the finished string can take V8's one-byte representation. +class Utf16Sink { + public: + void Append(uint16_t unit) { + units_.push_back(unit); + orAll_ |= unit; + } + + void AppendCodePoint(uint32_t codePoint) { + if (codePoint <= 0xFFFF) { + Append(static_cast(codePoint)); + return; + } + codePoint -= 0x10000; + Append(static_cast(0xD800 + (codePoint >> 10))); + Append(static_cast(0xDC00 + (codePoint & 0x3FF))); + } + + MaybeLocal Finish(Isolate* isolate) const { + if (units_.empty()) { + return v8::String::Empty(isolate); + } + if (orAll_ <= 0xFF) { + std::vector oneByte(units_.size()); + for (size_t i = 0; i < units_.size(); i++) { + oneByte[i] = static_cast(units_[i]); + } + return v8::String::NewFromOneByte(isolate, oneByte.data(), + NewStringType::kNormal, + static_cast(oneByte.size())); + } + return v8::String::NewFromTwoByte(isolate, units_.data(), + NewStringType::kNormal, + static_cast(units_.size())); + } + + private: + std::vector units_; + uint32_t orAll_ = 0; +}; + +// A code point leaving a utf-8 / utf-16 decoder, with the leading-BOM removal +// TextDecoder performs once per stream. +class BomFilter { + public: + BomFilter(Utf16Sink& sink, DecoderState& state, bool ignoreBOM) + : sink_(sink), state_(state), ignoreBOM_(ignoreBOM) {} + + void Emit(uint32_t codePoint) { + if (!state_.bomSeen) { + state_.bomSeen = true; + if (!ignoreBOM_ && codePoint == 0xFEFF) { + return; + } + } + sink_.AppendCodePoint(codePoint); + } + + private: + Utf16Sink& sink_; + DecoderState& state_; + const bool ignoreBOM_; +}; + +// WHATWG utf-8 decoder. Incomplete trailing sequences are kept as raw bytes +// and replayed at the head of the next call, so the boundary constraints of a +// split sequence are re-derived from its own lead byte rather than carried in +// the saved state. Returns false when fatal mode hits invalid input. +bool DecodeUtf8(const uint8_t* input, size_t inputLength, DecoderState& state, + bool stream, bool fatal, bool ignoreBOM, Utf16Sink& sink) { + const size_t pendingLength = state.utf8PendingLength; + const size_t total = pendingLength + inputLength; + auto byteAt = [&](size_t index) -> uint8_t { + return index < pendingLength ? state.utf8Pending[index] + : input[index - pendingLength]; + }; + state.utf8PendingLength = 0; + + BomFilter out(sink, state, ignoreBOM); + uint32_t codePoint = 0; + uint32_t bytesNeeded = 0; + uint32_t bytesSeen = 0; + uint32_t lowerBoundary = 0x80; + uint32_t upperBoundary = 0xBF; + size_t sequenceStart = 0; + size_t index = 0; + + while (index < total) { + const uint8_t byte = byteAt(index); + if (bytesNeeded == 0) { + sequenceStart = index; + index++; + if (byte <= 0x7F) { + out.Emit(byte); + } else if (byte >= 0xC2 && byte <= 0xDF) { + bytesNeeded = 1; + codePoint = byte & 0x1F; + } else if (byte >= 0xE0 && byte <= 0xEF) { + if (byte == 0xE0) { + lowerBoundary = 0xA0; + } else if (byte == 0xED) { + upperBoundary = 0x9F; + } + bytesNeeded = 2; + codePoint = byte & 0x0F; + } else if (byte >= 0xF0 && byte <= 0xF4) { + if (byte == 0xF0) { + lowerBoundary = 0x90; + } else if (byte == 0xF4) { + upperBoundary = 0x8F; + } + bytesNeeded = 3; + codePoint = byte & 0x07; + } else { + if (fatal) { + state.Reset(); + return false; + } + out.Emit(kReplacementCharacter); + } + continue; + } + + if (byte < lowerBoundary || byte > upperBoundary) { + // One replacement for the maximal subpart consumed so far; the offending + // byte is reprocessed as the start of a new sequence (index unchanged). + codePoint = 0; + bytesNeeded = 0; + bytesSeen = 0; + lowerBoundary = 0x80; + upperBoundary = 0xBF; + if (fatal) { + state.Reset(); + return false; + } + out.Emit(kReplacementCharacter); + continue; + } + + lowerBoundary = 0x80; + upperBoundary = 0xBF; + codePoint = (codePoint << 6) | (byte & 0x3F); + bytesSeen++; + index++; + if (bytesSeen != bytesNeeded) { + continue; + } + const uint32_t finished = codePoint; + codePoint = 0; + bytesNeeded = 0; + bytesSeen = 0; + out.Emit(finished); + } + + if (bytesNeeded == 0) { + return true; + } + if (!stream) { + if (fatal) { + state.Reset(); + return false; + } + out.Emit(kReplacementCharacter); + return true; + } + // At most three bytes, and each is read before the slot it overwrites. + const size_t carried = total - sequenceStart; + for (size_t i = 0; i < carried; i++) { + state.utf8Pending[i] = byteAt(sequenceStart + i); + } + state.utf8PendingLength = static_cast(carried); + return true; +} + +// WHATWG shared utf-16 decoder, both endiannesses. +bool DecodeUtf16(const uint8_t* input, size_t inputLength, bool bigEndian, + DecoderState& state, bool stream, bool fatal, bool ignoreBOM, + Utf16Sink& sink) { + BomFilter out(sink, state, ignoreBOM); + + auto process = [&](uint16_t unit) -> bool { + if (state.hasLeadSurrogate) { + const uint16_t lead = state.leadSurrogate; + state.hasLeadSurrogate = false; + if (unit >= 0xDC00 && unit <= 0xDFFF) { + out.Emit(0x10000u + (static_cast(lead - 0xD800) << 10) + + (unit - 0xDC00)); + return true; + } + if (fatal) { + return false; + } + // The unpaired lead is replaced and `unit` starts over below. + out.Emit(kReplacementCharacter); + } + if (unit >= 0xD800 && unit <= 0xDBFF) { + state.hasLeadSurrogate = true; + state.leadSurrogate = unit; + return true; + } + if (unit >= 0xDC00 && unit <= 0xDFFF) { + if (fatal) { + return false; + } + out.Emit(kReplacementCharacter); + return true; + } + out.Emit(unit); + return true; + }; + + for (size_t i = 0; i < inputLength; i++) { + const uint8_t byte = input[i]; + if (!state.hasLeadByte) { + state.hasLeadByte = true; + state.leadByte = byte; + continue; + } + const uint16_t unit = + bigEndian ? static_cast((state.leadByte << 8) | byte) + : static_cast((byte << 8) | state.leadByte); + state.hasLeadByte = false; + if (!process(unit)) { + state.Reset(); + return false; + } + } + + if (stream) { + return true; + } + if (state.hasLeadByte || state.hasLeadSurrogate) { + state.hasLeadByte = false; + state.hasLeadSurrogate = false; + if (fatal) { + state.Reset(); + return false; + } + out.Emit(kReplacementCharacter); + } + return true; +} + +void DecodeWindows1252(const uint8_t* input, size_t inputLength, + Utf16Sink& sink) { + for (size_t i = 0; i < inputLength; i++) { + const uint8_t byte = input[i]; + sink.Append(byte >= 0x80 && byte <= 0x9F ? kWindows1252Index[byte - 0x80] + : byte); + } +} + +bool AllBytesBelow(const uint8_t* input, size_t length, uint8_t limit) { + for (size_t i = 0; i < length; i++) { + if (input[i] >= limit) { + return false; + } + } + return true; +} + +bool NoC1Bytes(const uint8_t* input, size_t length) { + for (size_t i = 0; i < length; i++) { + if (input[i] >= 0x80 && input[i] <= 0x9F) { + return false; + } + } + return true; +} + +// Bytes of an ArrayBuffer, SharedArrayBuffer or any ArrayBufferView; a +// detached buffer reads as empty. Only the builtin calls in, so anything else +// is a programming error rather than a user-visible one. +bool GetByteSource(Local value, const uint8_t** data, size_t* length) { + *data = nullptr; + *length = 0; + if (value.IsEmpty() || value->IsUndefined()) { + return true; + } + if (value->IsArrayBufferView()) { + Local view = value.As(); + Local buffer = view->Buffer(); + void* base = buffer->Data(); + if (base == nullptr) { + return true; + } + *data = static_cast(base) + view->ByteOffset(); + *length = view->ByteLength(); + return true; + } + if (value->IsArrayBuffer()) { + Local buffer = value.As(); + if (buffer->Data() == nullptr) { + return true; + } + *data = static_cast(buffer->Data()); + *length = buffer->ByteLength(); + return true; + } + if (value->IsSharedArrayBuffer()) { + Local buffer = value.As(); + if (buffer->Data() == nullptr) { + return true; + } + *data = static_cast(buffer->Data()); + *length = buffer->ByteLength(); + return true; + } + return false; +} + +void LabelToEncodingCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + info.GetReturnValue().Set(LookupEncoding(tns::ToString(isolate, info[0]))); +} + +void DecodeCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + + const uint8_t* input = nullptr; + size_t inputLength = 0; + if (!GetByteSource(info[0], &input, &inputLength)) { + isolate->ThrowException(Exception::TypeError(tns::ToV8String( + isolate, + "The \"input\" argument must be an ArrayBuffer, SharedArrayBuffer or " + "ArrayBufferView"))); + return; + } + + const uint32_t encoding = + static_cast(info[1].As()->Value()); + const uint32_t flags = + static_cast(info[2].As()->Value()); + const bool fatal = (flags & kFlagFatal) != 0; + const bool ignoreBOM = (flags & kFlagIgnoreBOM) != 0; + const bool stream = (flags & kFlagStream) != 0; + + Local stateArray = info[3].As(); + uint8_t* rawState = static_cast(stateArray->Buffer()->Data()) + + stateArray->ByteOffset(); + DecoderState state; + state.Load(rawState); + + // Byte-for-byte one-byte results skip the intermediate code-unit buffer. + if (input != nullptr && !stream && state.utf8PendingLength == 0 && + !state.hasLeadByte && !state.hasLeadSurrogate) { + const bool asciiUtf8 = + encoding == kUtf8 && AllBytesBelow(input, inputLength, 0x80); + const bool latin1Windows1252 = + encoding == kWindows1252 && NoC1Bytes(input, inputLength); + if (asciiUtf8 || latin1Windows1252) { + state.Reset(); + state.Store(rawState); + Local result; + if (v8::String::NewFromOneByte(isolate, input, NewStringType::kNormal, + static_cast(inputLength)) + .ToLocal(&result)) { + info.GetReturnValue().Set(result); + } + return; + } + } + + Utf16Sink sink; + bool ok = true; + switch (encoding) { + case kUtf8: + ok = + DecodeUtf8(input, inputLength, state, stream, fatal, ignoreBOM, sink); + break; + case kUtf16le: + case kUtf16be: + ok = DecodeUtf16(input, inputLength, encoding == kUtf16be, state, stream, + fatal, ignoreBOM, sink); + break; + default: + DecodeWindows1252(input, inputLength, sink); + break; + } + + // A non-streaming call is the end of a stream: the next one starts from a + // clean decoder, BOM tracking included. + if (!stream) { + state.Reset(); + } + state.Store(rawState); + if (!ok) { + isolate->ThrowException(Exception::TypeError( + tns::ToV8String(isolate, "The encoded data was not valid"))); + return; + } + + Local result; + if (sink.Finish(isolate).ToLocal(&result)) { + info.GetReturnValue().Set(result); + } +} + +void EncodeUtf8Callback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local source = info[0].As(); + + const size_t length = source->Utf8LengthV2(isolate); + std::unique_ptr store = + ArrayBuffer::NewBackingStore(isolate, length); + if (length > 0) { + source->WriteUtf8V2(isolate, static_cast(store->Data()), length, + v8::String::WriteFlags::kReplaceInvalidUtf8); + } + Local buffer = ArrayBuffer::New(isolate, std::move(store)); + info.GetReturnValue().Set(Uint8Array::New(buffer, 0, length)); +} + +// Writes as much of `source` as fits into `destination` without splitting an +// encoded code point, and reports {read, written} through `results` — a +// Uint32Array the builtin owns, so the op returns only the destination +// type check and stays expressible as a fast call. A destination that is a +// Uint8Array but detached or empty is a zero-length write, not a failure. +bool EncodeIntoImpl(Isolate* isolate, Local sourceValue, + Local destinationValue, Local resultsValue) { + if (!destinationValue->IsUint8Array()) { + return false; + } + if (!sourceValue->IsString() || !resultsValue->IsUint32Array()) { + return true; + } + + Local results = resultsValue.As(); + uint32_t* resultData = static_cast(results->Buffer()->Data()); + if (resultData == nullptr || results->Length() < 2) { + return true; + } + resultData += results->ByteOffset() / sizeof(uint32_t); + resultData[0] = 0; + resultData[1] = 0; + + Local destination = destinationValue.As(); + void* base = destination->Buffer()->Data(); + const size_t capacity = destination->ByteLength(); + if (base == nullptr || capacity == 0) { + return true; + } + + size_t read = 0; + const size_t written = sourceValue.As()->WriteUtf8V2( + isolate, static_cast(base) + destination->ByteOffset(), capacity, + v8::String::WriteFlags::kReplaceInvalidUtf8, &read); + resultData[0] = static_cast(read); + resultData[1] = static_cast(written); + return true; +} + +void EncodeIntoCallback(const FunctionCallbackInfo& info) { + info.GetReturnValue().Set( + EncodeIntoImpl(info.GetIsolate(), info[0], info[1], info[2])); +} + +#if NATIVESCRIPT_ENABLE_FAST_API +// Fast-call overload of encodeInto. Inert wherever V8's optimizing tiers are +// absent (iOS runs jitless), so the slow callback above stays the only path +// there. It allocates nothing on the V8 heap and calls no JS. +bool FastEncodeInto(Local receiver, Local source, + Local destination, Local results, + // NOLINTNEXTLINE(runtime/references) + FastApiCallbackOptions& options) { + HandleScope scope(options.isolate); + return EncodeIntoImpl(options.isolate, source, destination, results); +} + +const CFunction kFastEncodeInto = CFunction::Make(FastEncodeInto); +#endif + +} // namespace + +Local TextEncoding::CreateBinding(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local binding = Object::New(isolate); + + tns::SetMethodNoSideEffect(context, binding, "labelToEncoding", + LabelToEncodingCallback); + tns::SetMethod(context, binding, "decode", DecodeCallback); + tns::SetMethodNoSideEffect(context, binding, "encodeUtf8", + EncodeUtf8Callback); +#if NATIVESCRIPT_ENABLE_FAST_API + tns::SetFastMethod(context, binding, "encodeInto", EncodeIntoCallback, + &kFastEncodeInto); +#else + tns::SetMethod(context, binding, "encodeInto", EncodeIntoCallback); +#endif + + return binding; +} + +} // namespace tns diff --git a/NativeScript/runtime/TextEncoding.h b/NativeScript/runtime/TextEncoding.h new file mode 100644 index 00000000..8da5b22b --- /dev/null +++ b/NativeScript/runtime/TextEncoding.h @@ -0,0 +1,22 @@ +#ifndef TextEncoding_h +#define TextEncoding_h + +#include "Common.h" + +namespace tns { + +// Native ops behind the text-encoding builtin (internal/text-encoding.js): +// the WHATWG label table, UTF-8 encoding and the decoders for the encodings +// the runtime supports (utf-8, utf-16le, utf-16be, windows-1252). The builtin +// owns the web-facing shapes; everything that touches bytes lives here. +class TextEncoding { + public: + static v8::Local CreateBinding(v8::Local context); + + // Bytes of decoder state the builtin must hand back on every decode call. + static constexpr int kDecoderStateSize = 16; +}; + +} // namespace tns + +#endif /* TextEncoding_h */ diff --git a/NativeScript/runtime/js/README.md b/NativeScript/runtime/js/README.md index 76d8c7b4..cc16e24b 100644 --- a/NativeScript/runtime/js/README.md +++ b/NativeScript/runtime/js/README.md @@ -61,12 +61,37 @@ module.exports = somethingTheCallSiteNeeds; - Destructure `binding` and `primordials` once, at the top of the file, so the file's dependencies are visible and greppable. +## Eager and lazy builtins + +Most builtins run during `Runtime::Init` and install their globals themselves. +A **lazy** builtin instead exports its interfaces and is run by +`LazyGlobals` (`runtime/LazyGlobals.cpp`), which registers each global it backs +as a lazy data property on the global template: the first read of the name runs +the file, caches its `module.exports` per isolate so sibling names share one +run, and V8 replaces the property with a plain data property. Until then +nothing of it exists — no compile, no run, no allocation. `text-encoding.js` +(`TextEncoder`/`TextDecoder`) and `base64.js` (`atob`/`btoa`) are the current +ones; new globals join by adding a row to `kLazyGlobals`. + +The two extra rules a lazy builtin lives by: + +- **It runs at an arbitrary point in the isolate's life, not at init.** The + `internals` channel is therefore off limits: its producers publish during + their own init, and a consumer that reads a key it does not find fails at + first use instead of loudly at boot. Anything a lazy builtin needs from + another builtin has to come through `require` or its `binding`. +- **It must not install anything on `globalThis`.** The C++ tier owns + placement; a file that self-installs would have to run to do it, which is + the thing being avoided. + ## Rules -- Run at isolate init, before any user code: capture any global you rely on - (e.g. `globalThis.Event`) eagerly so later monkey-patching can't break you. - For intrinsics that is what `primordials` is; for everything else - (`URLSearchParams`, …) capture it into a file-level `const`. +- Eager builtins run at isolate init, before any user code: capture any global + you rely on (e.g. `globalThis.Event`) eagerly so later monkey-patching can't + break you. For intrinsics that is what `primordials` is; for everything else + (`URLSearchParams`, …) capture it into a file-level `const`. A lazy builtin + gets the same pristine `primordials`, but the live globals it would capture + are whatever user code left behind, so it should not reach for them at all. - No `import`/`export` — these are classic function bodies, not modules. - ESLint (`eslint.config.mjs` at the repo root, run by lint-staged) declares `exports`, `require`, `module`, `binding`, `primordials` and the reachable diff --git a/NativeScript/runtime/js/base64.js b/NativeScript/runtime/js/base64.js new file mode 100644 index 00000000..3fa07542 --- /dev/null +++ b/NativeScript/runtime/js/base64.js @@ -0,0 +1,46 @@ +"use strict"; +// atob / btoa (HTML Standard §8.3, base64 utility methods) over the WHATWG +// forgiving-base64 codec in Base64.cpp. +// +// This file exports the two functions instead of installing them; the C++ +// lazy-global tier (LazyGlobals) places them and is what runs this file, on +// the first read of either name. Nothing here may depend on a builtin that +// runs after it, so `internals` is off limits — see the README. +// +// Deliberate deviation from the spec: no DOMException in this runtime, so the +// failure is an Error with `name` patched to "InvalidCharacterError", the same +// stand-in abort-signal.js and performance.js use. The native ops answer null +// on failure rather than throwing, so that shape stays here. +const { Error, TypeError } = primordials; + +const { atob: decodeBase64, btoa: encodeBase64 } = binding; + +function invalidCharacterError() { + const e = new Error("Invalid character"); + e.name = "InvalidCharacterError"; + return e; +} + +function btoa(data) { + if (arguments.length < 1) { + throw new TypeError("btoa requires 1 argument"); + } + const result = encodeBase64(`${data}`); + if (result === null) { + throw invalidCharacterError(); + } + return result; +} + +function atob(data) { + if (arguments.length < 1) { + throw new TypeError("atob requires 1 argument"); + } + const result = decodeBase64(`${data}`); + if (result === null) { + throw invalidCharacterError(); + } + return result; +} + +module.exports = { atob, btoa }; diff --git a/NativeScript/runtime/js/primordials.js b/NativeScript/runtime/js/primordials.js index 984e3262..37381979 100644 --- a/NativeScript/runtime/js/primordials.js +++ b/NativeScript/runtime/js/primordials.js @@ -30,6 +30,8 @@ const intrinsics = { Set, String, TypeError, + Uint8Array, + Uint32Array, URL, WeakRef, SymbolHasInstance: Symbol.hasInstance, diff --git a/NativeScript/runtime/js/text-encoding.js b/NativeScript/runtime/js/text-encoding.js new file mode 100644 index 00000000..37129060 --- /dev/null +++ b/NativeScript/runtime/js/text-encoding.js @@ -0,0 +1,172 @@ +"use strict"; +// TextEncoder / TextDecoder (WHATWG Encoding Standard, +// https://encoding.spec.whatwg.org). +// +// This file exports the two interfaces instead of installing them; the C++ +// lazy-global tier (LazyGlobals) places them and is what runs this file, on +// the first read of either name. Nothing here may depend on a builtin that +// runs after it, so `internals` is off limits — see the README. +// +// The supported encodings (utf-8, utf-16le, utf-16be, windows-1252) with +// their complete label sets, the decoders and the UTF-8 encoder all live in +// TextEncoding.cpp. Per-decoder streaming state is the Uint8Array this file +// allocates and the native decoder reads and rewrites, so a TextDecoder needs +// neither a native handle nor a finalizer. +const { + ObjectDefineProperty, + ObjectGetOwnPropertyDescriptor, + RangeError, + SymbolToStringTag, + TypeError, + Uint8Array, + Uint32Array, +} = primordials; + +const { labelToEncoding, decode, encodeUtf8, encodeInto } = binding; + +// Indexed by the encoding ids labelToEncoding returns. +const kEncodingNames = ["utf-8", "utf-16le", "utf-16be", "windows-1252"]; + +// Mirror the kFlag* constants in TextEncoding.cpp. +const kFlagFatal = 1; +const kFlagIgnoreBOM = 2; +const kFlagStream = 4; + +// Mirrors TextEncoding::kDecoderStateSize. +const kDecoderStateSize = 16; + +// encodeInto reports {read, written} through this rather than allocating a +// result object natively; the op is synchronous, so one buffer serves every +// encoder in the isolate. +const encodeIntoResults = new Uint32Array(2); + +// WebIDL dictionary conversion: undefined and null mean "all defaults", +// anything else must be an object. +function toDictionary(value, name) { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== "object" && typeof value !== "function") { + throw new TypeError(`The "${name}" argument must be an object`); + } + return value; +} + +class TextEncoder { + #brand; + + static #check(receiver) { + if (!(#brand in receiver)) { + throw new TypeError("Illegal invocation"); + } + } + + get encoding() { + TextEncoder.#check(this); + return "utf-8"; + } + + encode(input = "") { + TextEncoder.#check(this); + return encodeUtf8(`${input}`); + } + + encodeInto(source, destination) { + TextEncoder.#check(this); + const text = `${source}`; + if (!encodeInto(text, destination, encodeIntoResults)) { + throw new TypeError( + 'The "destination" argument must be an instance of Uint8Array' + ); + } + return { read: encodeIntoResults[0], written: encodeIntoResults[1] }; + } +} + +class TextDecoder { + #encoding; + #fatal; + #ignoreBOM; + #flags; + #state; + + static #check(receiver) { + if (!(#encoding in receiver)) { + throw new TypeError("Illegal invocation"); + } + } + + constructor(label = "utf-8", options = undefined) { + const name = `${label}`; + const dictionary = toDictionary(options, "options"); + const encoding = labelToEncoding(name); + if (encoding < 0) { + throw new RangeError(`The encoding "${name}" is not supported`); + } + const fatal = dictionary !== undefined && !!dictionary.fatal; + const ignoreBOM = dictionary !== undefined && !!dictionary.ignoreBOM; + this.#encoding = encoding; + this.#fatal = fatal; + this.#ignoreBOM = ignoreBOM; + this.#flags = (fatal ? kFlagFatal : 0) | (ignoreBOM ? kFlagIgnoreBOM : 0); + this.#state = new Uint8Array(kDecoderStateSize); + } + + get encoding() { + TextDecoder.#check(this); + return kEncodingNames[this.#encoding]; + } + + get fatal() { + TextDecoder.#check(this); + return this.#fatal; + } + + get ignoreBOM() { + TextDecoder.#check(this); + return this.#ignoreBOM; + } + + decode(input = undefined, options = undefined) { + TextDecoder.#check(this); + const dictionary = toDictionary(options, "options"); + const stream = dictionary !== undefined && !!dictionary.stream; + return decode( + input, + this.#encoding, + stream ? this.#flags | kFlagStream : this.#flags, + this.#state + ); + } +} + +// WebIDL shape: interface members are enumerable prototype properties and the +// class string is a configurable, non-writable Symbol.toStringTag; class +// syntax alone yields non-enumerable members. +function finishInterface(ctor, tag, members) { + const proto = ctor.prototype; + ObjectDefineProperty(proto, SymbolToStringTag, { + value: tag, + writable: false, + enumerable: false, + configurable: true, + }); + for (let i = 0; i < members.length; i++) { + const desc = ObjectGetOwnPropertyDescriptor(proto, members[i]); + desc.enumerable = true; + ObjectDefineProperty(proto, members[i], desc); + } +} +finishInterface(TextEncoder, "TextEncoder", [ + "encoding", + "encode", + "encodeInto", +]); +finishInterface(TextDecoder, "TextDecoder", [ + "encoding", + "fatal", + "ignoreBOM", + "decode", +]); + +module.exports = { TextEncoder, TextDecoder }; diff --git a/tools/js2c-inputs.xcfilelist b/tools/js2c-inputs.xcfilelist index 1913e2ab..18c55154 100644 --- a/tools/js2c-inputs.xcfilelist +++ b/tools/js2c-inputs.xcfilelist @@ -1,5 +1,6 @@ $(SRCROOT)/tools/js2c.mjs $(SRCROOT)/NativeScript/runtime/js/abort-signal.js +$(SRCROOT)/NativeScript/runtime/js/base64.js $(SRCROOT)/NativeScript/runtime/js/blob-url.js $(SRCROOT)/NativeScript/runtime/js/class-extends.js $(SRCROOT)/NativeScript/runtime/js/error-events.js @@ -17,5 +18,6 @@ $(SRCROOT)/NativeScript/runtime/js/performance.js $(SRCROOT)/NativeScript/runtime/js/promise-proxy.js $(SRCROOT)/NativeScript/runtime/js/require-factory.js $(SRCROOT)/NativeScript/runtime/js/structured-clone.js +$(SRCROOT)/NativeScript/runtime/js/text-encoding.js $(SRCROOT)/NativeScript/runtime/js/ts-helpers.js $(SRCROOT)/NativeScript/runtime/js/weak-ref.js diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index c3083b3e..ad53635c 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -22,6 +22,12 @@ 2BFE22062AC1C93100307752 /* metadata-arm64.bin in Resources */ = {isa = PBXBuildFile; fileRef = 2BFE22052AC1C93100307752 /* metadata-arm64.bin */; }; 3C1850542A6DCB2D002ACC81 /* Timers.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3C1850522A6DCB2D002ACC81 /* Timers.cpp */; }; 3C1850552A6DCB2D002ACC81 /* Timers.hpp in Headers */ = {isa = PBXBuildFile; fileRef = 3C1850532A6DCB2D002ACC81 /* Timers.hpp */; }; + 3CAE10112F900001002ACC81 /* TextEncoding.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3CAE10012F900001002ACC81 /* TextEncoding.cpp */; }; + 3CAE10122F900001002ACC81 /* TextEncoding.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CAE10022F900001002ACC81 /* TextEncoding.h */; }; + 3CAE10132F900001002ACC81 /* Base64.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3CAE10032F900001002ACC81 /* Base64.cpp */; }; + 3CAE10142F900001002ACC81 /* Base64.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CAE10042F900001002ACC81 /* Base64.h */; }; + 3CAE10152F900001002ACC81 /* LazyGlobals.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3CAE10052F900001002ACC81 /* LazyGlobals.cpp */; }; + 3CAE10162F900001002ACC81 /* LazyGlobals.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CAE10062F900001002ACC81 /* LazyGlobals.h */; }; 3CFCA0032E5A0001002ACC81 /* AnimationFrame.mm in Sources */ = {isa = PBXBuildFile; fileRef = 3CFCA0012E5A0001002ACC81 /* AnimationFrame.mm */; }; 3CFCA0042E5A0001002ACC81 /* AnimationFrame.hpp in Headers */ = {isa = PBXBuildFile; fileRef = 3CFCA0022E5A0001002ACC81 /* AnimationFrame.hpp */; }; 3C48F68D2F57905500C14231 /* json.hpp in Headers */ = {isa = PBXBuildFile; fileRef = 3C48F68B2F57905500C14231 /* json.hpp */; }; @@ -469,6 +475,12 @@ 2BFE22052AC1C93100307752 /* metadata-arm64.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; path = "metadata-arm64.bin"; sourceTree = ""; }; 3C1850522A6DCB2D002ACC81 /* Timers.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = Timers.cpp; sourceTree = ""; }; 3C1850532A6DCB2D002ACC81 /* Timers.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = Timers.hpp; sourceTree = ""; }; + 3CAE10012F900001002ACC81 /* TextEncoding.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = TextEncoding.cpp; sourceTree = ""; }; + 3CAE10022F900001002ACC81 /* TextEncoding.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = TextEncoding.h; sourceTree = ""; }; + 3CAE10032F900001002ACC81 /* Base64.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = Base64.cpp; sourceTree = ""; }; + 3CAE10042F900001002ACC81 /* Base64.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = Base64.h; sourceTree = ""; }; + 3CAE10052F900001002ACC81 /* LazyGlobals.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = LazyGlobals.cpp; sourceTree = ""; }; + 3CAE10062F900001002ACC81 /* LazyGlobals.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = LazyGlobals.h; sourceTree = ""; }; 3CFCA0012E5A0001002ACC81 /* AnimationFrame.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = AnimationFrame.mm; sourceTree = ""; }; 3CFCA0022E5A0001002ACC81 /* AnimationFrame.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = AnimationFrame.hpp; sourceTree = ""; }; 3C48F68B2F57905500C14231 /* json.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = json.hpp; sourceTree = ""; }; @@ -1547,6 +1559,12 @@ 3C78BA5B2A0D600100C20A88 /* ModuleBinding.hpp */, 3C1850522A6DCB2D002ACC81 /* Timers.cpp */, 3C1850532A6DCB2D002ACC81 /* Timers.hpp */, + 3CAE10012F900001002ACC81 /* TextEncoding.cpp */, + 3CAE10022F900001002ACC81 /* TextEncoding.h */, + 3CAE10032F900001002ACC81 /* Base64.cpp */, + 3CAE10042F900001002ACC81 /* Base64.h */, + 3CAE10052F900001002ACC81 /* LazyGlobals.cpp */, + 3CAE10062F900001002ACC81 /* LazyGlobals.h */, 3CFCA0012E5A0001002ACC81 /* AnimationFrame.mm */, 3CFCA0022E5A0001002ACC81 /* AnimationFrame.hpp */, 3C5333332B0E683100BE0C47 /* Message.hpp */, @@ -1644,6 +1662,9 @@ C22536B8241A318900192740 /* ffi.h in Headers */, C247C16F22F82842001D2CA2 /* v8-util.h in Headers */, 3C1850552A6DCB2D002ACC81 /* Timers.hpp in Headers */, + 3CAE10122F900001002ACC81 /* TextEncoding.h in Headers */, + 3CAE10142F900001002ACC81 /* Base64.h in Headers */, + 3CAE10162F900001002ACC81 /* LazyGlobals.h in Headers */, 3CFCA0042E5A0001002ACC81 /* AnimationFrame.hpp in Headers */, C2C8EE7222CE323C001F8CEC /* ConcurrentMap.h in Headers */, C2A6EF3123745A0B00E8FBE7 /* MetadataInlines.h in Headers */, @@ -2289,6 +2310,9 @@ 6573B9D4291FE29F00B0ED7C /* V8RuntimeFactory.cpp in Sources */, C2DDEBB4229EAC8300345BFE /* DictionaryAdapter.mm in Sources */, 3C1850542A6DCB2D002ACC81 /* Timers.cpp in Sources */, + 3CAE10112F900001002ACC81 /* TextEncoding.cpp in Sources */, + 3CAE10132F900001002ACC81 /* Base64.cpp in Sources */, + 3CAE10152F900001002ACC81 /* LazyGlobals.cpp in Sources */, 3CFCA0032E5A0001002ACC81 /* AnimationFrame.mm in Sources */, C298C027233C9AEA000DDF54 /* TSHelpers.cpp in Sources */, C2FEA16F22A3C75C00A5C0FC /* InlineFunctions.cpp in Sources */, From d41fb373701b93fa7d4bd7b75df28ed5d6b5a145 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 24 Aug 2026 17:03:15 -0300 Subject: [PATCH 2/5] test: bump shared tests for the TextEncoding suites --- TestRunner/app/shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 0baab7cc..0f45dc87 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 0baab7cceaca2bb5fdb7b697c08b19be1e46d925 +Subproject commit 0f45dc8776207618c2dce3202f8767d2275dfb5a From 62b6927830b656bc5c53550ce2891da999b98256 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 24 Aug 2026 17:48:01 -0300 Subject: [PATCH 3/5] feat(runtime): expose TextEncoder/TextDecoder from ns:util and node:util MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node puts the two encoding interfaces on util, so both the standard module and its shim carry them, and they are the very objects the globals of those names hold: require("node:util").TextDecoder === globalThis.TextDecoder, whichever is reached first. That identity needs one cache. The lazy-global tier had its own per-isolate exports slots and the builtin-module registry another one keyed by specifier, so a builtin reached through both would have run twice and exported two sets of classes. Both now go through BuiltinLoader::GetExports, which runs a builtin at most once per isolate — with its binding, built only when the run actually happens — and hands back that one module.exports. TextEncoding and Base64 own the accessor for their file; the registry gained a per-specifier binding factory in place of the switch, which is what let the two schemes converge. Requiring util still costs nothing extra: ns:util's binding carries the two names as lazy data properties and both files keep the read inside a getter, so the text-encoding builtin runs on the first read of util.TextEncoder, not on the require. --- NativeScript/runtime/Base64.cpp | 11 +- NativeScript/runtime/Base64.h | 4 +- NativeScript/runtime/BuiltinLoader.cpp | 37 ++++ NativeScript/runtime/BuiltinLoader.h | 15 ++ NativeScript/runtime/Caches.h | 10 +- NativeScript/runtime/LazyGlobals.cpp | 58 +----- NativeScript/runtime/LazyGlobals.h | 7 +- NativeScript/runtime/NsBuiltinModules.cpp | 192 +++++++++--------- NativeScript/runtime/TextEncoding.cpp | 12 +- NativeScript/runtime/TextEncoding.h | 6 +- NativeScript/runtime/js/README.md | 7 +- NativeScript/runtime/js/node-util.js | 16 +- NativeScript/runtime/js/ns-util.js | 14 +- TestRunner/app/tests/NsUtilTests.js | 49 ++++- .../app/tests/nsUtilEncodingOrderWorker.js | 28 +++ docs/ns-builtin-modules.md | 10 +- types/ns-util.d.ts | 35 ++++ 17 files changed, 346 insertions(+), 165 deletions(-) create mode 100644 TestRunner/app/tests/nsUtilEncodingOrderWorker.js diff --git a/NativeScript/runtime/Base64.cpp b/NativeScript/runtime/Base64.cpp index 41ea6789..221ba91d 100644 --- a/NativeScript/runtime/Base64.cpp +++ b/NativeScript/runtime/Base64.cpp @@ -2,6 +2,7 @@ #include +#include "BuiltinLoader.h" #include "Helpers.h" using namespace v8; @@ -167,9 +168,7 @@ void AtobCallback(const FunctionCallbackInfo& info) { } } -} // namespace - -Local Base64::CreateBinding(Local context) { +MaybeLocal CreateBinding(Local context) { Isolate* isolate = v8::Isolate::GetCurrent(); Local binding = Object::New(isolate); tns::SetMethodNoSideEffect(context, binding, "btoa", BtoaCallback); @@ -177,4 +176,10 @@ Local Base64::CreateBinding(Local context) { return binding; } +} // namespace + +MaybeLocal Base64::GetExports(Local context) { + return BuiltinLoader::GetExports(context, BuiltinId::kBase64, CreateBinding); +} + } // namespace tns diff --git a/NativeScript/runtime/Base64.h b/NativeScript/runtime/Base64.h index 100008a7..9408d3bb 100644 --- a/NativeScript/runtime/Base64.h +++ b/NativeScript/runtime/Base64.h @@ -10,7 +10,9 @@ namespace tns { // null instead of throwing, so the builtin owns the error shape. class Base64 { public: - static v8::Local CreateBinding(v8::Local context); + // The builtin's exports, `{ atob, btoa }`, from the one run it gets per + // isolate. + static v8::MaybeLocal GetExports(v8::Local context); }; } // namespace tns diff --git a/NativeScript/runtime/BuiltinLoader.cpp b/NativeScript/runtime/BuiltinLoader.cpp index 888645a6..4da2aec4 100644 --- a/NativeScript/runtime/BuiltinLoader.cpp +++ b/NativeScript/runtime/BuiltinLoader.cpp @@ -31,6 +31,13 @@ constexpr const char* kPrimordialsParamName = "primordials"; constexpr const char* kInternalsParamName = "internals"; constexpr int kParamCount = 6; +// `module.exports` of every builtin that has run in this isolate, indexed by +// id. Per isolate because a builtin is a singleton per realm, so workers run +// their own copy of a file and export their own objects. +struct BuiltinExportsState { + Persistent exports[static_cast(BuiltinId::kCount)]; +}; + // Per-isolate `internals` object handed to every builtin: the private // channel for cross-builtin capabilities (hook keys, setters) that must // never reach app code. Producers publish during their init, consumers read @@ -242,4 +249,34 @@ MaybeLocal BuiltinLoader::RunBuiltin(Local context, return CallBuiltin(context, id, binding, primordials, internals); } +MaybeLocal BuiltinLoader::GetExports(Local context, + BuiltinId id, + BindingFactory bindingFactory) { + Isolate* isolate = v8::Isolate::GetCurrent(); + auto* state = Caches::StateFor(isolate); + if (state == nullptr) { + return MaybeLocal(); + } + + const unsigned index = static_cast(id); + if (!state->exports[index].IsEmpty()) { + return state->exports[index].Get(isolate); + } + + Local binding; + if (bindingFactory != nullptr && !bindingFactory(context).ToLocal(&binding)) { + return MaybeLocal(); + } + + Local result; + if (!RunBuiltin(context, id, binding).ToLocal(&result) || + !result->IsObject()) { + return MaybeLocal(); + } + + Local exports = result.As(); + state->exports[index].Reset(isolate, exports); + return exports; +} + } // namespace tns diff --git a/NativeScript/runtime/BuiltinLoader.h b/NativeScript/runtime/BuiltinLoader.h index fdea44ab..3373789e 100644 --- a/NativeScript/runtime/BuiltinLoader.h +++ b/NativeScript/runtime/BuiltinLoader.h @@ -8,6 +8,11 @@ namespace tns { class BuiltinLoader { public: + // Builds the bag of natives a builtin receives as its `binding` parameter. + // GetExports calls it only when the builtin actually runs, so a call site + // that hits the cache pays nothing for it. + using BindingFactory = v8::MaybeLocal (*)(v8::Local); + // Compiles the builtin identified by id as a function body with the fixed // parameters `exports`, `require`, `module`, `binding` (Node's module wrapper // plus its internalBinding idiom), `primordials` and `internals`, calls it @@ -29,6 +34,16 @@ class BuiltinLoader { static v8::MaybeLocal RunBuiltin( v8::Local context, BuiltinId id, v8::Local binding = v8::Local()); + + // The builtin's `module.exports`, running it at most once per isolate. Every + // entry point that reaches the same file — the `ns:`/`node:` module registry, + // the lazy-global tier, another builtin's `require` — shares that one run, so + // a value a file exports is the same object through all of them. Empty when + // the builtin failed to run (an exception is pending) or exported a + // non-object. + static v8::MaybeLocal GetExports(v8::Local context, + BuiltinId id, + BindingFactory binding); }; } // namespace tns diff --git a/NativeScript/runtime/Caches.h b/NativeScript/runtime/Caches.h index 9c983318..bc4641e4 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -218,12 +218,10 @@ class Caches { std::unique_ptr> UnmanagedTypeCtorFunc = std::unique_ptr>(nullptr); - // `ns:`/`node:` builtin modules (NsBuiltinModules), keyed by specifier. Both - // are per isolate: a builtin module is a singleton per realm, so workers get - // their own exports objects and their own synthetic modules. - robin_hood::unordered_map>> - BuiltinModuleExports; + // Synthetic ES modules wrapping the `ns:`/`node:` builtin modules + // (NsBuiltinModules), keyed by specifier. Per isolate: a builtin module is a + // singleton per realm, so workers get their own. The exports they wrap live + // in the per-isolate builtin exports cache (BuiltinLoader::GetExports). robin_hood::unordered_map>> BuiltinModules; diff --git a/NativeScript/runtime/LazyGlobals.cpp b/NativeScript/runtime/LazyGlobals.cpp index eebb129a..dcc60ec9 100644 --- a/NativeScript/runtime/LazyGlobals.cpp +++ b/NativeScript/runtime/LazyGlobals.cpp @@ -1,8 +1,6 @@ #include "LazyGlobals.h" #include "Base64.h" -#include "BuiltinLoader.h" -#include "Caches.h" #include "Helpers.h" #include "TextEncoding.h" @@ -12,60 +10,24 @@ namespace tns { namespace { -using BindingFactory = Local (*)(Local); +// The builtin's `module.exports`, from the single run it gets per isolate +// (BuiltinLoader::GetExports). Two globals out of the same file therefore cost +// one run, and so does a module that exports the same interfaces. +using ExportsAccessor = MaybeLocal (*)(Local); struct LazyGlobalEntry { const char* name; - BuiltinId builtin; const char* exportName; // key of `name` in the builtin's module.exports - BindingFactory binding; // natives the builtin needs, null if it needs none + ExportsAccessor exports; }; constexpr LazyGlobalEntry kLazyGlobals[] = { - {"TextEncoder", BuiltinId::kTextEncoding, "TextEncoder", - TextEncoding::CreateBinding}, - {"TextDecoder", BuiltinId::kTextEncoding, "TextDecoder", - TextEncoding::CreateBinding}, - {"atob", BuiltinId::kBase64, "atob", Base64::CreateBinding}, - {"btoa", BuiltinId::kBase64, "btoa", Base64::CreateBinding}, + {"TextEncoder", "TextEncoder", TextEncoding::GetExports}, + {"TextDecoder", "TextDecoder", TextEncoding::GetExports}, + {"atob", "atob", Base64::GetExports}, + {"btoa", "btoa", Base64::GetExports}, }; -// One entry per builtin this tier can run, so two globals from the same file -// cost one run. -struct LazyGlobalsState { - Persistent exports[static_cast(BuiltinId::kCount)]; -}; - -MaybeLocal GetExports(Local context, - const LazyGlobalEntry& entry) { - Isolate* isolate = v8::Isolate::GetCurrent(); - auto* state = Caches::StateFor(isolate); - if (state == nullptr) { - return MaybeLocal(); - } - - const unsigned index = static_cast(entry.builtin); - if (!state->exports[index].IsEmpty()) { - return state->exports[index].Get(isolate); - } - - Local binding; - if (entry.binding != nullptr) { - binding = entry.binding(context); - } - - Local result; - if (!BuiltinLoader::RunBuiltin(context, entry.builtin, binding) - .ToLocal(&result) || - !result->IsObject()) { - return MaybeLocal(); - } - - Local exports = result.As(); - state->exports[index].Reset(isolate, exports); - return exports; -} - void LazyGlobalGetter(Local property, const PropertyCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); @@ -74,7 +36,7 @@ void LazyGlobalGetter(Local property, Local context = isolate->GetCurrentContext(); Local exports; - if (!GetExports(context, *entry).ToLocal(&exports)) { + if (!entry->exports(context).ToLocal(&exports)) { return; } Local value; diff --git a/NativeScript/runtime/LazyGlobals.h b/NativeScript/runtime/LazyGlobals.h index 60b57d7a..3123e926 100644 --- a/NativeScript/runtime/LazyGlobals.h +++ b/NativeScript/runtime/LazyGlobals.h @@ -10,9 +10,10 @@ namespace tns { // Globals whose implementation is a runtime builtin that must not run until // someone actually reaches for the name. Each entry is registered on the // global template as a lazy data property; the first read runs the builtin -// once per isolate and caches its exports, so sibling names (TextEncoder and -// TextDecoder) share the run, and V8 then replaces the property with a plain -// data property so later reads cost nothing. +// through the per-isolate exports cache (BuiltinLoader::GetExports), so sibling +// names (TextEncoder and TextDecoder) share the run — as does a module +// exporting the same interfaces — and V8 then replaces the property with a +// plain data property so later reads cost nothing. // // A builtin behind this tier runs at an arbitrary point in the isolate's life // rather than during init, so it may only consume `internals` keys published diff --git a/NativeScript/runtime/NsBuiltinModules.cpp b/NativeScript/runtime/NsBuiltinModules.cpp index 534a5255..63f97132 100644 --- a/NativeScript/runtime/NsBuiltinModules.cpp +++ b/NativeScript/runtime/NsBuiltinModules.cpp @@ -8,6 +8,7 @@ #include "Helpers.h" #include "ModuleInternalCallbacks.h" #include "Runtime.h" +#include "TextEncoding.h" using namespace v8; @@ -18,9 +19,17 @@ namespace { constexpr const char* kNsPrefix = "ns:"; constexpr const char* kNodePrefix = "node:"; +// Defined below, each next to the natives it gathers. +MaybeLocal NsModuleBinding(Local context); +MaybeLocal NsRuntimeBinding(Local context); +MaybeLocal NsUtilBinding(Local context); + struct Registration { const char* specifier; BuiltinId builtin; + // Natives the file receives as `binding`, null when it needs none (every + // `node:` shim, which reaches its `ns:` module through require instead). + BuiltinLoader::BindingFactory binding; }; // The public registry (docs/ns-builtin-modules.md). One specifier, one source @@ -28,12 +37,12 @@ struct Registration { // adapts, so the two module objects stay distinct and the standard module // never carries compatibility code. constexpr Registration kRegistry[] = { - {"ns:module", BuiltinId::kNsModule}, - {"ns:runtime", BuiltinId::kNsRuntime}, - {"ns:util", BuiltinId::kNsUtil}, - {"node:module", BuiltinId::kNodeModule}, - {"node:url", BuiltinId::kNodeUrl}, - {"node:util", BuiltinId::kNodeUtil}, + {"ns:module", BuiltinId::kNsModule, NsModuleBinding}, + {"ns:runtime", BuiltinId::kNsRuntime, NsRuntimeBinding}, + {"ns:util", BuiltinId::kNsUtil, NsUtilBinding}, + {"node:module", BuiltinId::kNodeModule, nullptr}, + {"node:url", BuiltinId::kNodeUrl, nullptr}, + {"node:util", BuiltinId::kNodeUtil, nullptr}, }; // ns:runtime config keys. Each key defines its value domain and scope here; @@ -145,97 +154,77 @@ bool HasPrefix(const std::string& specifier, const char* prefix) { return specifier.rfind(prefix, 0) == 0; } -MaybeLocal BuildBinding(Local context, BuiltinId builtin) { +MaybeLocal NsModuleBinding(Local context) { Isolate* isolate = v8::Isolate::GetCurrent(); Local binding = Object::New(isolate); - - switch (builtin) { - case BuiltinId::kNsModule: { - // The HTTP-loader control surface (HttpLoader.mm). The binding builder - // decides build-dependent membership; ns-module.js only shapes - // and freezes whatever arrives. - if (!BuildNsModuleBinding(context, binding)) { - return MaybeLocal(); - } - break; - } - case BuiltinId::kNsRuntime: { - Local setConfig, getConfig; - if (!v8::Function::New(context, SetConfigCallback).ToLocal(&setConfig) || - !v8::Function::New(context, GetConfigCallback).ToLocal(&getConfig) || - !binding - ->Set(context, tns::ToV8String(isolate, "setConfig"), setConfig) - .FromMaybe(false) || - !binding - ->Set(context, tns::ToV8String(isolate, "getConfig"), getConfig) - .FromMaybe(false)) { - return MaybeLocal(); - } - break; - } - case BuiltinId::kNsUtil: { - // The console formatter is built once per realm; ns:util re-exports that - // instance instead of creating a second one. - Console::InitInspect(context); - std::shared_ptr cache = Caches::Get(isolate); - if (cache->InspectFunc == nullptr) { - return MaybeLocal(); - } - if (!binding - ->Set(context, tns::ToV8String(isolate, "inspect"), - cache->InspectFunc->Get(isolate)) - .FromMaybe(false)) { - return MaybeLocal(); - } - break; - } - default: - break; + // The HTTP-loader control surface (HttpLoader.mm). The binding builder + // decides build-dependent membership; ns-module.js only shapes and freezes + // whatever arrives. + if (!BuildNsModuleBinding(context, binding)) { + return MaybeLocal(); } - return binding; } -// Runs a module's builtin and caches its exports. Always leaves an exception -// pending when it returns false. -bool Instantiate(Local context, const Registration& requested) { +MaybeLocal NsRuntimeBinding(Local context) { Isolate* isolate = v8::Isolate::GetCurrent(); - std::shared_ptr cache = Caches::Get(isolate); + Local binding = Object::New(isolate); + Local setConfig, getConfig; + if (!v8::Function::New(context, SetConfigCallback).ToLocal(&setConfig) || + !v8::Function::New(context, GetConfigCallback).ToLocal(&getConfig) || + !binding->Set(context, tns::ToV8String(isolate, "setConfig"), setConfig) + .FromMaybe(false) || + !binding->Set(context, tns::ToV8String(isolate, "getConfig"), getConfig) + .FromMaybe(false)) { + return MaybeLocal(); + } + return binding; +} - // A shim reaches its ns: module through the builtin require, so the graph is - // walked while a module is still being built; a cycle would otherwise - // recurse until the stack runs out. - if (cache->BuiltinModulesInProgress.count(requested.specifier) > 0) { - isolate->ThrowException(Exception::Error( - tns::ToV8String(isolate, "Circular require of built-in module: " + - std::string(requested.specifier)))); - return false; +// TextEncoder / TextDecoder for ns:util, read straight out of the +// text-encoding builtin's per-isolate run, so the module's classes are the +// objects the globals of the same name expose. +void TextEncodingClassGetter(Local property, + const PropertyCallbackInfo& info) { + Local context = info.GetIsolate()->GetCurrentContext(); + Local exports; + Local value; + if (TextEncoding::GetExports(context).ToLocal(&exports) && + exports->Get(context, property).ToLocal(&value)) { + info.GetReturnValue().Set(value); } - cache->BuiltinModulesInProgress.emplace(requested.specifier); +} - TryCatch tc(isolate); - Local binding; - Local result; - bool built = BuildBinding(context, requested.builtin).ToLocal(&binding) && - BuiltinLoader::RunBuiltin(context, requested.builtin, binding) - .ToLocal(&result) && - result->IsObject(); - cache->BuiltinModulesInProgress.erase(requested.specifier); +MaybeLocal NsUtilBinding(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local binding = Object::New(isolate); - if (built) { - cache->BuiltinModuleExports[requested.specifier] = - std::make_unique>(isolate, result.As()); - return true; + // The console formatter is built once per realm; ns:util re-exports that + // instance instead of creating a second one. + Console::InitInspect(context); + std::shared_ptr cache = Caches::Get(isolate); + if (cache->InspectFunc == nullptr) { + return MaybeLocal(); + } + if (!binding + ->Set(context, tns::ToV8String(isolate, "inspect"), + cache->InspectFunc->Get(isolate)) + .FromMaybe(false)) { + return MaybeLocal(); } - if (tc.HasCaught()) { - tc.ReThrow(); - return false; + // Lazy so that requiring ns:util does not run the text-encoding builtin; + // ns-util.js keeps the read inside its own getters to preserve that. + for (const char* name : {"TextEncoder", "TextDecoder"}) { + if (binding + ->SetLazyDataProperty(context, tns::ToV8String(isolate, name), + TextEncodingClassGetter) + .IsNothing()) { + return MaybeLocal(); + } } - isolate->ThrowException(Exception::Error( - tns::ToV8String(isolate, "Failed to initialize built-in module '" + - std::string(requested.specifier) + "'"))); - return false; + + return binding; } // The exports a synthetic module re-exports by name, in the order used both @@ -327,17 +316,34 @@ MaybeLocal NsBuiltinModules::GetExports(Local context, Isolate* isolate = v8::Isolate::GetCurrent(); std::shared_ptr cache = Caches::Get(isolate); - auto it = cache->BuiltinModuleExports.find(specifier); - if (it == cache->BuiltinModuleExports.end()) { - if (!Instantiate(context, *registration)) { - return MaybeLocal(); - } - it = cache->BuiltinModuleExports.find(specifier); - if (it == cache->BuiltinModuleExports.end()) { - return MaybeLocal(); - } + + // A shim reaches its ns: module through the builtin require, so the graph is + // walked while a module is still being built; a cycle would otherwise + // recurse until the stack runs out. + if (cache->BuiltinModulesInProgress.count(specifier) > 0) { + isolate->ThrowException(Exception::Error(tns::ToV8String( + isolate, "Circular require of built-in module: " + specifier))); + return MaybeLocal(); + } + cache->BuiltinModulesInProgress.emplace(specifier); + + TryCatch tc(isolate); + Local exports; + bool built = BuiltinLoader::GetExports(context, registration->builtin, + registration->binding) + .ToLocal(&exports); + cache->BuiltinModulesInProgress.erase(specifier); + + if (built) { + return exports; + } + if (tc.HasCaught()) { + tc.ReThrow(); + return MaybeLocal(); } - return it->second->Get(isolate); + isolate->ThrowException(Exception::Error(tns::ToV8String( + isolate, "Failed to initialize built-in module '" + specifier + "'"))); + return MaybeLocal(); } MaybeLocal NsBuiltinModules::GetModule(Local context, diff --git a/NativeScript/runtime/TextEncoding.cpp b/NativeScript/runtime/TextEncoding.cpp index 0600affe..bfeecd7a 100644 --- a/NativeScript/runtime/TextEncoding.cpp +++ b/NativeScript/runtime/TextEncoding.cpp @@ -8,6 +8,7 @@ #include "v8-fast-api-calls.h" #pragma clang diagnostic pop +#include "BuiltinLoader.h" #include "Helpers.h" using namespace v8; @@ -639,9 +640,7 @@ bool FastEncodeInto(Local receiver, Local source, const CFunction kFastEncodeInto = CFunction::Make(FastEncodeInto); #endif -} // namespace - -Local TextEncoding::CreateBinding(Local context) { +MaybeLocal CreateBinding(Local context) { Isolate* isolate = v8::Isolate::GetCurrent(); Local binding = Object::New(isolate); @@ -660,4 +659,11 @@ Local TextEncoding::CreateBinding(Local context) { return binding; } +} // namespace + +MaybeLocal TextEncoding::GetExports(Local context) { + return BuiltinLoader::GetExports(context, BuiltinId::kTextEncoding, + CreateBinding); +} + } // namespace tns diff --git a/NativeScript/runtime/TextEncoding.h b/NativeScript/runtime/TextEncoding.h index 8da5b22b..854b5b2d 100644 --- a/NativeScript/runtime/TextEncoding.h +++ b/NativeScript/runtime/TextEncoding.h @@ -11,7 +11,11 @@ namespace tns { // owns the web-facing shapes; everything that touches bytes lives here. class TextEncoding { public: - static v8::Local CreateBinding(v8::Local context); + // The builtin's exports, `{ TextEncoder, TextDecoder }`, from the one run it + // gets per isolate. The lazy globals and `ns:util` both hand out these + // objects, so require("ns:util").TextDecoder === globalThis.TextDecoder + // whichever is reached first. + static v8::MaybeLocal GetExports(v8::Local context); // Bytes of decoder state the builtin must hand back on every decode call. static constexpr int kDecoderStateSize = 16; diff --git a/NativeScript/runtime/js/README.md b/NativeScript/runtime/js/README.md index cc16e24b..144e358e 100644 --- a/NativeScript/runtime/js/README.md +++ b/NativeScript/runtime/js/README.md @@ -67,8 +67,11 @@ Most builtins run during `Runtime::Init` and install their globals themselves. A **lazy** builtin instead exports its interfaces and is run by `LazyGlobals` (`runtime/LazyGlobals.cpp`), which registers each global it backs as a lazy data property on the global template: the first read of the name runs -the file, caches its `module.exports` per isolate so sibling names share one -run, and V8 replaces the property with a plain data property. Until then +the file through the per-isolate exports cache (`BuiltinLoader::GetExports`) so +sibling names share one run, and V8 replaces the property with a plain data +property. That cache is the same one the `ns:`/`node:` module registry uses, so +a module re-exporting a lazy builtin's interfaces (`ns:util`'s `TextEncoder`) +hands out the objects the globals hold, in either access order. Until then nothing of it exists — no compile, no run, no allocation. `text-encoding.js` (`TextEncoder`/`TextDecoder`) and `base64.js` (`atob`/`btoa`) are the current ones; new globals join by adding a row to `kLazyGlobals`. diff --git a/NativeScript/runtime/js/node-util.js b/NativeScript/runtime/js/node-util.js index a89c2826..fd965d47 100644 --- a/NativeScript/runtime/js/node-util.js +++ b/NativeScript/runtime/js/node-util.js @@ -11,6 +11,18 @@ // surfaces can diverge without either one carrying the other's baggage. const { ObjectFreeze } = primordials; -const { inspect, format } = require("ns:util"); +const util = require("ns:util"); +const { inspect, format } = util; -module.exports = ObjectFreeze({ inspect, format }); +// Node exposes the two encoding interfaces on util; they stay lazy here for +// the same reason they are lazy on ns:util. +module.exports = ObjectFreeze({ + inspect, + format, + get TextEncoder() { + return util.TextEncoder; + }, + get TextDecoder() { + return util.TextDecoder; + }, +}); diff --git a/NativeScript/runtime/js/ns-util.js b/NativeScript/runtime/js/ns-util.js index edc47327..ef886bbb 100644 --- a/NativeScript/runtime/js/ns-util.js +++ b/NativeScript/runtime/js/ns-util.js @@ -148,4 +148,16 @@ function format(...args) { return str; } -module.exports = ObjectFreeze({ inspect, format }); +// `binding.TextEncoder`/`.TextDecoder` are lazy: reading either one runs the +// text-encoding builtin, so the reads stay inside these getters instead of +// joining the destructuring at the top of the file. +module.exports = ObjectFreeze({ + inspect, + format, + get TextEncoder() { + return binding.TextEncoder; + }, + get TextDecoder() { + return binding.TextDecoder; + }, +}); diff --git a/TestRunner/app/tests/NsUtilTests.js b/TestRunner/app/tests/NsUtilTests.js index 50cd3e69..16f2215c 100644 --- a/TestRunner/app/tests/NsUtilTests.js +++ b/TestRunner/app/tests/NsUtilTests.js @@ -11,7 +11,45 @@ describe("ns:util", function () { // The export set is public API, declared in types/ns-util.d.ts and // docs/ns-builtin-modules.md — all three must change together. it("exposes exactly the declared surface", function () { - expect(Object.keys(util).sort()).toEqual(["format", "inspect"]); + expect(Object.keys(util).sort()).toEqual(["TextDecoder", "TextEncoder", "format", "inspect"]); + }); + + it("exposes the encoding interfaces the globals expose", function () { + expect(typeof util.TextEncoder).toBe("function"); + expect(typeof util.TextDecoder).toBe("function"); + // One run of the text-encoding builtin backs both entry points, so the + // classes are identical objects no matter which is reached first. + expect(util.TextEncoder).toBe(globalThis.TextEncoder); + expect(util.TextDecoder).toBe(globalThis.TextDecoder); + }); + + it("round trips text through the module's encoding interfaces", function () { + var bytes = new util.TextEncoder().encode("héllo"); + expect(bytes instanceof Uint8Array).toBe(true); + expect(bytes.length).toBe(6); + expect(new util.TextDecoder().decode(bytes)).toBe("héllo"); + }); + + it("keeps the classes identical in a fresh isolate, whichever is touched first", function (done) { + var orders = ["global-first", "util-first"]; + var replies = 0; + orders.forEach(function (order) { + var worker = new Worker("./nsUtilEncodingOrderWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data).toEqual({ + order: order, + encoder: true, + decoder: true, + roundTrip: "ok", + }); + worker.terminate(); + replies++; + if (replies === orders.length) { + done(); + } + }; + worker.postMessage(order); + }); }); it("is a singleton per realm", function () { @@ -149,6 +187,15 @@ describe("node:util", function () { expect(Object.isFrozen(nodeUtil)).toBe(true); expect(nodeUtil.inspect).toBe(util.inspect); expect(nodeUtil.format).toBe(util.format); + expect(nodeUtil.TextEncoder).toBe(util.TextEncoder); + expect(nodeUtil.TextDecoder).toBe(util.TextDecoder); + }); + + it("exposes Node's encoding interfaces, identical to the globals", function () { + expect(Object.keys(nodeUtil).sort()).toEqual(["TextDecoder", "TextEncoder", "format", "inspect"]); + expect(nodeUtil.TextEncoder).toBe(globalThis.TextEncoder); + expect(nodeUtil.TextDecoder).toBe(globalThis.TextDecoder); + expect(new nodeUtil.TextDecoder("utf-8").decode(new nodeUtil.TextEncoder().encode("ok"))).toBe("ok"); }); it("is a singleton per realm", function () { diff --git a/TestRunner/app/tests/nsUtilEncodingOrderWorker.js b/TestRunner/app/tests/nsUtilEncodingOrderWorker.js new file mode 100644 index 00000000..3a7e6c70 --- /dev/null +++ b/TestRunner/app/tests/nsUtilEncodingOrderWorker.js @@ -0,0 +1,28 @@ +// A worker is a fresh isolate, which is what makes the access order testable: +// the parent realm has already materialized TextEncoder/TextDecoder by the +// time any spec runs. Nothing here may touch either name before the handler, +// or the requested order is lost. +onmessage = function (msg) { + var order = msg.data; + var results = { order: order }; + + if (order === "global-first") { + var globalEncoder = globalThis.TextEncoder; + var globalDecoder = globalThis.TextDecoder; + var nsUtil = require("ns:util"); + var nodeUtil = require("node:util"); + results.encoder = nsUtil.TextEncoder === globalEncoder && nodeUtil.TextEncoder === globalEncoder; + results.decoder = nsUtil.TextDecoder === globalDecoder && nodeUtil.TextDecoder === globalDecoder; + results.roundTrip = new nsUtil.TextDecoder().decode(new nodeUtil.TextEncoder().encode("ok")); + } else { + var util = require("ns:util"); + var node = require("node:util"); + var utilEncoder = util.TextEncoder; + var utilDecoder = node.TextDecoder; + results.encoder = globalThis.TextEncoder === utilEncoder && node.TextEncoder === utilEncoder; + results.decoder = globalThis.TextDecoder === utilDecoder && util.TextDecoder === utilDecoder; + results.roundTrip = new node.TextDecoder().decode(new util.TextEncoder().encode("ok")); + } + + postMessage(results); +}; diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index ba2ce433..bf298c8b 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -59,6 +59,7 @@ Rules: |---|---| | `inspect(value[, options])` | Formats any value for human consumption: depth-limited, output-capped, cycle-safe, never invokes getters (except a guarded `error.stack` read and custom `toString` overrides, which are honored). `options.depth` (number) overrides the default depth of 2. Other option keys are reserved. | | `format(fmt, ...args)` | Node-style printf formatting: `%s`, `%d`, `%i`, `%f`, `%j`, `%o`, `%O`, `%%`. Extra arguments are appended space-separated, objects rendered via `inspect`. When `fmt` is not a string or contains no substitutions, all arguments are formatted and joined with spaces. `console.*` routes its arguments through this, so `console.log("%d apples", 3)` works. | +| `TextEncoder` / `TextDecoder` | The WHATWG encoding interfaces, **the very same class objects the globals of those names hold** (`require("ns:util").TextDecoder === globalThis.TextDecoder`). Reading either member is what materializes them, so requiring the module costs nothing extra. | ```js const { inspect, format } = require("ns:util"); @@ -78,6 +79,13 @@ format("%j", { ok: true }); // '{"ok":true}' format("100% sure", "extra"); // "100% sure extra" (no placeholder consumed) ``` +```js +const { TextEncoder, TextDecoder } = require("ns:util"); + +TextDecoder === globalThis.TextDecoder; // true +new TextDecoder().decode(new TextEncoder().encode("héllo")); // "héllo" +``` + **Stability caveat (verbatim from Node's contract):** the output of `inspect` (and therefore `format`'s object rendering) may change between runtime versions for readability; it is intended for humans and must not be parsed @@ -426,7 +434,7 @@ unmodified where a shim exists: | module | exports | notes | |---|---|---| -| `node:util` | `inspect`, `format` | Re-exports `ns:util`'s members unchanged (`nodeUtil.inspect === nsUtil.inspect`) from a **distinct, separately frozen module object**. Documented as partial. | +| `node:util` | `inspect`, `format`, `TextEncoder`, `TextDecoder` | Re-exports `ns:util`'s members unchanged (`nodeUtil.inspect === nsUtil.inspect`) from a **distinct, separately frozen module object**. `TextEncoder`/`TextDecoder` are the globals of those names, as they are in Node. Documented as partial. | | `node:url` | `fileURLToPath`, `pathToFileURL` | Node-strict converters between `file:` URLs and paths. Documented as partial — no `URL`/`URLSearchParams` re-exports (both are globals), no legacy `url.parse`/`format`/`resolve`. | | `node:module` | `createRequire` | Re-exports `ns:module`'s `createRequire` unchanged from a **distinct, separately frozen module object**. `createPumpingRequire` is deliberately absent: it has no Node counterpart, so code written against this shim keeps running on Node. `require.resolve`/`.cache`/`.main` are not implemented, and neither is any other `node:module` member (`Module`, `builtinModules`, `isBuiltin`, `register`, `syncBuiltinESMExports`). Documented as partial. | diff --git a/types/ns-util.d.ts b/types/ns-util.d.ts index fd32d948..534f2c63 100644 --- a/types/ns-util.d.ts +++ b/types/ns-util.d.ts @@ -23,4 +23,39 @@ declare module "ns:util" { * `console.*` routes its arguments through this. */ export function format(format?: unknown, ...args: unknown[]): string; + + export interface TextEncoderInstance { + readonly encoding: "utf-8"; + encode(input?: string): Uint8Array; + encodeInto( + source: string, + destination: Uint8Array + ): { read: number; written: number }; + } + + export interface TextDecoderInstance { + readonly encoding: string; + readonly fatal: boolean; + readonly ignoreBOM: boolean; + decode( + input?: ArrayBuffer | ArrayBufferView | null, + options?: { stream?: boolean } + ): string; + } + + /** + * The WHATWG `TextEncoder`, which is the very object the global of that name + * holds: `require("ns:util").TextEncoder === globalThis.TextEncoder`. The + * members are declared here rather than taken from `globalThis` so the + * declaration stands on its own, without a DOM lib in the program. + */ + export const TextEncoder: { new (): TextEncoderInstance }; + + /** The WHATWG `TextDecoder`, likewise identical to the global. */ + export const TextDecoder: { + new ( + label?: string, + options?: { fatal?: boolean; ignoreBOM?: boolean } + ): TextDecoderInstance; + }; } From c29e30fd2c067089b3f98e1c5852669f7d5412f9 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 25 Aug 2026 17:00:16 -0300 Subject: [PATCH 4/5] fix(runtime): keep the encodeInto fast path off the JS heap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WriteUtf8V2 flattens a cons string, and flattening allocates on the JS heap — which a fast callback must never do, with no fallback mechanism in this V8 to escape to. The fast overload now takes the source as kSeqOneByteString, so V8 routes cons and two-byte strings to the slow callback by construction, and the flat latin-1 units it does receive are encoded by hand with no V8 string calls at all. Materializing an on-heap typed array's buffer is an allocation too, so the op now returns a status code: the fast path declines such views with kEncodeIntoRetrySlow and the builtin finishes the call through encodeIntoFallback, the same slow callback registered without a fast overload. The {read, written} array moves to the binding, built on a native ArrayBuffer, which is off-heap from birth and can never bounce the fast path. Also aligns the ns:util decode() declaration with the runtime: null is rejected, shared buffers are accepted, ArrayBufferLike keeps the file free of lib assumptions beyond es5. --- NativeScript/runtime/TextEncoding.cpp | 107 +++++++++++++++++++---- NativeScript/runtime/js/text-encoding.js | 26 ++++-- types/ns-util.d.ts | 2 +- 3 files changed, 110 insertions(+), 25 deletions(-) diff --git a/NativeScript/runtime/TextEncoding.cpp b/NativeScript/runtime/TextEncoding.cpp index bfeecd7a..772f8966 100644 --- a/NativeScript/runtime/TextEncoding.cpp +++ b/NativeScript/runtime/TextEncoding.cpp @@ -581,24 +581,33 @@ void EncodeUtf8Callback(const FunctionCallbackInfo& info) { info.GetReturnValue().Set(Uint8Array::New(buffer, 0, length)); } +// encodeInto status codes, mirrored in text-encoding.js. +constexpr int32_t kEncodeIntoOk = 0; +constexpr int32_t kEncodeIntoBadDestination = 1; +// Fast path only: the view's buffer is still on the V8 heap and Buffer() would +// allocate to materialize it, which a fast callback must not do. The builtin +// retries through encodeIntoFallback, which always runs the slow callback. +constexpr int32_t kEncodeIntoRetrySlow = 2; + // Writes as much of `source` as fits into `destination` without splitting an -// encoded code point, and reports {read, written} through `results` — a -// Uint32Array the builtin owns, so the op returns only the destination -// type check and stays expressible as a fast call. A destination that is a -// Uint8Array but detached or empty is a zero-length write, not a failure. -bool EncodeIntoImpl(Isolate* isolate, Local sourceValue, - Local destinationValue, Local resultsValue) { +// encoded code point, and reports {read, written} through `results` — the +// Uint32Array the binding owns, so the op returns only a status code and +// stays expressible as a fast call. A destination that is a Uint8Array but +// detached or empty is a zero-length write, not a failure. +int32_t EncodeIntoImpl(Isolate* isolate, Local sourceValue, + Local destinationValue, + Local resultsValue) { if (!destinationValue->IsUint8Array()) { - return false; + return kEncodeIntoBadDestination; } if (!sourceValue->IsString() || !resultsValue->IsUint32Array()) { - return true; + return kEncodeIntoOk; } Local results = resultsValue.As(); uint32_t* resultData = static_cast(results->Buffer()->Data()); if (resultData == nullptr || results->Length() < 2) { - return true; + return kEncodeIntoOk; } resultData += results->ByteOffset() / sizeof(uint32_t); resultData[0] = 0; @@ -608,7 +617,7 @@ bool EncodeIntoImpl(Isolate* isolate, Local sourceValue, void* base = destination->Buffer()->Data(); const size_t capacity = destination->ByteLength(); if (base == nullptr || capacity == 0) { - return true; + return kEncodeIntoOk; } size_t read = 0; @@ -617,7 +626,7 @@ bool EncodeIntoImpl(Isolate* isolate, Local sourceValue, v8::String::WriteFlags::kReplaceInvalidUtf8, &read); resultData[0] = static_cast(read); resultData[1] = static_cast(written); - return true; + return kEncodeIntoOk; } void EncodeIntoCallback(const FunctionCallbackInfo& info) { @@ -628,13 +637,63 @@ void EncodeIntoCallback(const FunctionCallbackInfo& info) { #if NATIVESCRIPT_ENABLE_FAST_API // Fast-call overload of encodeInto. Inert wherever V8's optimizing tiers are // absent (iOS runs jitless), so the slow callback above stays the only path -// there. It allocates nothing on the V8 heap and calls no JS. -bool FastEncodeInto(Local receiver, Local source, - Local destination, Local results, - // NOLINTNEXTLINE(runtime/references) - FastApiCallbackOptions& options) { +// there. A fast callback must not allocate on the JS heap, which shapes all +// three inputs: the kSeqOneByteString parameter keeps cons and two-byte +// sources on the slow callback (WriteUtf8V2 flattens, which allocates) and +// the latin-1 units are encoded by hand; a view whose buffer is still +// on-heap is declined with kEncodeIntoRetrySlow rather than materialized. +int32_t FastEncodeInto(Local receiver, const FastOneByteString& source, + Local destinationValue, Local resultsValue, + // NOLINTNEXTLINE(runtime/references) + FastApiCallbackOptions& options) { HandleScope scope(options.isolate); - return EncodeIntoImpl(options.isolate, source, destination, results); + if (!destinationValue->IsUint8Array()) { + return kEncodeIntoBadDestination; + } + if (!resultsValue->IsUint32Array()) { + return kEncodeIntoOk; + } + Local destination = destinationValue.As(); + Local results = resultsValue.As(); + if (!destination->HasBuffer() || !results->HasBuffer()) { + return kEncodeIntoRetrySlow; + } + + uint32_t* resultData = static_cast(results->Buffer()->Data()); + if (resultData == nullptr || results->Length() < 2) { + return kEncodeIntoOk; + } + resultData += results->ByteOffset() / sizeof(uint32_t); + resultData[0] = 0; + resultData[1] = 0; + + void* base = destination->Buffer()->Data(); + const size_t capacity = destination->ByteLength(); + if (base == nullptr || capacity == 0) { + return kEncodeIntoOk; + } + uint8_t* out = static_cast(base) + destination->ByteOffset(); + + size_t read = 0; + size_t written = 0; + for (; read < source.length; read++) { + const uint8_t unit = static_cast(source.data[read]); + if (unit < 0x80) { + if (written + 1 > capacity) { + break; + } + out[written++] = unit; + } else { + if (written + 2 > capacity) { + break; + } + out[written++] = 0xC0 | (unit >> 6); + out[written++] = 0x80 | (unit & 0x3F); + } + } + resultData[0] = static_cast(read); + resultData[1] = static_cast(written); + return kEncodeIntoOk; } const CFunction kFastEncodeInto = CFunction::Make(FastEncodeInto); @@ -655,6 +714,20 @@ MaybeLocal CreateBinding(Local context) { #else tns::SetMethod(context, binding, "encodeInto", EncodeIntoCallback); #endif + // Same slow callback with no fast overload: where the fast path answers + // kEncodeIntoRetrySlow, the builtin finishes the call through this name. + tns::SetMethod(context, binding, "encodeIntoFallback", EncodeIntoCallback); + + // Native ArrayBuffers carry a real backing store from birth, so the fast + // path's HasBuffer test always passes for the results array. + Local resultsBuffer = + ArrayBuffer::New(isolate, 2 * sizeof(uint32_t)); + bool success = + binding + ->Set(context, tns::ToV8String(isolate, "encodeIntoResults"), + Uint32Array::New(resultsBuffer, 0, 2)) + .FromMaybe(false); + tns::Assert(success, isolate); return binding; } diff --git a/NativeScript/runtime/js/text-encoding.js b/NativeScript/runtime/js/text-encoding.js index 37129060..0bf08011 100644 --- a/NativeScript/runtime/js/text-encoding.js +++ b/NativeScript/runtime/js/text-encoding.js @@ -19,10 +19,16 @@ const { SymbolToStringTag, TypeError, Uint8Array, - Uint32Array, } = primordials; -const { labelToEncoding, decode, encodeUtf8, encodeInto } = binding; +const { + labelToEncoding, + decode, + encodeUtf8, + encodeInto, + encodeIntoFallback, + encodeIntoResults, +} = binding; // Indexed by the encoding ids labelToEncoding returns. const kEncodingNames = ["utf-8", "utf-16le", "utf-16be", "windows-1252"]; @@ -35,10 +41,12 @@ const kFlagStream = 4; // Mirrors TextEncoding::kDecoderStateSize. const kDecoderStateSize = 16; -// encodeInto reports {read, written} through this rather than allocating a -// result object natively; the op is synchronous, so one buffer serves every -// encoder in the isolate. -const encodeIntoResults = new Uint32Array(2); +// Mirror the kEncodeInto* status codes in TextEncoding.cpp. The op reports +// {read, written} through binding.encodeIntoResults rather than allocating a +// result object per call; it is synchronous, so that one native Uint32Array +// serves every encoder in the isolate. +const kEncodeIntoBadDestination = 1; +const kEncodeIntoRetrySlow = 2; // WebIDL dictionary conversion: undefined and null mean "all defaults", // anything else must be an object. @@ -74,7 +82,11 @@ class TextEncoder { encodeInto(source, destination) { TextEncoder.#check(this); const text = `${source}`; - if (!encodeInto(text, destination, encodeIntoResults)) { + let code = encodeInto(text, destination, encodeIntoResults); + if (code === kEncodeIntoRetrySlow) { + code = encodeIntoFallback(text, destination, encodeIntoResults); + } + if (code === kEncodeIntoBadDestination) { throw new TypeError( 'The "destination" argument must be an instance of Uint8Array' ); diff --git a/types/ns-util.d.ts b/types/ns-util.d.ts index 534f2c63..bc9d362b 100644 --- a/types/ns-util.d.ts +++ b/types/ns-util.d.ts @@ -38,7 +38,7 @@ declare module "ns:util" { readonly fatal: boolean; readonly ignoreBOM: boolean; decode( - input?: ArrayBuffer | ArrayBufferView | null, + input?: ArrayBufferLike | ArrayBufferView, options?: { stream?: boolean } ): string; } From b5310e704bbc4f8fd753c5c02afb654074698e8f Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 25 Aug 2026 17:10:51 -0300 Subject: [PATCH 5/5] test: fail fast on worker errors and bump the flush-pinning shared spec A worker that dies in the fresh-isolate identity spec now reports the actual error instead of surfacing as a jasmine timeout. The shared bump pins the utf-16 end-of-queue step emitting a single U+FFFD when a lead surrogate and an odd trailing byte are pending together. --- TestRunner/app/shared | 2 +- TestRunner/app/tests/NsUtilTests.js | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 0f45dc87..364cba6f 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 0f45dc8776207618c2dce3202f8767d2275dfb5a +Subproject commit 364cba6f26f540a47e3c62a9029135218851f5a1 diff --git a/TestRunner/app/tests/NsUtilTests.js b/TestRunner/app/tests/NsUtilTests.js index 16f2215c..21a5870e 100644 --- a/TestRunner/app/tests/NsUtilTests.js +++ b/TestRunner/app/tests/NsUtilTests.js @@ -35,6 +35,11 @@ describe("ns:util", function () { var replies = 0; orders.forEach(function (order) { var worker = new Worker("./nsUtilEncodingOrderWorker.js"); + worker.onerror = function (err) { + worker.terminate(); + fail("worker (" + order + ") failed: " + (err && err.message)); + done(); + }; worker.onmessage = function (msg) { expect(msg.data).toEqual({ order: order,