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..9795dfdb 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,21 @@ 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 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. -- `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 +74,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..e04b3d47 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,31 @@ 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 (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 // 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..7cb43312 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,39 @@ 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 || {}; + // `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", { + 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..9cc46c06 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 364cba6f26f540a47e3c62a9029135218851f5a1 +Subproject commit 9cc46c06bc918d849a54d089842f5a42ebfbb6e6 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/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 22b33be5..ab19c8aa 100644 --- a/docs/abort-signal.md +++ b/docs/abort-signal.md @@ -50,20 +50,19 @@ 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. +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. 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