From f3e15a0c2d7654ec8f2a6c3c841086837f8ad904 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 25 Aug 2026 18:05:14 -0300 Subject: [PATCH 1/4] feat(runtime): DOMException and CustomEvent as lazy globals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DOMException (Web IDL §4.3) arrives as a new lazy builtin: a class grafted onto Error.prototype with branded accessor attributes, the legacy code table, and the constants on interface object and prototype. CustomEvent is defined in events.js next to the Event it extends and placed by the lazy tier through the shared exports cache, so only the placement is deferred — Events::Init now runs the file via GetExports and reads the backing EventTarget from the exports bag. Builtins reach each other through a new internal require tier: registry rows marked internal-only resolve for the require builtins receive and nowhere else, the Node internal-module idiom the js README planned for. The four name-patched-Error stand-ins (abort-signal, performance, structured-clone, base64) now throw real DOMExceptions, required at first throw so the builtin never runs on a clean path, and the native serializer builds the same class for its DataCloneError with the old shape kept as a teardown fallback. With the tier in place the interim internals parameter loses its only two users: kListenerChanged and setListenerErrorReporter move into events.js's exports behind internal/events, and the builtin wrapper drops back to Node's five parameters (exports, require, module, binding, primordials). A consumer that runs before its producer now fails loudly at the require instead of silently reading a missing key. Not implemented: the spec's [Serializable] slot — a DOMException inside a cloned graph still degrades like any custom Error subclass, since v8::ValueSerializer has no hook for a plain JS class. Shared suites (self-gating, skip where the APIs are absent) land in the tests submodule; unguarded canaries on this runtime keep a regression from turning them into silent skips. --- NativeScript/runtime/BuiltinLoader.cpp | 69 +++------ NativeScript/runtime/BuiltinLoader.h | 17 +-- NativeScript/runtime/Events.cpp | 32 ++-- NativeScript/runtime/LazyGlobals.cpp | 12 ++ NativeScript/runtime/LazyGlobals.h | 5 +- NativeScript/runtime/NsBuiltinModules.cpp | 28 +++- NativeScript/runtime/NsBuiltinModules.h | 6 +- .../runtime/StructuredSerialization.cpp | 31 ++++ .../runtime/StructuredSerialization.h | 6 +- NativeScript/runtime/js/README.md | 46 +++--- NativeScript/runtime/js/abort-signal.js | 33 ++-- NativeScript/runtime/js/base64.js | 22 +-- NativeScript/runtime/js/dom-exception.js | 144 ++++++++++++++++++ NativeScript/runtime/js/error-events.js | 2 +- NativeScript/runtime/js/events.js | 44 +++++- NativeScript/runtime/js/performance.js | 24 +-- NativeScript/runtime/js/primordials.js | 2 + NativeScript/runtime/js/structured-clone.js | 22 +-- NativeScript/runtime/js/text-encoding.js | 4 +- TestRunner/app/shared | 2 +- .../app/tests/RuntimeImplementedAPIs.js | 20 +++ docs/abort-signal.md | 6 +- eslint.config.mjs | 1 - tools/js2c-inputs.xcfilelist | 1 + 24 files changed, 412 insertions(+), 167 deletions(-) create mode 100644 NativeScript/runtime/js/dom-exception.js diff --git a/NativeScript/runtime/BuiltinLoader.cpp b/NativeScript/runtime/BuiltinLoader.cpp index 4da2aec4..dfcaca61 100644 --- a/NativeScript/runtime/BuiltinLoader.cpp +++ b/NativeScript/runtime/BuiltinLoader.cpp @@ -28,8 +28,7 @@ constexpr const char* kRequireParamName = "require"; constexpr const char* kModuleParamName = "module"; constexpr const char* kBindingParamName = "binding"; constexpr const char* kPrimordialsParamName = "primordials"; -constexpr const char* kInternalsParamName = "internals"; -constexpr int kParamCount = 6; +constexpr int kParamCount = 5; // `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 @@ -38,30 +37,9 @@ 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 -// during theirs, so Runtime::Init's ordering is the dependency graph. -struct BuiltinInternalsState { - Persistent internals; -}; - -MaybeLocal GetInternals(Local context) { - Isolate* isolate = v8::Isolate::GetCurrent(); - auto* state = Caches::StateFor(isolate); - if (state == nullptr) { - return MaybeLocal(); - } - if (!state->internals.IsEmpty()) { - return state->internals.Get(isolate); - } - Local internals = Object::New(isolate); - state->internals.Reset(isolate, internals); - return internals; -} - -// The `require` every builtin receives: builtin specifiers only, so a builtin -// can never reach application code or the filesystem. +// The `require` every builtin receives: builtin specifiers only — including +// the internal tier app code can never name — so a builtin can never reach +// application code or the filesystem. void BuiltinRequireCallback(const FunctionCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); if (info.Length() < 1 || !info[0]->IsString()) { @@ -75,7 +53,8 @@ void BuiltinRequireCallback(const FunctionCallbackInfo& info) { Local exports; if (NsBuiltinModules::GetExports(context, specifier).ToLocal(&exports)) { info.GetReturnValue().Set(exports); - } else if (!NsBuiltinModules::IsRegistered(specifier)) { + } else if (!NsBuiltinModules::IsRegistered(specifier, + /* includeInternal */ true)) { isolate->ThrowException(Exception::Error(tns::ToV8String( isolate, NsBuiltinModules::NotFoundMessage(specifier)))); } @@ -124,12 +103,12 @@ MaybeLocal CompileBuiltin(Local context, BuiltinId id) { ); Local sourceText = tns::ToV8String( isolate, builtin.source, static_cast(builtin.length)); - Local params[] = {tns::ToV8String(isolate, kExportsParamName), - tns::ToV8String(isolate, kRequireParamName), - tns::ToV8String(isolate, kModuleParamName), - tns::ToV8String(isolate, kBindingParamName), - tns::ToV8String(isolate, kPrimordialsParamName), - tns::ToV8String(isolate, kInternalsParamName)}; + Local params[] = { + tns::ToV8String(isolate, kExportsParamName), + tns::ToV8String(isolate, kRequireParamName), + tns::ToV8String(isolate, kModuleParamName), + tns::ToV8String(isolate, kBindingParamName), + tns::ToV8String(isolate, kPrimordialsParamName)}; Local fn; if (!blob.empty()) { @@ -170,8 +149,7 @@ MaybeLocal CompileBuiltin(Local context, BuiltinId id) { } MaybeLocal CallBuiltin(Local context, BuiltinId id, - Local binding, Local primordials, - Local internals) { + Local binding, Local primordials) { Isolate* isolate = v8::Isolate::GetCurrent(); Local fn; @@ -192,12 +170,9 @@ MaybeLocal CallBuiltin(Local context, BuiltinId id, } Local args[] = { - exportsObj, - require, - moduleObj, + exportsObj, require, moduleObj, binding.IsEmpty() ? v8::Undefined(isolate).As() : binding, - primordials, - internals}; + primordials}; if (fn->Call(context, v8::Undefined(isolate), kParamCount, args).IsEmpty()) { return MaybeLocal(); } @@ -209,8 +184,7 @@ MaybeLocal CallBuiltin(Local context, BuiltinId id, // isolate — during runtime init, before user code can replace a global. // Builtins compiled later in the isolate's life get the same pristine // snapshot. -MaybeLocal GetPrimordials(Local context, - Local internals) { +MaybeLocal GetPrimordials(Local context) { Isolate* isolate = v8::Isolate::GetCurrent(); std::shared_ptr cache = Caches::Get(isolate); if (cache->Primordials != nullptr) { @@ -219,7 +193,7 @@ MaybeLocal GetPrimordials(Local context, Local result; if (!CallBuiltin(context, BuiltinId::kPrimordials, Local(), - v8::Undefined(isolate), internals) + v8::Undefined(isolate)) .ToLocal(&result) || !result->IsObject()) { return MaybeLocal(); @@ -236,17 +210,12 @@ MaybeLocal GetPrimordials(Local context, MaybeLocal BuiltinLoader::RunBuiltin(Local context, BuiltinId id, Local binding) { - Local internals; - if (!GetInternals(context).ToLocal(&internals)) { - return MaybeLocal(); - } - Local primordials; - if (!GetPrimordials(context, internals).ToLocal(&primordials)) { + if (!GetPrimordials(context).ToLocal(&primordials)) { return MaybeLocal(); } - return CallBuiltin(context, id, binding, primordials, internals); + return CallBuiltin(context, id, binding, primordials); } MaybeLocal BuiltinLoader::GetExports(Local context, diff --git a/NativeScript/runtime/BuiltinLoader.h b/NativeScript/runtime/BuiltinLoader.h index 3373789e..32a192aa 100644 --- a/NativeScript/runtime/BuiltinLoader.h +++ b/NativeScript/runtime/BuiltinLoader.h @@ -14,16 +14,13 @@ class BuiltinLoader { 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 - // with the given bag of natives (or undefined when omitted), this isolate's - // frozen intrinsics snapshot, and the isolate's shared internals object, and - // returns the resulting `module.exports`. `require` reaches the builtin - // modules (NsBuiltinModules) and nothing else. `internals` is one plain - // object per isolate handed identically to every builtin and never exposed - // anywhere app code can reach: the channel for cross-builtin capabilities - // (see the js README; interim until a Node-style private internal-module - // tier exists). The snapshot is + // parameters `exports`, `require`, `module`, `binding` (Node's module + // wrapper plus its internalBinding idiom) and `primordials`, calls it with + // the given bag of natives (or undefined when omitted) and this isolate's + // frozen intrinsics snapshot, and returns the resulting `module.exports`. + // `require` reaches the builtin modules (NsBuiltinModules) — including the + // internal-only tier, which is also how builtins hand each other + // capabilities app code must not see — and nothing else. The snapshot is // produced by the kPrimordials builtin on first use and cached per isolate, // so it is taken before any user code can replace a global. Scripts carry // an "internal/.js" origin so runtime diff --git a/NativeScript/runtime/Events.cpp b/NativeScript/runtime/Events.cpp index 4765b94b..f906f43b 100644 --- a/NativeScript/runtime/Events.cpp +++ b/NativeScript/runtime/Events.cpp @@ -10,26 +10,34 @@ namespace tns { void Events::Init(Local context) { // Generic WHATWG event primitives (internal/events.js). The builtin installs - // Event/EventTarget and the global EventTarget methods, then exports the - // internal EventTarget instance backing the global so native dispatch - // survives app code overwriting globalThis.dispatchEvent. The error-events - // layer (ErrorEvents::Init) runs immediately after and installs the native + // Event/EventTarget and the global EventTarget methods; its exports carry + // the internal EventTarget instance backing the global (cached here so + // native dispatch survives app code overwriting globalThis.dispatchEvent) + // and the CustomEvent interface the lazy-global tier places. Run through + // GetExports so that tier's read shares this run. The error-events layer + // (ErrorEvents::Init) runs immediately after and installs the native // listener-error reporter through _installListenerErrorReporter. Isolate* isolate = v8::Isolate::GetCurrent(); - Local result; - bool success = - BuiltinLoader::RunBuiltin(context, BuiltinId::kEvents).ToLocal(&result); - tns::Assert(success && result->IsObject(), isolate); + Local exports; + bool success = BuiltinLoader::GetExports(context, BuiltinId::kEvents, nullptr) + .ToLocal(&exports); + tns::Assert(success, isolate); + + Local globalEventTarget; + success = exports->Get(context, tns::ToV8String(isolate, "globalEventTarget")) + .ToLocal(&globalEventTarget) && + globalEventTarget->IsObject(); + tns::Assert(success, isolate); auto cache = Caches::Get(isolate); - cache->GlobalEventTarget = - std::make_unique>(isolate, result.As()); + cache->GlobalEventTarget = std::make_unique>( + isolate, globalEventTarget.As()); // AbortController/AbortSignal (internal/abort-signal.js) build directly on // the event primitives installed above; the listener-mutation hook key for - // its GC-liveness accounting arrives through the shared `internals` - // parameter, published by the events builtin. + // its GC-liveness accounting comes from the events builtin's exports, via + // require("internal/events"). Local abortResult; success = BuiltinLoader::RunBuiltin(context, BuiltinId::kAbortSignal) .ToLocal(&abortResult); diff --git a/NativeScript/runtime/LazyGlobals.cpp b/NativeScript/runtime/LazyGlobals.cpp index dcc60ec9..98cd3a1b 100644 --- a/NativeScript/runtime/LazyGlobals.cpp +++ b/NativeScript/runtime/LazyGlobals.cpp @@ -1,6 +1,7 @@ #include "LazyGlobals.h" #include "Base64.h" +#include "BuiltinLoader.h" #include "Helpers.h" #include "TextEncoding.h" @@ -21,11 +22,22 @@ struct LazyGlobalEntry { ExportsAccessor exports; }; +// Exports accessor for a builtin with no natives of its own; modules with a +// binding (TextEncoding, Base64) own a hand-written accessor instead. +template +MaybeLocal BuiltinExports(Local context) { + return BuiltinLoader::GetExports(context, id, nullptr); +} + constexpr LazyGlobalEntry kLazyGlobals[] = { {"TextEncoder", "TextEncoder", TextEncoding::GetExports}, {"TextDecoder", "TextDecoder", TextEncoding::GetExports}, {"atob", "atob", Base64::GetExports}, {"btoa", "btoa", Base64::GetExports}, + {"DOMException", "DOMException", BuiltinExports}, + // events.js is an eager builtin (Events::Init), so this row never runs a + // file: the read hits the exports cache and only the placement is lazy. + {"CustomEvent", "CustomEvent", BuiltinExports}, }; void LazyGlobalGetter(Local property, diff --git a/NativeScript/runtime/LazyGlobals.h b/NativeScript/runtime/LazyGlobals.h index 3123e926..5d0b488a 100644 --- a/NativeScript/runtime/LazyGlobals.h +++ b/NativeScript/runtime/LazyGlobals.h @@ -16,8 +16,9 @@ namespace tns { // 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). +// rather than during init, so anything it needs from a sibling builtin must +// come through `require` or its `binding`, never from init order (see +// NativeScript/runtime/js/README.md). class LazyGlobals { public: // Registers every lazy global. Must run before Context::New, on the same diff --git a/NativeScript/runtime/NsBuiltinModules.cpp b/NativeScript/runtime/NsBuiltinModules.cpp index 63f97132..7cd7458f 100644 --- a/NativeScript/runtime/NsBuiltinModules.cpp +++ b/NativeScript/runtime/NsBuiltinModules.cpp @@ -30,12 +30,16 @@ struct Registration { // 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 internal tier (runtime/js/README.md): resolvable only through the + // require builtins receive, never through the module system, so the file's + // exports can carry capabilities app code must not reach. + bool internalOnly = false; }; -// The public registry (docs/ns-builtin-modules.md). One specifier, one source -// file: a `node:` shim is its own builtin that requires the `ns:` module it -// adapts, so the two module objects stay distinct and the standard module -// never carries compatibility code. +// The public registry (docs/ns-builtin-modules.md) plus the internal tier. +// One specifier, one source file: a `node:` shim is its own builtin that +// requires the `ns:` module it adapts, so the two module objects stay +// distinct and the standard module never carries compatibility code. constexpr Registration kRegistry[] = { {"ns:module", BuiltinId::kNsModule, NsModuleBinding}, {"ns:runtime", BuiltinId::kNsRuntime, NsRuntimeBinding}, @@ -43,6 +47,8 @@ constexpr Registration kRegistry[] = { {"node:module", BuiltinId::kNodeModule, nullptr}, {"node:url", BuiltinId::kNodeUrl, nullptr}, {"node:util", BuiltinId::kNodeUtil, nullptr}, + {"internal/dom-exception", BuiltinId::kDomException, nullptr, true}, + {"internal/events", BuiltinId::kEvents, nullptr, true}, }; // ns:runtime config keys. Each key defines its value domain and scope here; @@ -299,8 +305,11 @@ bool NsBuiltinModules::IsNsScheme(const std::string& specifier) { return HasPrefix(specifier, kNsPrefix); } -bool NsBuiltinModules::IsRegistered(const std::string& specifier) { - return Find(specifier) != nullptr; +bool NsBuiltinModules::IsRegistered(const std::string& specifier, + bool includeInternal) { + const Registration* registration = Find(specifier); + return registration != nullptr && + (includeInternal || !registration->internalOnly); } std::string NsBuiltinModules::NotFoundMessage(const std::string& specifier) { @@ -348,6 +357,13 @@ MaybeLocal NsBuiltinModules::GetExports(Local context, MaybeLocal NsBuiltinModules::GetModule(Local context, const std::string& specifier) { + // The module system is the app-facing entry point; the internal tier only + // exists behind the require builtins receive. + const Registration* registration = Find(specifier); + if (registration == nullptr || registration->internalOnly) { + return MaybeLocal(); + } + Isolate* isolate = v8::Isolate::GetCurrent(); std::shared_ptr cache = Caches::Get(isolate); diff --git a/NativeScript/runtime/NsBuiltinModules.h b/NativeScript/runtime/NsBuiltinModules.h index da6edb67..b58750e4 100644 --- a/NativeScript/runtime/NsBuiltinModules.h +++ b/NativeScript/runtime/NsBuiltinModules.h @@ -23,7 +23,11 @@ class NsBuiltinModules { static bool IsNsScheme(const std::string& specifier); // Whether a module of that name exists. - static bool IsRegistered(const std::string& specifier); + // Whether the specifier names a registered builtin module. Internal-tier + // rows (see kRegistry) only count when includeInternal is set, which is the + // builtin require's privilege; every app-facing caller keeps the default. + static bool IsRegistered(const std::string& specifier, + bool includeInternal = false); // Frozen exports object of a registered specifier. Empty when the specifier // is not registered (nothing thrown) or when the module failed to build (an diff --git a/NativeScript/runtime/StructuredSerialization.cpp b/NativeScript/runtime/StructuredSerialization.cpp index a6c6602d..ae62cd93 100644 --- a/NativeScript/runtime/StructuredSerialization.cpp +++ b/NativeScript/runtime/StructuredSerialization.cpp @@ -1,5 +1,6 @@ #include "StructuredSerialization.h" +#include "BuiltinLoader.h" #include "Helpers.h" #include "NativeScriptException.h" @@ -9,6 +10,36 @@ namespace tns { namespace serialization { void ThrowDataCloneError(Isolate* isolate, const std::string& message) { + // The spec's DataCloneError is a DOMException; build it through the + // builtin's exports cache so native and JS throw sites produce the same + // class. Delegates may call into JS here — V8 allows it, and Node's + // serializer delegates do the same. The fallback covers a builtin that can + // no longer run (isolate teardown, broken realm). + Local domException; + { + TryCatch tc(isolate); + Local context = isolate->GetCurrentContext(); + Local exports; + Local ctor; + if (BuiltinLoader::GetExports(context, BuiltinId::kDomException, nullptr) + .ToLocal(&exports) && + exports->Get(context, tns::ToV8String(isolate, "DOMException")) + .ToLocal(&ctor) && + ctor->IsFunction()) { + Local args[] = {tns::ToV8String(isolate, message), + tns::ToV8String(isolate, "DataCloneError")}; + Local instance; + if (ctor.As() + ->NewInstance(context, 2, args) + .ToLocal(&instance)) { + domException = instance; + } + } + } + if (!domException.IsEmpty()) { + isolate->ThrowException(domException); + return; + } NativeScriptException exception(isolate, message, "DataCloneError"); exception.ReThrowToV8(isolate); } diff --git a/NativeScript/runtime/StructuredSerialization.h b/NativeScript/runtime/StructuredSerialization.h index 860655e1..156db4b7 100644 --- a/NativeScript/runtime/StructuredSerialization.h +++ b/NativeScript/runtime/StructuredSerialization.h @@ -26,9 +26,9 @@ enum class HostObjectPolicy { kDegrade, }; -// Throws the runtime's DataCloneError. There is no DOMException here, so it is -// an Error carrying that name — routed through NativeScriptException so the -// object is shaped like every other error the runtime raises. +// Throws a "DataCloneError" DOMException, the same class the JS half of +// structuredClone raises. Falls back to a NativeScriptException-shaped Error +// carrying that name when the dom-exception builtin cannot run. void ThrowDataCloneError(v8::Isolate* isolate, const std::string& message); // A value serialized out of one isolate, plus the memory that travels with it. diff --git a/NativeScript/runtime/js/README.md b/NativeScript/runtime/js/README.md index 144e358e..3aed15c9 100644 --- a/NativeScript/runtime/js/README.md +++ b/NativeScript/runtime/js/README.md @@ -9,8 +9,8 @@ at runtime `BuiltinLoader::RunBuiltin` compiles and executes them with an ## Contract (Node's module wrapper + internalBinding idiom) Every file is compiled as a **function body** via `v8::ScriptCompiler::CompileFunction` -with the fixed parameters `exports`, `require`, `module`, `binding`, -`primordials` and `internals`: +with the fixed parameters `exports`, `require`, `module`, `binding` and +`primordials`: ```js const { someNative, anotherNative } = binding; @@ -27,20 +27,20 @@ module.exports = somethingTheCallSiteNeeds; `No such built-in module: `. It is how a `node:` shim consumes the `ns:` module it adapts, and it materializes that module on first use. Requiring a module that is still loading throws rather than recursing. + It also resolves the **internal tier** (`internal/events`, + `internal/dom-exception`): registry rows marked internal-only in + `NsBuiltinModules.cpp` that the module system refuses, so only builtins can + name them — Node's internal-module idiom. This is the one channel for + cross-builtin capabilities that must never leak to app code (the + `kListenerChanged` hook key abort-signal.js takes from events.js, the + `setListenerErrorReporter` setter error-events.js calls): the producer puts + the capability in its `module.exports`, the consumer requires it. + `require("internal/…")` at first use runs the file through the shared + exports cache — for a consumer of an eager producer that is a cache hit, + and requiring before the producer ran is an init-order bug that fails + loudly at the require. - `primordials` is the frozen intrinsics snapshot built by `primordials.js` (see below), the same object for every builtin in an isolate. -- `internals` is one plain per-isolate object handed identically to every - builtin and reachable from nowhere else — the private channel for - cross-builtin capabilities that must never leak to app code (the - `kListenerChanged` hook key events.js publishes for abort-signal.js, the - `setListenerErrorReporter` setter error-events.js calls). Producers - publish during their init, consumers read during theirs, so the - `Runtime::Init` ordering is the dependency graph; a missing key fails - loudly at init, not at first use. **Interim mechanism**: if cross-builtin - needs outgrow one shared object (many producers, lazy consumers), migrate - to a Node-style private internal-module tier — `require("internal/…")` - resolved for builtins only, never through the public `ns:`/`node:` - registry — and fold `internals` into it. - **`module.exports` is the export channel** — whatever it holds when the file finishes is what `RunBuiltin` hands back to C++ (used for factory functions and init results). Both CommonJS styles work: replace the whole export with @@ -73,16 +73,20 @@ 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`. +(`TextEncoder`/`TextDecoder`), `base64.js` (`atob`/`btoa`) and +`dom-exception.js` (`DOMException`) are the current ones; new globals join by +adding a row to `kLazyGlobals`. + +An **eager** file can also feed the tier: `events.js` (eager, `Events::Init`) +exports `CustomEvent`, and the `CustomEvent` row reads it through the same +exports cache — the run happened at init, so only the placement is lazy. 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 runs at an arbitrary point in the isolate's life, not at init.** + Anything it needs from another builtin has to come through `require` + (including the internal tier) or its `binding` — never from init-order + assumptions. - **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. diff --git a/NativeScript/runtime/js/abort-signal.js b/NativeScript/runtime/js/abort-signal.js index 31d61234..21ba2e50 100644 --- a/NativeScript/runtime/js/abort-signal.js +++ b/NativeScript/runtime/js/abort-signal.js @@ -16,15 +16,13 @@ // fires from every listener-list mutation path and cannot be bypassed // from app code. // -// Deliberate deviation from Node: no DOMException in this runtime — the -// default abort and timeout reasons are Error instances with `name` patched -// ("AbortError" / "TimeoutError"), the same stand-in performance.js and -// structured-clone.js use. +// The default abort and timeout reasons are DOMExceptions ("AbortError" / +// "TimeoutError"), required from the internal tier on first use so an app +// that never aborts never runs the dom-exception builtin. const { ArrayPrototypeIndexOf, ArrayPrototypePush, ArrayPrototypeSplice, - Error, FinalizationRegistry, FinalizationRegistryPrototypeRegister, FinalizationRegistryPrototypeUnregister, @@ -54,23 +52,32 @@ const dispatchEvent = EventTarget.prototype.dispatchEvent; const addEventListener = EventTarget.prototype.addEventListener; const removeEventListener = EventTarget.prototype.removeEventListener; // Published by events.js: the symbol under which EventTargetImpl looks up -// the listener-mutation hook. -const kListenerChanged = internals.kListenerChanged; +// the listener-mutation hook. events.js already ran, so this require is a +// cache hit; running before it would be an init-order bug this line turns +// into a loud failure. +const { kListenerChanged } = require("internal/events"); // Construction token: AbortSignal instances come only from the factories in // this module (the controller, and the abort/timeout/any statics). const kInternal = {}; +let DOMException; +function getDOMException() { + if (DOMException === undefined) { + ({ DOMException } = require("internal/dom-exception")); + } + return DOMException; +} + function abortError() { - const e = new Error("This operation was aborted"); - e.name = "AbortError"; - return e; + return new (getDOMException())("This operation was aborted", "AbortError"); } function timeoutError() { - const e = new Error("The operation was aborted due to timeout"); - e.name = "TimeoutError"; - return e; + return new (getDOMException())( + "The operation was aborted due to timeout", + "TimeoutError" + ); } // The strong holds described in the header. Entries leave on abort, on the diff --git a/NativeScript/runtime/js/base64.js b/NativeScript/runtime/js/base64.js index 3fa07542..70bb423c 100644 --- a/NativeScript/runtime/js/base64.js +++ b/NativeScript/runtime/js/base64.js @@ -4,21 +4,23 @@ // // 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. +// the first read of either name. Anything needed from a sibling builtin +// comes through `require` or `binding`, never init order — 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; +// The native ops answer null on failure rather than throwing, so the +// exception shape — an "InvalidCharacterError" DOMException, per the HTML +// spec — stays here, required on first failure so well-formed input never +// runs the dom-exception builtin. +const { TypeError } = primordials; const { atob: decodeBase64, btoa: encodeBase64 } = binding; +let DOMException; function invalidCharacterError() { - const e = new Error("Invalid character"); - e.name = "InvalidCharacterError"; - return e; + if (DOMException === undefined) { + ({ DOMException } = require("internal/dom-exception")); + } + return new DOMException("Invalid character", "InvalidCharacterError"); } function btoa(data) { diff --git a/NativeScript/runtime/js/dom-exception.js b/NativeScript/runtime/js/dom-exception.js new file mode 100644 index 00000000..e8821d6f --- /dev/null +++ b/NativeScript/runtime/js/dom-exception.js @@ -0,0 +1,144 @@ +"use strict"; +// DOMException (Web IDL Standard §4.3), the error type the web platform uses +// for named failures. Modeled on Node's per-context implementation: a plain +// class whose prototype is grafted onto Error.prototype, with the readonly +// name/message/code attributes as branded prototype getters (the private +// fields double as the Web IDL brand check). +// +// Lazy builtin: LazyGlobals places the global, and sibling builtins reach the +// constructor through require("internal/dom-exception") at throw time, so +// this file never runs in an app that never touches a DOMException. +// +// Not implemented: the spec's [Serializable] slot. structuredClone and worker +// postMessage go through v8::ValueSerializer, which has no hook for a plain +// JS class, so a DOMException inside a cloned graph degrades the same way any +// custom Error subclass does. +const { + ErrorCaptureStackTrace, + ErrorPrototype, + ObjectDefineProperty, + ObjectSetPrototypeOf, + SymbolToStringTag, +} = primordials; + +// Web IDL §4.3.4: the closed table of names with a legacy code. Any name +// outside it — including every post-table spec name — has code 0. +const nameToCode = { + __proto__: null, + IndexSizeError: 1, + DOMStringSizeError: 2, + HierarchyRequestError: 3, + WrongDocumentError: 4, + InvalidCharacterError: 5, + NoDataAllowedError: 6, + NoModificationAllowedError: 7, + NotFoundError: 8, + NotSupportedError: 9, + InUseAttributeError: 10, + InvalidStateError: 11, + SyntaxError: 12, + InvalidModificationError: 13, + NamespaceError: 14, + InvalidAccessError: 15, + ValidationError: 16, + TypeMismatchError: 17, + SecurityError: 18, + NetworkError: 19, + AbortError: 20, + URLMismatchError: 21, + QuotaExceededError: 22, + TimeoutError: 23, + InvalidNodeTypeError: 24, + DataCloneError: 25, +}; + +class DOMException { + #name; + #message; + + // The `= ""` / `= "Error"` defaults also give the constructor the arity the + // IDL requires (0: both arguments optional). + constructor(message = "", name = "Error") { + this.#message = `${message}`; + this.#name = `${name}`; + ErrorCaptureStackTrace(this, DOMException); + } + + get name() { + return this.#name; + } + + get message() { + return this.#message; + } + + get code() { + const code = nameToCode[this.#name]; + return code === undefined ? 0 : code; + } +} + +// Web IDL inheritance: DOMException.prototype's parent is Error.prototype, so +// instanceof Error holds and Error.prototype.toString renders +// "name: message" through the getters above. +ObjectSetPrototypeOf(DOMException.prototype, ErrorPrototype); + +// Class getters are non-enumerable; the IDL attributes are enumerable. +for (const key of ["name", "message", "code"]) { + ObjectDefineProperty(DOMException.prototype, key, { + __proto__: null, + enumerable: true, + }); +} + +ObjectDefineProperty(DOMException.prototype, SymbolToStringTag, { + __proto__: null, + configurable: true, + value: "DOMException", +}); + +// The legacy code constants, on the interface object and on the prototype +// (Web IDL §3.7.5: both, { writable: false, enumerable: true, +// configurable: false }). +const constants = { + __proto__: null, + INDEX_SIZE_ERR: 1, + DOMSTRING_SIZE_ERR: 2, + HIERARCHY_REQUEST_ERR: 3, + WRONG_DOCUMENT_ERR: 4, + INVALID_CHARACTER_ERR: 5, + NO_DATA_ALLOWED_ERR: 6, + NO_MODIFICATION_ALLOWED_ERR: 7, + NOT_FOUND_ERR: 8, + NOT_SUPPORTED_ERR: 9, + INUSE_ATTRIBUTE_ERR: 10, + INVALID_STATE_ERR: 11, + SYNTAX_ERR: 12, + INVALID_MODIFICATION_ERR: 13, + NAMESPACE_ERR: 14, + INVALID_ACCESS_ERR: 15, + VALIDATION_ERR: 16, + TYPE_MISMATCH_ERR: 17, + SECURITY_ERR: 18, + NETWORK_ERR: 19, + ABORT_ERR: 20, + URL_MISMATCH_ERR: 21, + QUOTA_EXCEEDED_ERR: 22, + TIMEOUT_ERR: 23, + INVALID_NODE_TYPE_ERR: 24, + DATA_CLONE_ERR: 25, +}; + +for (const key in constants) { + const descriptor = { + __proto__: null, + value: constants[key], + writable: false, + enumerable: true, + configurable: false, + }; + ObjectDefineProperty(DOMException, key, descriptor); + ObjectDefineProperty(DOMException.prototype, key, descriptor); +} + +module.exports = { DOMException }; diff --git a/NativeScript/runtime/js/error-events.js b/NativeScript/runtime/js/error-events.js index 10db7ca6..b48d1d57 100644 --- a/NativeScript/runtime/js/error-events.js +++ b/NativeScript/runtime/js/error-events.js @@ -29,7 +29,7 @@ PromiseRejectionEvent.prototype.constructor = PromiseRejectionEvent; // A listener that throws must not stop other listeners: route the thrown // value to the native fatal tail instead of ever recursively dispatching // another `error` event from inside dispatch. -internals.setListenerErrorReporter(function (e) { +require("internal/events").setListenerErrorReporter(function (e) { try { nativeReportFatal(e, (e && e.stack) || ""); } catch (ignored) {} }); diff --git a/NativeScript/runtime/js/events.js b/NativeScript/runtime/js/events.js index 4430b265..1f4a39af 100644 --- a/NativeScript/runtime/js/events.js +++ b/NativeScript/runtime/js/events.js @@ -6,6 +6,7 @@ const { ArrayPrototypeSplice, FunctionPrototypeCall, ObjectCreate, + ObjectDefineProperty, String, } = primordials; var g = globalThis; @@ -34,21 +35,20 @@ Event.prototype.stopImmediatePropagation = function () { // A listener that throws must not stop other listeners: route the thrown // value to the native fatal tail instead of ever recursively dispatching // another `error` event from inside dispatch. The error-events layer -// installs the real reporter via internals.setListenerErrorReporter (before -// any user code runs); until then a thrown listener is swallowed. +// installs the real reporter through this file's exports (before any user +// code runs); until then a thrown listener is swallowed. var reportListenerError = function (e) {}; -internals.setListenerErrorReporter = function (fn) { +function setListenerErrorReporter(fn) { reportListenerError = fn; -}; +} // Internal listener-mutation hook. A target (in practice: AbortSignal, on // its prototype) may carry a function under this symbol; it is called with // (target, type, newCount) from every path that changes a listener list — // add, remove, and the once-splice inside dispatch. The key travels only -// through `internals`, so the accounting cannot be bypassed the way an -// overridable addEventListener could. +// through require("internal/events"), so the accounting cannot be bypassed +// the way an overridable addEventListener could. var kListenerChanged = Symbol("listenerChanged"); -internals.kListenerChanged = kListenerChanged; function notifyListenerChanged(target, type, count) { var hook = target[kListenerChanged]; if (hook !== undefined) { hook(target, type, count); } @@ -146,4 +146,32 @@ EventTarget.prototype.dispatchEvent = EventTargetImpl.prototype.dispatchEvent; g.Event = Event; g.EventTarget = EventTarget; -module.exports = globalTarget; +// CustomEvent (DOM Standard §2.4): Event carrying an app-supplied `detail`. +// Defined here so it extends the same Event the globals hold, but NOT +// installed eagerly — the lazy-global tier (LazyGlobals) places it from this +// file's exports on the first read of the name, sharing the init-time run +// through the exports cache. +function CustomEvent(type, opts) { + FunctionPrototypeCall(Event, this, type, opts); + opts = opts || {}; + this.detail = opts.detail !== undefined ? opts.detail : null; +} +CustomEvent.prototype = ObjectCreate(Event.prototype); +ObjectDefineProperty(CustomEvent.prototype, "constructor", { + value: CustomEvent, + writable: true, + configurable: true, +}); + +// Consumed by Events::Init, the lazy-global tier (CustomEvent), and sibling +// builtins via require("internal/events"). The module system refuses that +// specifier, so the capabilities here never reach app code. +module.exports = { + // The EventTarget instance backing the global listener methods; Events::Init + // caches it so native dispatch survives app code overwriting + // globalThis.dispatchEvent. + globalEventTarget: globalTarget, + CustomEvent: CustomEvent, + kListenerChanged: kListenerChanged, + setListenerErrorReporter: setListenerErrorReporter, +}; diff --git a/NativeScript/runtime/js/performance.js b/NativeScript/runtime/js/performance.js index 6f57cd6d..93dc368a 100644 --- a/NativeScript/runtime/js/performance.js +++ b/NativeScript/runtime/js/performance.js @@ -7,13 +7,10 @@ // else is portable JS, intended to run unchanged on the Android runtime // against an equivalent bag. // -// Deliberate deviations from the specs: -// - Observer callbacks are delivered from a microtask rather than a queued -// task. Delivery is still asynchronous relative to mark()/measure(), but it -// precedes timer callbacks scheduled in the same turn. -// - Failures the specs express as DOMException (SyntaxError, -// InvalidModificationError) are Error instances with `name` patched — -// DOMException does not exist in this runtime. +// Deliberate deviation from the specs: observer callbacks are delivered from +// a microtask rather than a queued task. Delivery is still asynchronous +// relative to mark()/measure(), but it precedes timer callbacks scheduled in +// the same turn. const { now, timeOrigin } = binding; const { ArrayPrototypeIndexOf, @@ -21,7 +18,6 @@ const { ArrayPrototypeSlice, ArrayPrototypeSort, ArrayPrototypeSplice, - Error, FunctionPrototypeCall, Number, NumberIsFinite, @@ -60,11 +56,15 @@ function illegalConstructor() { return new TypeError("Illegal constructor"); } -// SyntaxError / InvalidModificationError stand-in (see header). +// The specs' SyntaxError / InvalidModificationError / DataCloneError +// failures, required from the internal tier on first use so the +// dom-exception builtin only ever runs on a throw. +let DOMException; function domException(message, name) { - const e = new Error(message); - e.name = name; - return e; + if (DOMException === undefined) { + ({ DOMException } = require("internal/dom-exception")); + } + return new DOMException(message, name); } // WebIDL sequence conversion: only an object with a callable diff --git a/NativeScript/runtime/js/primordials.js b/NativeScript/runtime/js/primordials.js index 37381979..c9710670 100644 --- a/NativeScript/runtime/js/primordials.js +++ b/NativeScript/runtime/js/primordials.js @@ -39,10 +39,12 @@ const intrinsics = { SymbolToStringTag: Symbol.toStringTag, // Namespaces / prototypes. + ErrorPrototype: Error.prototype, ObjectPrototype: Object.prototype, // Statics. ArrayBufferIsView: ArrayBuffer.isView, + ErrorCaptureStackTrace: Error.captureStackTrace, ArrayIsArray: Array.isArray, decodeURIComponent, JSONStringify: JSON.stringify, diff --git a/NativeScript/runtime/js/structured-clone.js b/NativeScript/runtime/js/structured-clone.js index 3b3243f4..ff25ec28 100644 --- a/NativeScript/runtime/js/structured-clone.js +++ b/NativeScript/runtime/js/structured-clone.js @@ -4,19 +4,17 @@ // WebIDL sequence handling for `transfer`; the clone itself is native // (v8::ValueSerializer round-tripped in this isolate). // -// Deviations from the HTML spec, both forced by the platform: -// - There is no DOMException here, so a clone failure throws an Error whose -// `name` is "DataCloneError" (same shape as the native-exception errors in -// docs/error-handling.md). `instanceof DOMException` checks cannot work. -// - Only ArrayBuffers are transferable. MessagePort, ImageBitmap and the -// native/interop wrapper objects have no serialization form in this runtime, -// so they are rejected rather than half-supported. +// Deviation from the HTML spec, forced by the platform: only ArrayBuffers are +// transferable. MessagePort, ImageBitmap and the native/interop wrapper +// objects have no serialization form in this runtime, so they are rejected +// rather than half-supported. Clone failures are "DataCloneError" +// DOMExceptions, from here and from the native serializer alike +// (StructuredSerialization.cpp builds the same class). const { clone } = binding; const { ArrayBufferPrototypeGetByteLength, ArrayPrototypePush, - Error, FunctionPrototypeCall, SymbolIterator, TypeError, @@ -24,10 +22,12 @@ const { var g = globalThis; +let DOMException; function dataCloneError(message) { - var e = new Error(message); - e.name = "DataCloneError"; - return e; + if (DOMException === undefined) { + ({ DOMException } = require("internal/dom-exception")); + } + return new DOMException(message, "DataCloneError"); } // Brand check through the captured byteLength getter: it is the one thing only diff --git a/NativeScript/runtime/js/text-encoding.js b/NativeScript/runtime/js/text-encoding.js index 0bf08011..12751af1 100644 --- a/NativeScript/runtime/js/text-encoding.js +++ b/NativeScript/runtime/js/text-encoding.js @@ -4,8 +4,8 @@ // // 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 first read of either name. Anything needed from a sibling builtin +// comes through `require` or `binding`, never init order — 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 diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 364cba6f..47e0142b 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 364cba6f26f540a47e3c62a9029135218851f5a1 +Subproject commit 47e0142b42fd10ff067aa949b36bd8fea99aec49 diff --git a/TestRunner/app/tests/RuntimeImplementedAPIs.js b/TestRunner/app/tests/RuntimeImplementedAPIs.js index f30be4cf..74b31290 100644 --- a/TestRunner/app/tests/RuntimeImplementedAPIs.js +++ b/TestRunner/app/tests/RuntimeImplementedAPIs.js @@ -69,3 +69,23 @@ describe("structuredClone canary", () => { expect(typeof structuredClone).toBe("function"); }); }); + +// Same contract as above for the shared DOMException / CustomEvent suites: +// they self-gate, these unguarded specs turn absence into a failure. +describe("DOMException canary", () => { + it("is implemented by this runtime", () => { + expect(typeof DOMException).toBe("function"); + expect(new DOMException("x", "AbortError") instanceof Error).toBe(true); + }); + + it("is not reachable as a module from app code", () => { + expect(() => require("internal/dom-exception")).toThrow(); + }); +}); + +describe("CustomEvent canary", () => { + it("is implemented by this runtime", () => { + expect(typeof CustomEvent).toBe("function"); + expect(new CustomEvent("x") instanceof Event).toBe(true); + }); +}); diff --git a/docs/abort-signal.md b/docs/abort-signal.md index 22b33be5..d19bbe12 100644 --- a/docs/abort-signal.md +++ b/docs/abort-signal.md @@ -50,9 +50,9 @@ be dropped. accounting comes from an internal symbol-keyed hook the events builtin calls from every listener-list mutation path (add, remove, and `once` removal during dispatch); the key travels only through the builtin-only - `internals` object (see `NativeScript/runtime/js/README.md`) and never - reaches app code, so the accounting cannot be bypassed via a captured - `EventTarget.prototype.addEventListener`. + `require("internal/events")` tier (see `NativeScript/runtime/js/README.md`) + and never reaches app code, so the accounting cannot be bypassed via a + captured `EventTarget.prototype.addEventListener`. Entries leave the persistent set on abort, on the last abort-listener removal, or when a composite loses its last source. diff --git a/eslint.config.mjs b/eslint.config.mjs index cc4725f8..e99fc31f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -69,7 +69,6 @@ export default [ module: 'readonly', binding: 'readonly', primordials: 'readonly', - internals: 'readonly', global: 'readonly', console: 'readonly', URL: 'readonly', diff --git a/tools/js2c-inputs.xcfilelist b/tools/js2c-inputs.xcfilelist index 18c55154..f3934564 100644 --- a/tools/js2c-inputs.xcfilelist +++ b/tools/js2c-inputs.xcfilelist @@ -3,6 +3,7 @@ $(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/dom-exception.js $(SRCROOT)/NativeScript/runtime/js/error-events.js $(SRCROOT)/NativeScript/runtime/js/events.js $(SRCROOT)/NativeScript/runtime/js/inline-functions.js From 818aca3d9ad5c9464b88b1bbf237c4a80c138b2d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 25 Aug 2026 18:24:12 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(runtime):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20read-only=20CustomEvent.detail,=20doc=20accuracy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detail is a readonly attribute in the IDL, unlike the base Event's fields that mutate during dispatch, so define it non-writable. The internal require tier's misdescribed failure mode is corrected in the README and abort-signal comment: a cache miss runs the producer on demand, so a consumer can never observe a missing capability. The docs that still described the pre-DOMException stand-ins (abort-signal, performance, structured-clone, index) now describe the real class. --- NativeScript/runtime/js/README.md | 5 +++-- NativeScript/runtime/js/abort-signal.js | 5 ++--- NativeScript/runtime/js/events.js | 9 ++++++++- TestRunner/app/shared | 2 +- docs/README.md | 4 ++-- docs/abort-signal.md | 9 ++++----- docs/performance.md | 10 +++++----- docs/structured-clone.md | 2 +- 8 files changed, 26 insertions(+), 20 deletions(-) diff --git a/NativeScript/runtime/js/README.md b/NativeScript/runtime/js/README.md index 3aed15c9..9795dfdb 100644 --- a/NativeScript/runtime/js/README.md +++ b/NativeScript/runtime/js/README.md @@ -37,8 +37,9 @@ module.exports = somethingTheCallSiteNeeds; the capability in its `module.exports`, the consumer requires it. `require("internal/…")` at first use runs the file through the shared exports cache — for a consumer of an eager producer that is a cache hit, - and requiring before the producer ran is an init-order bug that fails - loudly at the require. + and a miss runs the producer on demand. A consumer can therefore never + observe a missing capability: it gets the exports, or the require throws + (circular require, or the producer failing to run). - `primordials` is the frozen intrinsics snapshot built by `primordials.js` (see below), the same object for every builtin in an isolate. - **`module.exports` is the export channel** — whatever it holds when the file diff --git a/NativeScript/runtime/js/abort-signal.js b/NativeScript/runtime/js/abort-signal.js index 21ba2e50..e04b3d47 100644 --- a/NativeScript/runtime/js/abort-signal.js +++ b/NativeScript/runtime/js/abort-signal.js @@ -52,9 +52,8 @@ const dispatchEvent = EventTarget.prototype.dispatchEvent; const addEventListener = EventTarget.prototype.addEventListener; const removeEventListener = EventTarget.prototype.removeEventListener; // Published by events.js: the symbol under which EventTargetImpl looks up -// the listener-mutation hook. events.js already ran, so this require is a -// cache hit; running before it would be an init-order bug this line turns -// into a loud failure. +// the listener-mutation hook. events.js already ran (Events::Init), so this +// require is a cache hit; a miss would run it on demand rather than fail. const { kListenerChanged } = require("internal/events"); // Construction token: AbortSignal instances come only from the factories in diff --git a/NativeScript/runtime/js/events.js b/NativeScript/runtime/js/events.js index 1f4a39af..7cb43312 100644 --- a/NativeScript/runtime/js/events.js +++ b/NativeScript/runtime/js/events.js @@ -154,7 +154,14 @@ g.EventTarget = EventTarget; function CustomEvent(type, opts) { FunctionPrototypeCall(Event, this, type, opts); opts = opts || {}; - this.detail = opts.detail !== undefined ? opts.detail : null; + // `detail` is readonly in the IDL, unlike the base Event's mutable-by-need + // fields (target/defaultPrevented change during dispatch). + ObjectDefineProperty(this, "detail", { + value: opts.detail !== undefined ? opts.detail : null, + writable: false, + enumerable: true, + configurable: true, + }); } CustomEvent.prototype = ObjectCreate(Event.prototype); ObjectDefineProperty(CustomEvent.prototype, "constructor", { diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 47e0142b..9cc46c06 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 47e0142b42fd10ff067aa949b36bd8fea99aec49 +Subproject commit 9cc46c06bc918d849a54d089842f5a42ebfbb6e6 diff --git a/docs/README.md b/docs/README.md index a9133bad..e0cbc0bc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,11 +14,11 @@ (`AbortController`, `AbortSignal` with the `abort`/`timeout`/`any` statics) layered on the runtime's `EventTarget`, the GC contract (weak timers and `any()` links, listener-driven persistence), and the `DOMException` - stand-in (name-patched `Error` reasons). + reasons. - [Error handling](error-handling.md) — global `error`/`unhandledrejection` events, `reportError`, catching native exceptions in JS (`error.nativeException`), forwarding JS throws to native (`interop.escapeException`), JS stacks on `NSException`, configuration flags, and crash-reporter integration. -- [structuredClone](structured-clone.md) — the WHATWG `structuredClone(value, { transfer })` global: what clones, how graph identity and cycles are preserved, `ArrayBuffer` transfer, and the `DataCloneError`-named `Error` that stands in for `DOMException`. +- [structuredClone](structured-clone.md) — the WHATWG `structuredClone(value, { transfer })` global: what clones, how graph identity and cycles are preserved, `ArrayBuffer` transfer, and the `DataCloneError` `DOMException` on failure. - [Node-API](node-api.md) — writing a Node-API addon for this runtime: registering a module and loading it with `require()`, getting the `napi_env` from native code, the threading contract, finalizer timing, which Node-API version applies, and the divergences from Node's `node_api.h`. diff --git a/docs/abort-signal.md b/docs/abort-signal.md index d19bbe12..ab19c8aa 100644 --- a/docs/abort-signal.md +++ b/docs/abort-signal.md @@ -57,13 +57,12 @@ be dropped. Entries leave the persistent set on abort, on the last abort-listener removal, or when a composite loses its last source. +Default reasons are real `DOMException`s — `"AbortError"` for a plain abort, +`"TimeoutError"` for `timeout()` — so both `reason.name` and +`instanceof DOMException` checks work. + ## Deviations from Node / the web -- **No `DOMException`.** As with [structuredClone](structured-clone.md) and - the [Performance API](performance.md), default reasons are `Error` - instances with `name` patched: `"AbortError"` (default abort) and - `"TimeoutError"` (timeout). `instanceof DOMException` checks cannot work; - match on `reason.name`. - Abort events carry no `isTrusted` flag (the runtime's `Event` doesn't model it). diff --git a/docs/performance.md b/docs/performance.md index 63173bd5..69b9088d 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -64,11 +64,11 @@ shares `performance.timeOrigin` as its base. asynchronous relative to `mark()`/`measure()` but precedes timer callbacks scheduled in the same turn. Callback exceptions are routed to `reportError`, so one throwing observer does not starve the others. -- **No `DOMException`.** Errors the specs express as `DOMException` — the - `SyntaxError` for a missing mark name, the `InvalidModificationError` for - switching an observer between the `entryTypes` and `type` forms — are - `Error` instances with `name` patched. `err.name` checks work; - `instanceof DOMException` does not. +- Errors the specs express as `DOMException` — the `SyntaxError` for a + missing mark name, the `InvalidModificationError` for switching an + observer between the `entryTypes` and `type` forms — are real + `DOMException`s: both `err.name` and `instanceof DOMException` checks + work. - Browser-only surface is absent: no resource/navigation timing, no `eventCounts`, and no `PerformanceTiming`-attribute resolution in `measure()`. diff --git a/docs/structured-clone.md b/docs/structured-clone.md index 1ed42734..2dda58e3 100644 --- a/docs/structured-clone.md +++ b/docs/structured-clone.md @@ -48,7 +48,7 @@ Two differences are intentional: ## Deviations from the specification -- **`DataCloneError` is an `Error`, not a `DOMException`.** This runtime has no `DOMException`, so failures throw an `Error` whose `name` is set to `"DataCloneError"` — the same shape used for native exceptions (see [Error handling](error-handling.md)). Detect failures with `e.name === "DataCloneError"`; `instanceof DOMException` cannot work. +- **`DataCloneError` is a `DOMException`.** Failures throw a `DOMException` named `"DataCloneError"`, from the JS argument checks and the native serializer alike, so both `e.name === "DataCloneError"` and `instanceof DOMException` detect them. (The serializer falls back to a `DataCloneError`-named `Error` only when the builtin can no longer run, e.g. during isolate teardown.) - **Only `ArrayBuffer` is transferable.** The spec's other transferable types — `MessagePort`, `ImageBitmap`, `ReadableStream` and friends — do not exist here. A non-`ArrayBuffer` in the transfer list is a `DataCloneError`. - **Host objects are not cloneable by `structuredClone`.** The spec leaves platform objects to each host; here every native/interop wrapper is rejected with a `DataCloneError`, because a JavaScript copy detached from its native counterpart would be a wrapper around nothing. Worker `postMessage` deliberately differs — see above. From c78ada46e9c8fa35791c0a48d722a88e67d61351 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 25 Aug 2026 19:00:58 -0300 Subject: [PATCH 3/4] feat(runtime): serialize DOMException per Web IDL [Serializable] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dom-exception builtin gains a native half: markCloneable stamps every instance with a per-isolate private brand (Caches::StateFor), and the serialization delegates claim branded objects through V8's HasCustomHostObject/IsHostObject hooks — the same escape hatch Node's JSTransferable protocol uses, reduced to the one class. The payload (name, message, stack) travels out-of-band on the SerializedValue with only a tag and index in the stream, because V8 forbids JS execution while a value is being read: Deserialize constructs every instance through the real constructor before ReadValue starts — on a worker isolate that never touched DOMException that runs the builtin on demand — and ReadHostObject hands them out by index, Node's host_objects_ design. Rebuilding through the constructor re-brands the instance, so a forwarded exception serializes again on the next hop. The degraded-wrapper path now writes an explicit tag where it wrote nothing; the bytes never outlive the process, so the format is free to change with the file. DOMException serializes under both host-object policies: structuredClone's kReject only refuses objects with a native half to lose, and a DOMException has none. Cost of the claim: with HasCustomHostObject on, V8 asks IsHostObject about every plain JS object in a graph — one private-symbol lookup each, the price Node pays for the same protocol. --- NativeScript/runtime/LazyGlobals.cpp | 3 +- NativeScript/runtime/NsBuiltinModules.cpp | 4 +- .../runtime/StructuredSerialization.cpp | 233 ++++++++++++++++-- .../runtime/StructuredSerialization.h | 29 +++ NativeScript/runtime/js/dom-exception.js | 12 +- TestRunner/app/shared | 2 +- .../app/tests/RuntimeImplementedAPIs.js | 6 + docs/structured-clone.md | 2 +- 8 files changed, 267 insertions(+), 24 deletions(-) diff --git a/NativeScript/runtime/LazyGlobals.cpp b/NativeScript/runtime/LazyGlobals.cpp index 98cd3a1b..148e7ec2 100644 --- a/NativeScript/runtime/LazyGlobals.cpp +++ b/NativeScript/runtime/LazyGlobals.cpp @@ -3,6 +3,7 @@ #include "Base64.h" #include "BuiltinLoader.h" #include "Helpers.h" +#include "StructuredSerialization.h" #include "TextEncoding.h" using namespace v8; @@ -34,7 +35,7 @@ constexpr LazyGlobalEntry kLazyGlobals[] = { {"TextDecoder", "TextDecoder", TextEncoding::GetExports}, {"atob", "atob", Base64::GetExports}, {"btoa", "btoa", Base64::GetExports}, - {"DOMException", "DOMException", BuiltinExports}, + {"DOMException", "DOMException", serialization::GetDomExceptionExports}, // events.js is an eager builtin (Events::Init), so this row never runs a // file: the read hits the exports cache and only the placement is lazy. {"CustomEvent", "CustomEvent", BuiltinExports}, diff --git a/NativeScript/runtime/NsBuiltinModules.cpp b/NativeScript/runtime/NsBuiltinModules.cpp index 7cd7458f..cbfc747e 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 "StructuredSerialization.h" #include "TextEncoding.h" using namespace v8; @@ -47,7 +48,8 @@ constexpr Registration kRegistry[] = { {"node:module", BuiltinId::kNodeModule, nullptr}, {"node:url", BuiltinId::kNodeUrl, nullptr}, {"node:util", BuiltinId::kNodeUtil, nullptr}, - {"internal/dom-exception", BuiltinId::kDomException, nullptr, true}, + {"internal/dom-exception", BuiltinId::kDomException, + serialization::DomExceptionBinding, true}, {"internal/events", BuiltinId::kEvents, nullptr, true}, }; diff --git a/NativeScript/runtime/StructuredSerialization.cpp b/NativeScript/runtime/StructuredSerialization.cpp index ae62cd93..4b7afdf3 100644 --- a/NativeScript/runtime/StructuredSerialization.cpp +++ b/NativeScript/runtime/StructuredSerialization.cpp @@ -1,6 +1,7 @@ #include "StructuredSerialization.h" #include "BuiltinLoader.h" +#include "Caches.h" #include "Helpers.h" #include "NativeScriptException.h" @@ -9,6 +10,67 @@ using namespace v8; namespace tns { namespace serialization { +namespace { + +// The private symbol markCloneable stamps on every DOMException instance. +// Private, so app code can neither forge the brand onto an impostor nor strip +// it; per isolate because a worker's instances are branded and checked on its +// own isolate, and only bytes cross between them. +struct DomExceptionBrandState { + Persistent brand; +}; + +// Empty once teardown has begun — callers bail to their fallback. +Local DomExceptionBrand(Isolate* isolate) { + auto* state = Caches::StateFor(isolate); + if (state == nullptr) { + return Local(); + } + if (state->brand.IsEmpty()) { + state->brand.Reset( + isolate, Private::New(isolate, tns::ToV8String( + isolate, "domExceptionCloneable"))); + } + return state->brand.Get(isolate); +} + +void MarkCloneableCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsObject()) { + return; + } + Local brand = DomExceptionBrand(isolate); + if (brand.IsEmpty()) { + return; + } + info[0] + .As() + ->SetPrivate(isolate->GetCurrentContext(), brand, v8::True(isolate)) + .FromMaybe(false); +} + +} // namespace + +MaybeLocal DomExceptionBinding(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local binding = Object::New(isolate); + Local markCloneable; + if (!v8::Function::New(context, MarkCloneableCallback) + .ToLocal(&markCloneable) || + !binding + ->Set(context, tns::ToV8String(isolate, "markCloneable"), + markCloneable) + .FromMaybe(false)) { + return MaybeLocal(); + } + return binding; +} + +MaybeLocal GetDomExceptionExports(Local context) { + return BuiltinLoader::GetExports(context, BuiltinId::kDomException, + DomExceptionBinding); +} + void ThrowDataCloneError(Isolate* isolate, const std::string& message) { // The spec's DataCloneError is a DOMException; build it through the // builtin's exports cache so native and JS throw sites produce the same @@ -21,8 +83,7 @@ void ThrowDataCloneError(Isolate* isolate, const std::string& message) { Local context = isolate->GetCurrentContext(); Local exports; Local ctor; - if (BuiltinLoader::GetExports(context, BuiltinId::kDomException, nullptr) - .ToLocal(&exports) && + if (GetDomExceptionExports(context).ToLocal(&exports) && exports->Get(context, tns::ToV8String(isolate, "DOMException")) .ToLocal(&ctor) && ctor->IsFunction()) { @@ -46,24 +107,65 @@ void ThrowDataCloneError(Isolate* isolate, const std::string& message) { namespace { +// Every host object's payload starts with one of these, so the reader can +// dispatch. kHostObjectDegraded carries nothing further; +// kHostObjectDomException carries a uint32 index into the SerializedValue's +// out-of-band payload list. The bytes never outlive the process +// (structuredClone round-trips in one isolate, worker messages cross isolates +// in the same binary), so the format can evolve freely with this file. +constexpr uint32_t kHostObjectDegraded = 0; +constexpr uint32_t kHostObjectDomException = 1; + class SerializerDelegate : public ValueSerializer::Delegate { public: - SerializerDelegate(Isolate* isolate, HostObjectPolicy hostObjectPolicy, - std::vector>* sharedBuffers) + SerializerDelegate( + Isolate* isolate, HostObjectPolicy hostObjectPolicy, + std::vector>* sharedBuffers, + std::vector* domExceptions) : isolate_(isolate), hostObjectPolicy_(hostObjectPolicy), - sharedBuffers_(sharedBuffers) {} + sharedBuffers_(sharedBuffers), + domExceptions_(domExceptions) {} + + void SetSerializer(ValueSerializer* serializer) { serializer_ = serializer; } void ThrowDataCloneError(Local message) override { serialization::ThrowDataCloneError(isolate_, tns::ToString(isolate_, message)); } + // With this returning true, V8 asks IsHostObject about every plain JS + // object in the graph — the cost of claiming a plain-JS class as a host + // object is one private-symbol lookup per object (Node pays the same for + // its JSTransferable protocol). + bool HasCustomHostObject(Isolate* isolate) override { return true; } + + Maybe IsHostObject(Isolate* isolate, Local object) override { + // Only branded DOMException instances are claimed; native-backed wrappers + // keep reaching WriteHostObject through V8's embedder-field detection. + Local brand = DomExceptionBrand(isolate); + if (brand.IsEmpty()) { + return Just(false); + } + return object->HasPrivate(isolate->GetCurrentContext(), brand); + } + Maybe WriteHostObject(Isolate* isolate, Local object) override { + // DOMException serializes under both policies: it is [Serializable] in + // the IDL, and it is a plain JS object with no native half to lose. + Local brand = DomExceptionBrand(isolate); + bool isDomException = false; + if (!brand.IsEmpty() && + !object->HasPrivate(isolate->GetCurrentContext(), brand) + .To(&isDomException)) { + return Nothing(); + } + if (isDomException) { + return WriteDomException(isolate, object); + } if (hostObjectPolicy_ == HostObjectPolicy::kDegrade) { - // V8 has already written the kHostObject tag; writing no payload is what - // the zero-byte ReadHostObject below expects, and the value surfaces as - // an empty object. + // Tag only, no payload: the value surfaces as an empty object. + serializer_->WriteUint32(kHostObjectDegraded); return Just(true); } std::string name = tns::ToString(isolate, object->GetConstructorName()); @@ -98,21 +200,78 @@ class SerializerDelegate : public ValueSerializer::Delegate { } private: + // Web IDL's DOMException serialization steps (name and message), plus the + // stack, matching Node. The payload travels out-of-band and only an index + // enters the stream: the receiving side must construct instances before + // ReadValue runs, because V8 forbids JS execution during deserialization. + Maybe WriteDomException(Isolate* isolate, Local object) { + Local context = isolate->GetCurrentContext(); + Local name, message, stack; + if (!object->Get(context, tns::ToV8String(isolate, "name")) + .ToLocal(&name) || + !object->Get(context, tns::ToV8String(isolate, "message")) + .ToLocal(&message) || + !object->Get(context, tns::ToV8String(isolate, "stack")) + .ToLocal(&stack)) { + return Nothing(); + } + SerializedValue::DomExceptionPayload payload; + payload.name = tns::ToString(isolate, name); + payload.message = tns::ToString(isolate, message); + // The stack can legitimately be absent or tampered into a non-string; + // carry it only when it is the string captureStackTrace left. + payload.hasStack = stack->IsString(); + if (payload.hasStack) { + payload.stack = tns::ToString(isolate, stack); + } + serializer_->WriteUint32(kHostObjectDomException); + serializer_->WriteUint32(static_cast(domExceptions_->size())); + domExceptions_->push_back(std::move(payload)); + return Just(true); + } + Isolate* isolate_; HostObjectPolicy hostObjectPolicy_; std::vector>* sharedBuffers_; + std::vector* domExceptions_; + ValueSerializer* serializer_ = nullptr; }; class DeserializerDelegate : public ValueDeserializer::Delegate { public: - explicit DeserializerDelegate( - const std::vector>* sharedBuffers) - : sharedBuffers_(sharedBuffers) {} + DeserializerDelegate( + const std::vector>* sharedBuffers, + const std::vector>* domExceptions) + : sharedBuffers_(sharedBuffers), domExceptions_(domExceptions) {} + + void SetDeserializer(ValueDeserializer* deserializer) { + deserializer_ = deserializer; + } - // Counterpart of the kDegrade branch: consumes no bytes, so the stream stays - // balanced. Unreachable for a value written under kReject. + // No JS may run in here (V8 forbids it during a read); DOMException + // instances were constructed by Deserialize before ReadValue started, and + // this only hands them out. MaybeLocal ReadHostObject(Isolate* isolate) override { - return Object::New(isolate); + uint32_t tag; + if (!deserializer_->ReadUint32(&tag)) { + return MaybeLocal(); + } + switch (tag) { + case kHostObjectDegraded: + // Counterpart of the kDegrade branch: tag only, so the value arrives + // as an empty object. Unreachable for a value written under kReject. + return Object::New(isolate); + case kHostObjectDomException: { + uint32_t index; + if (!deserializer_->ReadUint32(&index) || + index >= domExceptions_->size()) { + return MaybeLocal(); + } + return (*domExceptions_)[index]; + } + default: + return MaybeLocal(); + } } MaybeLocal GetSharedArrayBufferFromId( @@ -125,6 +284,8 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { private: const std::vector>* sharedBuffers_; + const std::vector>* domExceptions_; + ValueDeserializer* deserializer_ = nullptr; }; // Validates the transfer list and collects it in registration order. The @@ -193,8 +354,10 @@ Maybe SerializedValue::Serialize(Isolate* isolate, Local context, return Nothing(); } - SerializerDelegate delegate(isolate, hostObjectPolicy, &sharedBuffers_); + SerializerDelegate delegate(isolate, hostObjectPolicy, &sharedBuffers_, + &domExceptions_); ValueSerializer serializer(isolate, &delegate); + delegate.SetSerializer(&serializer); for (size_t i = 0; i < transfers.size(); i++) { serializer.TransferArrayBuffer(static_cast(i), transfers[i]); } @@ -243,9 +406,47 @@ MaybeLocal SerializedValue::Deserialize(Isolate* isolate, sharedBuffers.push_back(SharedArrayBuffer::New(isolate, backingStore)); } - DeserializerDelegate delegate(&sharedBuffers); + // Construct every DOMException the payload names before the read begins: + // JS is allowed here and forbidden inside ReadHostObject. Construction goes + // through the real constructor — on a worker isolate that never touched + // DOMException this runs the builtin on demand — so each instance is + // branded again and re-serializes on the next hop. + std::vector> domExceptions; + if (!domExceptions_.empty()) { + Local exports; + Local ctor; + if (!GetDomExceptionExports(context).ToLocal(&exports) || + !exports->Get(context, tns::ToV8String(isolate, "DOMException")) + .ToLocal(&ctor) || + !ctor->IsFunction()) { + return MaybeLocal(); + } + Local stackKey = tns::ToV8String(isolate, "stack"); + for (const DomExceptionPayload& payload : domExceptions_) { + Local args[] = {tns::ToV8String(isolate, payload.message), + tns::ToV8String(isolate, payload.name)}; + Local exception; + if (!ctor.As() + ->NewInstance(context, 2, args) + .ToLocal(&exception)) { + return MaybeLocal(); + } + // The sender's stack replaces the one captured just now for the + // receiving side's constructor frame, matching Node. + if (payload.hasStack && + !exception + ->Set(context, stackKey, tns::ToV8String(isolate, payload.stack)) + .FromMaybe(false)) { + return MaybeLocal(); + } + domExceptions.push_back(exception); + } + } + + DeserializerDelegate delegate(&sharedBuffers, &domExceptions); ValueDeserializer deserializer(isolate, buffer_.get(), bufferSize_, &delegate); + delegate.SetDeserializer(&deserializer); for (size_t i = 0; i < transferredBuffers_.size(); i++) { deserializer.TransferArrayBuffer( diff --git a/NativeScript/runtime/StructuredSerialization.h b/NativeScript/runtime/StructuredSerialization.h index 156db4b7..ac176fdf 100644 --- a/NativeScript/runtime/StructuredSerialization.h +++ b/NativeScript/runtime/StructuredSerialization.h @@ -31,6 +31,20 @@ enum class HostObjectPolicy { // carrying that name when the dom-exception builtin cannot run. void ThrowDataCloneError(v8::Isolate* isolate, const std::string& message); +// The dom-exception builtin's native half: `markCloneable`, which stamps a +// per-isolate private brand on every instance the constructor makes. The +// brand is what the serialization delegates answer IsHostObject from, so +// DOMException travels through structuredClone and worker postMessage +// (Web IDL [Serializable]). Lives here, next to those delegates. +v8::MaybeLocal DomExceptionBinding(v8::Local context); + +// The dom-exception builtin's exports with its binding attached. GetExports +// consults the factory only on the run that populates the cache, so every +// call site for this builtin must go through here — a site passing a +// different factory would win or lose by init order. +v8::MaybeLocal GetDomExceptionExports( + v8::Local context); + // A value serialized out of one isolate, plus the memory that travels with it. // Serializing and deserializing are separate halves because a worker message is // read back on a different isolate than it was written on, while @@ -58,6 +72,18 @@ class SerializedValue { v8::MaybeLocal Deserialize(v8::Isolate* isolate, v8::Local context); + // Web IDL's DOMException serialization steps (name, message) plus the + // stack, matching Node. Kept out-of-band because V8 forbids JS while a + // value is being read: Deserialize constructs every instance up front and + // ReadHostObject only hands them out by index (Node's host_objects_ + // design). + struct DomExceptionPayload { + std::string name; + std::string message; + std::string stack; + bool hasStack = false; + }; + private: struct FreeDeleter { void operator()(void* pointer) const { std::free(pointer); } @@ -72,6 +98,9 @@ class SerializedValue { std::vector> transferredBuffers_; // Backing stores shared with — not moved from — the sending isolate. std::vector> sharedBuffers_; + // One entry per distinct DOMException in the graph, in write order (a + // repeated reference is an object id in the stream, not a second entry). + std::vector domExceptions_; }; } // namespace serialization diff --git a/NativeScript/runtime/js/dom-exception.js b/NativeScript/runtime/js/dom-exception.js index e8821d6f..64eb2575 100644 --- a/NativeScript/runtime/js/dom-exception.js +++ b/NativeScript/runtime/js/dom-exception.js @@ -9,10 +9,11 @@ // constructor through require("internal/dom-exception") at throw time, so // this file never runs in an app that never touches a DOMException. // -// Not implemented: the spec's [Serializable] slot. structuredClone and worker -// postMessage go through v8::ValueSerializer, which has no hook for a plain -// JS class, so a DOMException inside a cloned graph degrades the same way any -// custom Error subclass does. +// [Serializable]: the constructor stamps every instance with a native private +// brand (binding.markCloneable), which the serialization delegates in +// StructuredSerialization.cpp claim through V8's IsHostObject hook — name, +// message and stack travel across structuredClone and worker postMessage, +// Node's JSTransferable approach reduced to the one class. const { ErrorCaptureStackTrace, ErrorPrototype, @@ -21,6 +22,8 @@ const { SymbolToStringTag, } = primordials; +const { markCloneable } = binding; + // Web IDL §4.3.4: the closed table of names with a legacy code. Any name // outside it — including every post-table spec name — has code 0. const nameToCode = { @@ -62,6 +65,7 @@ class DOMException { this.#message = `${message}`; this.#name = `${name}`; ErrorCaptureStackTrace(this, DOMException); + markCloneable(this); } get name() { diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 9cc46c06..67da5fc0 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 9cc46c06bc918d849a54d089842f5a42ebfbb6e6 +Subproject commit 67da5fc0f692aafc6342c6e87b05ced3ee770c09 diff --git a/TestRunner/app/tests/RuntimeImplementedAPIs.js b/TestRunner/app/tests/RuntimeImplementedAPIs.js index 74b31290..946f799d 100644 --- a/TestRunner/app/tests/RuntimeImplementedAPIs.js +++ b/TestRunner/app/tests/RuntimeImplementedAPIs.js @@ -81,6 +81,12 @@ describe("DOMException canary", () => { it("is not reachable as a module from app code", () => { expect(() => require("internal/dom-exception")).toThrow(); }); + + it("serializes through structuredClone on this runtime", () => { + const clone = structuredClone(new DOMException("x", "AbortError")); + expect(clone instanceof DOMException).toBe(true); + expect(clone.name).toBe("AbortError"); + }); }); describe("CustomEvent canary", () => { diff --git a/docs/structured-clone.md b/docs/structured-clone.md index 2dda58e3..cd20129f 100644 --- a/docs/structured-clone.md +++ b/docs/structured-clone.md @@ -18,7 +18,7 @@ buffer.byteLength; // 0 — the memory now belongs to `moved` - `options` may be `undefined` or `null` (both mean "no transfer"); anything else must be an object, or a `TypeError` is thrown. - `options.transfer` is a WebIDL sequence: any object with a callable `Symbol.iterator` works (an array, a `Set`, a generator). A non-iterable value — including a string primitive — throws a `TypeError`. -Cloneable: every primitive value except symbols — numbers (including `-0`, `NaN` and the infinities), strings, booleans, `BigInt`, `null` and `undefined`; plain objects and arrays; `Date`, `RegExp`, `Map`, `Set`, `Error`; `Boolean`/`String`/`Number` wrapper objects; `ArrayBuffer`, every typed array and `DataView`. +Cloneable: every primitive value except symbols — numbers (including `-0`, `NaN` and the infinities), strings, booleans, `BigInt`, `null` and `undefined`; plain objects and arrays; `Date`, `RegExp`, `Map`, `Set`, `Error`; `Boolean`/`String`/`Number` wrapper objects; `ArrayBuffer`, every typed array and `DataView`; and `DOMException`, per its Web IDL `[Serializable]` slot — `name`, `message` and `stack` round-trip through `structuredClone` and worker `postMessage`, and object identity within a graph is preserved. The clone preserves the shape of the graph, not just the values: an object referenced twice in the input is a single object referenced twice in the output, and cycles round-trip. Prototypes do not survive — a class instance clones to a plain object with the same own properties. Getters are invoked during cloning and their result is stored as a plain data property. Property insertion order is preserved. From d4eab63d4b1f58f900d74ce55b20a3e009091f47 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 25 Aug 2026 19:10:40 -0300 Subject: [PATCH 4/4] perf(runtime): claim host objects only once a DOMException exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HasCustomHostObject makes V8 consult IsHostObject for every plain JS object in a serialized graph — measured ~25ns each, ~+12% on an object-heavy structuredClone. An isolate that never constructed a DOMException cannot be holding one, so the claim is gated on a per-isolate flag markCloneable flips with the first instance; until then serialization runs the pre-claim path untouched. Accepted edge, documented at the sample site: a getter running during the very clone could construct the isolate's first DOMException after a false sample — that one instance degrades to a plain object, the pre-feature behavior, and every later serialization sees the flag. --- .../runtime/StructuredSerialization.cpp | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/NativeScript/runtime/StructuredSerialization.cpp b/NativeScript/runtime/StructuredSerialization.cpp index 4b7afdf3..c016a10d 100644 --- a/NativeScript/runtime/StructuredSerialization.cpp +++ b/NativeScript/runtime/StructuredSerialization.cpp @@ -15,9 +15,17 @@ namespace { // The private symbol markCloneable stamps on every DOMException instance. // Private, so app code can neither forge the brand onto an impostor nor strip // it; per isolate because a worker's instances are branded and checked on its -// own isolate, and only bytes cross between them. +// own isolate, and only bytes cross between them. `anyInstances` flips when +// the first instance is branded and gates HasCustomHostObject: claiming host +// objects makes V8 consult IsHostObject for every plain JS object in a +// serialized graph (~25ns each, ~+12% on an object-heavy clone), and an +// isolate that never constructed a DOMException cannot be holding one, so it +// keeps serializing on the exact pre-claim path. Every instance passes +// through markCloneable — deserialization rebuilds via the constructor — so +// the flag cannot miss one. struct DomExceptionBrandState { Persistent brand; + bool anyInstances = false; }; // Empty once teardown has begun — callers bail to their fallback. @@ -34,6 +42,11 @@ Local DomExceptionBrand(Isolate* isolate) { return state->brand.Get(isolate); } +bool AnyDomExceptionInstances(Isolate* isolate) { + auto* state = Caches::StateFor(isolate); + return state != nullptr && state->anyInstances; +} + void MarkCloneableCallback(const FunctionCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); if (info.Length() < 1 || !info[0]->IsObject()) { @@ -43,10 +56,12 @@ void MarkCloneableCallback(const FunctionCallbackInfo& info) { if (brand.IsEmpty()) { return; } - info[0] - .As() - ->SetPrivate(isolate->GetCurrentContext(), brand, v8::True(isolate)) - .FromMaybe(false); + if (info[0] + .As() + ->SetPrivate(isolate->GetCurrentContext(), brand, v8::True(isolate)) + .FromMaybe(false)) { + Caches::StateFor(isolate)->anyInstances = true; + } } } // namespace @@ -137,8 +152,16 @@ class SerializerDelegate : public ValueSerializer::Delegate { // With this returning true, V8 asks IsHostObject about every plain JS // object in the graph — the cost of claiming a plain-JS class as a host // object is one private-symbol lookup per object (Node pays the same for - // its JSTransferable protocol). - bool HasCustomHostObject(Isolate* isolate) override { return true; } + // its JSTransferable protocol). Claimed only once this isolate has actually + // constructed a DOMException; until then serialization runs the pre-claim + // path untouched. V8 samples this once per ValueSerializer. Accepted edge: + // a getter invoked during this very clone could construct the isolate's + // FIRST DOMException and return it into the graph after a false sample — + // that one instance degrades to a plain object (the pre-feature behavior) + // instead of cloning; every later serialization sees the flag. + bool HasCustomHostObject(Isolate* isolate) override { + return AnyDomExceptionInstances(isolate); + } Maybe IsHostObject(Isolate* isolate, Local object) override { // Only branded DOMException instances are claimed; native-backed wrappers