From f9ea14931f78ce6a91771d444b01338a018d87e4 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 30 Jul 2026 14:14:17 -0300 Subject: [PATCH 1/2] feat: expose inspect and format as the ns:util builtin module Runtime-provided modules now resolve under the URL-style `ns:` scheme, ahead of any filesystem or npm resolution, with `node:` compatibility shims served from the same registry. v1 ships `ns:util` (inspect, format) and a `node:util` shim re-exporting its members from a distinct, frozen module object. Each specifier is one source file and one registry entry: a shim is its own builtin that reaches the `ns:` module it adapts through a new fixed wrapper parameter, `require`, which resolves builtin specifiers and nothing else. Shims therefore compile only when first resolved and own every bit of Node adaptation, keeping compatibility knowledge out of the standard modules. format() is Node's util.format (%s %d %i %f %j %o %O %%, extras appended space-separated), and console.* routes its arguments through it, so `console.log("%d apples", 3)` works while unknown and dangling percent signs stay verbatim. Resolution is wired into the CommonJS require path, the ES module resolve callback and the dynamic-import callback; ESM consumption is served by a per-realm synthetic module. An unknown name in either scheme fails with `No such built-in module: `, which replaces the warn-and-export- nothing polyfill previously handed to unshimmed `node:` imports. Bare specifiers are untouched. docs/ns-builtin-modules.md is the cross-runtime contract both runtimes implement. --- NativeScript/runtime/BuiltinLoader.cpp | 58 +++- NativeScript/runtime/BuiltinLoader.h | 10 +- NativeScript/runtime/Caches.h | 20 ++ NativeScript/runtime/Console.cpp | 30 ++ NativeScript/runtime/Console.h | 4 +- NativeScript/runtime/ModuleInternal.mm | 20 ++ .../runtime/ModuleInternalCallbacks.mm | 46 ++- NativeScript/runtime/NsBuiltinModules.cpp | 303 ++++++++++++++++++ NativeScript/runtime/NsBuiltinModules.h | 50 +++ NativeScript/runtime/js/README.md | 17 +- NativeScript/runtime/js/node-util.js | 16 + NativeScript/runtime/js/ns-util.js | 151 +++++++++ NativeScript/runtime/js/primordials.js | 4 + TestRunner/app/tests/NsUtilTests.js | 176 ++++++++++ TestRunner/app/tests/esm/ns-util-import.mjs | 6 + TestRunner/app/tests/index.js | 3 + docs/ns-builtin-modules.md | 139 ++++++++ eslint.config.mjs | 5 +- tools/js2c-inputs.xcfilelist | 2 + v8ios.xcodeproj/project.pbxproj | 6 + 20 files changed, 1047 insertions(+), 19 deletions(-) create mode 100644 NativeScript/runtime/NsBuiltinModules.cpp create mode 100644 NativeScript/runtime/NsBuiltinModules.h create mode 100644 NativeScript/runtime/js/node-util.js create mode 100644 NativeScript/runtime/js/ns-util.js create mode 100644 TestRunner/app/tests/NsUtilTests.js create mode 100644 TestRunner/app/tests/esm/ns-util-import.mjs create mode 100644 docs/ns-builtin-modules.md diff --git a/NativeScript/runtime/BuiltinLoader.cpp b/NativeScript/runtime/BuiltinLoader.cpp index 0c99f3c8..f5c1c909 100644 --- a/NativeScript/runtime/BuiltinLoader.cpp +++ b/NativeScript/runtime/BuiltinLoader.cpp @@ -5,6 +5,7 @@ #include "Caches.h" #include "Helpers.h" +#include "NsBuiltinModules.h" using namespace v8; @@ -18,14 +19,55 @@ std::vector builtinCache[static_cast(BuiltinId::kCount)]; // Every builtin is compiled as a function body receiving these fixed // parameters, mirroring Node's module wrapper: a file exports through -// `module.exports`/`exports`, natives arrive as properties of the `binding` -// bag (Node's internalBinding idiom) and intrinsics as properties of -// `primordials`; each file destructures what it needs. +// `module.exports`/`exports`, reaches sibling builtin modules through +// `require`, natives arrive as properties of the `binding` bag (Node's +// internalBinding idiom) and intrinsics as properties of `primordials`; each +// file destructures what it needs. constexpr const char* kExportsParamName = "exports"; +constexpr const char* kRequireParamName = "require"; constexpr const char* kModuleParamName = "module"; constexpr const char* kBindingParamName = "binding"; constexpr const char* kPrimordialsParamName = "primordials"; -constexpr int kParamCount = 4; +constexpr int kParamCount = 5; + +// The `require` every builtin receives: builtin specifiers only, 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()) { + isolate->ThrowException(Exception::TypeError( + tns::ToV8String(isolate, "require() expects a specifier string"))); + return; + } + + Local context = isolate->GetCurrentContext(); + std::string specifier = tns::ToString(isolate, info[0].As()); + Local exports; + if (NsBuiltinModules::GetExports(context, specifier).ToLocal(&exports)) { + info.GetReturnValue().Set(exports); + } else if (!NsBuiltinModules::IsRegistered(specifier)) { + isolate->ThrowException(Exception::Error(tns::ToV8String( + isolate, NsBuiltinModules::NotFoundMessage(specifier)))); + } +} + +MaybeLocal GetBuiltinRequire(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + std::shared_ptr cache = Caches::Get(isolate); + if (cache->BuiltinRequire != nullptr) { + return cache->BuiltinRequire->Get(isolate); + } + + Local require; + if (!v8::Function::New(context, BuiltinRequireCallback, Local(), 1, + ConstructorBehavior::kThrow) + .ToLocal(&require)) { + return MaybeLocal(); + } + cache->BuiltinRequire = + std::make_unique>(isolate, require); + return require; +} MaybeLocal CompileBuiltin(Local context, BuiltinId id) { Isolate* isolate = v8::Isolate::GetCurrent(); @@ -54,6 +96,7 @@ MaybeLocal CompileBuiltin(Local context, BuiltinId id) { 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)}; @@ -105,6 +148,11 @@ MaybeLocal CallBuiltin(Local context, BuiltinId id, return MaybeLocal(); } + Local require; + if (!GetBuiltinRequire(context).ToLocal(&require)) { + return MaybeLocal(); + } + Local exportsObj = Object::New(isolate); Local moduleObj = Object::New(isolate); Local exportsKey = tns::ToV8String(isolate, kExportsParamName); @@ -113,7 +161,7 @@ MaybeLocal CallBuiltin(Local context, BuiltinId id, } Local args[] = { - exportsObj, moduleObj, + exportsObj, require, moduleObj, binding.IsEmpty() ? v8::Undefined(isolate).As() : binding, primordials}; if (fn->Call(context, v8::Undefined(isolate), kParamCount, args).IsEmpty()) { diff --git a/NativeScript/runtime/BuiltinLoader.h b/NativeScript/runtime/BuiltinLoader.h index 56fb7505..fe7ae8c4 100644 --- a/NativeScript/runtime/BuiltinLoader.h +++ b/NativeScript/runtime/BuiltinLoader.h @@ -9,10 +9,12 @@ namespace tns { class BuiltinLoader { public: // Compiles the builtin identified by id as a function body with the fixed - // parameters `exports`, `module`, `binding` (Node's module wrapper plus its - // internalBinding idiom) and `primordials`, calls it with the given bag of - // natives (or undefined when omitted) plus this isolate's frozen intrinsics - // snapshot, and returns the resulting `module.exports`. 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) plus this isolate's frozen + // intrinsics snapshot, and returns the resulting `module.exports`. `require` + // reaches the builtin modules (NsBuiltinModules) 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/Caches.h b/NativeScript/runtime/Caches.h index 647b4214..1b62c19b 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -155,6 +155,10 @@ class Caches { // console formatter (internal/inspect.js), initialized by Console::Init. std::unique_ptr> InspectFunc = std::unique_ptr>(nullptr); + // ns:util's format, used by console.* for %-substitution. + std::unique_ptr> FormatFunc = + std::unique_ptr>(nullptr); + bool FormatFuncUnavailable = false; std::unique_ptr> InteropReferenceCtorFunc = std::unique_ptr>(nullptr); std::unique_ptr> PointerCtorFunc = @@ -164,6 +168,22 @@ class Caches { std::unique_ptr> UnmanagedTypeCtorFunc = std::unique_ptr>(nullptr); + // `ns:`/`node:` builtin modules (NsBuiltinModules), keyed by specifier. Both + // are per isolate: a builtin module is a singleton per realm, so workers get + // their own exports objects and their own synthetic modules. + robin_hood::unordered_map>> + BuiltinModuleExports; + robin_hood::unordered_map>> + BuiltinModules; + // Specifiers currently being built, so a shim requiring back into the module + // that is loading it fails instead of recursing. + robin_hood::unordered_set BuiltinModulesInProgress; + // The `require` handed to every builtin, resolving builtin specifiers only. + std::unique_ptr> BuiltinRequire = + std::unique_ptr>(nullptr); + // Frozen intrinsics snapshot returned by internal/primordials.js, passed to // every builtin as its second fixed parameter (BuiltinLoader::RunBuiltin). // Per isolate, so workers snapshot their own realm's intrinsics. diff --git a/NativeScript/runtime/Console.cpp b/NativeScript/runtime/Console.cpp index 0ca65a89..a0550af8 100644 --- a/NativeScript/runtime/Console.cpp +++ b/NativeScript/runtime/Console.cpp @@ -11,6 +11,7 @@ #include "DataWrapper.h" #include "Helpers.h" #include "NativeScriptException.h" +#include "NsBuiltinModules.h" #include "RuntimeConfig.h" // #include "v8-log-agent-impl.h" #include @@ -272,6 +273,30 @@ std::string Console::BuildStringFromArgs( Isolate* isolate = args.GetIsolate(); Local context = isolate->GetCurrentContext(); int argLen = args.Length(); + + // console.* follows Node: the arguments go through util.format, so the first + // one may carry %-substitutions and the rest are appended space-separated. + Local format = argLen > startingIndex + ? NsBuiltinModules::GetFormatFunc(context) + : Local(); + if (!format.IsEmpty()) { + std::vector> formatArgs; + formatArgs.reserve(argLen - startingIndex); + for (int i = startingIndex; i < argLen; i++) { + formatArgs.push_back(args[i]); + } + TryCatch tc(isolate); + Local result; + if (format + ->Call(context, v8::Undefined(isolate), + static_cast(formatArgs.size()), formatArgs.data()) + .ToLocal(&result) && + result->IsString()) { + return tns::ToString(isolate, result.As()); + } + } + + // ns:util unavailable or the formatter threw: per-argument rendering. std::stringstream ss; if (argLen > 0) { @@ -327,6 +352,11 @@ static void GetNativeWrapperHintCallback( void Console::InitInspect(Local context) { Isolate* isolate = v8::Isolate::GetCurrent(); + if (Caches::Get(isolate)->InspectFunc != nullptr) { + // inspect.js installs a non-configurable global.__inspect, so a second run + // in the same realm would throw. + return; + } Local hintFunc; if (!v8::Function::New(context, GetNativeWrapperHintCallback) diff --git a/NativeScript/runtime/Console.h b/NativeScript/runtime/Console.h index 0ddb59a8..d0b43504 100644 --- a/NativeScript/runtime/Console.h +++ b/NativeScript/runtime/Console.h @@ -14,6 +14,9 @@ class Console { static void AttachInspectorClient( v8_inspector::JsV8InspectorClient* inspector); static void DetachInspectorClient(); + // Builds this realm's inspect function (Caches::InspectFunc) if it isn't + // there yet. Public so ns:util can re-export the same instance. + static void InitInspect(v8::Local context); private: using ConsoleAPIType = v8_inspector::ConsoleAPIType; @@ -34,7 +37,6 @@ class Console { static v8::Local InspectValue(v8::Local context, const v8::Local& val, int depth = -1); - static void InitInspect(v8::Local context); static ConsoleAPIType VerbosityToInspectorMethod(const std::string level); static void SendToDevToolsFrontEnd( diff --git a/NativeScript/runtime/ModuleInternal.mm b/NativeScript/runtime/ModuleInternal.mm index afabf8b2..a864298a 100644 --- a/NativeScript/runtime/ModuleInternal.mm +++ b/NativeScript/runtime/ModuleInternal.mm @@ -11,6 +11,7 @@ #include "Helpers.h" #include "ModuleInternalCallbacks.h" // for ResolveModuleCallback #include "NativeScriptException.h" +#include "NsBuiltinModules.h" #include "Runtime.h" // for GetAppConfigValue #include "RuntimeConfig.h" @@ -214,6 +215,25 @@ bool IsESModule(const std::string& path) { void ModuleInternal::RequireCallback(const FunctionCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); + // Builtin modules resolve before any path handling, so they can never be + // shadowed by a file or a package, and an unknown one fails as a missing + // builtin rather than as a missing file. Only prefixed specifiers get here: + // a bare `util` still resolves through npm. + if (info.Length() > 0 && info[0]->IsString()) { + std::string specifier = tns::ToString(isolate, info[0].As()); + if (NsBuiltinModules::IsBuiltinScheme(specifier)) { + Local context = isolate->GetCurrentContext(); + Local exports; + if (NsBuiltinModules::GetExports(context, specifier).ToLocal(&exports)) { + info.GetReturnValue().Set(exports); + } else if (!NsBuiltinModules::IsRegistered(specifier)) { + isolate->ThrowException(Exception::Error( + tns::ToV8String(isolate, NsBuiltinModules::NotFoundMessage(specifier)))); + } + return; + } + } + // Declare these outside try block so they're available in catch std::string moduleName; std::string callingModuleDirName; diff --git a/NativeScript/runtime/ModuleInternalCallbacks.mm b/NativeScript/runtime/ModuleInternalCallbacks.mm index 5b0fad3d..25305610 100644 --- a/NativeScript/runtime/ModuleInternalCallbacks.mm +++ b/NativeScript/runtime/ModuleInternalCallbacks.mm @@ -16,6 +16,7 @@ #include "Helpers.h" // for tns::Exists #include "ModuleInternal.h" // for LoadScript(...) #include "NativeScriptException.h" +#include "NsBuiltinModules.h" #include "Runtime.h" // for GetAppConfigValue #include "RuntimeConfig.h" @@ -715,6 +716,20 @@ static bool IsDocumentsPath(const std::string& path) { return v8::MaybeLocal(); } + // Builtin modules resolve before any path handling. Unshimmed "node:" names + // fall through to the legacy node:url polyfill below. + if (NsBuiltinModules::IsRegistered(rawSpec) || NsBuiltinModules::IsNsScheme(rawSpec)) { + v8::Local builtin; + if (NsBuiltinModules::GetModule(context, rawSpec).ToLocal(&builtin)) { + return v8::MaybeLocal(builtin); + } + if (!NsBuiltinModules::IsRegistered(rawSpec)) { + isolate->ThrowException(v8::Exception::Error( + tns::ToV8String(isolate, NsBuiltinModules::NotFoundMessage(rawSpec)))); + } + return v8::MaybeLocal(); + } + std::string normalizedSpec = rawSpec; // Normalize malformed HTTP(S) schemes that sometimes appear as 'http:/host' (single slash) @@ -1344,11 +1359,9 @@ static bool IsDocumentsPath(const std::string& path) { " return new URL('file://' + encoded);\n" "}\n"; } else { - // Generic polyfill for other Node.js built-in modules - polyfillContent = "// In-memory polyfill for node:" + builtinName + "\n" + - "console.warn('Node.js built-in module \\'node:" + builtinName + - "\\' is not fully supported in NativeScript');\n" + - "export default {};\n"; + isolate->ThrowException(v8::Exception::Error( + tns::ToV8String(isolate, NsBuiltinModules::NotFoundMessage(spec)))); + return v8::MaybeLocal(); } v8::MaybeLocal m = @@ -1862,6 +1875,29 @@ static bool IsDocumentsPath(const std::string& path) { // Normalize spec: expand '@/'; only strip ?query/hash for non-HTTP specs so SFC HTTP keys keep // version tags std::string rawSpec = cSpec ? std::string(cSpec) : std::string(); + + // Builtin modules never reach the loader below; the namespace comes straight + // from the realm's synthetic module. + if (NsBuiltinModules::IsRegistered(rawSpec) || NsBuiltinModules::IsNsScheme(rawSpec)) { + v8::EscapableHandleScope builtinScope(isolate); + v8::Local builtinResolver; + if (!v8::Promise::Resolver::New(context).ToLocal(&builtinResolver)) { + return v8::MaybeLocal(); + } + v8::TryCatch tc(isolate); + v8::Local builtin; + if (NsBuiltinModules::GetModule(context, rawSpec).ToLocal(&builtin)) { + builtinResolver->Resolve(context, builtin->GetModuleNamespace()).FromMaybe(false); + } else { + v8::Local error = tc.HasCaught() + ? tc.Exception() + : v8::Exception::Error(tns::ToV8String( + isolate, NsBuiltinModules::NotFoundMessage(rawSpec))); + builtinResolver->Reject(context, error).FromMaybe(false); + } + return builtinScope.Escape(builtinResolver->GetPromise()); + } + std::string normalizedSpec = rawSpec; // remove query/hash ONLY for non-HTTP specs bool isHttpLike = (!normalizedSpec.empty() && (StartsWith(normalizedSpec, "http://") || diff --git a/NativeScript/runtime/NsBuiltinModules.cpp b/NativeScript/runtime/NsBuiltinModules.cpp new file mode 100644 index 00000000..222e0f23 --- /dev/null +++ b/NativeScript/runtime/NsBuiltinModules.cpp @@ -0,0 +1,303 @@ +#include "NsBuiltinModules.h" + +#include + +#include "BuiltinLoader.h" +#include "Caches.h" +#include "Console.h" +#include "Helpers.h" + +using namespace v8; + +namespace tns { + +namespace { + +constexpr const char* kNsPrefix = "ns:"; +constexpr const char* kNodePrefix = "node:"; + +struct Registration { + const char* specifier; + BuiltinId builtin; +}; + +// The v1 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. +constexpr Registration kRegistry[] = { + {"ns:util", BuiltinId::kNsUtil}, + {"node:util", BuiltinId::kNodeUtil}, +}; + +const Registration* Find(const std::string& specifier) { + for (const Registration& registration : kRegistry) { + if (specifier == registration.specifier) { + return ®istration; + } + } + return nullptr; +} + +bool HasPrefix(const std::string& specifier, const char* prefix) { + return specifier.rfind(prefix, 0) == 0; +} + +MaybeLocal BuildBinding(Local context, BuiltinId builtin) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local binding = Object::New(isolate); + + switch (builtin) { + case BuiltinId::kNsUtil: { + // The console formatter is built once per realm; ns:util re-exports that + // instance instead of creating a second one. + Console::InitInspect(context); + std::shared_ptr cache = Caches::Get(isolate); + if (cache->InspectFunc == nullptr) { + return MaybeLocal(); + } + if (!binding + ->Set(context, tns::ToV8String(isolate, "inspect"), + cache->InspectFunc->Get(isolate)) + .FromMaybe(false)) { + return MaybeLocal(); + } + break; + } + default: + break; + } + + return binding; +} + +// Runs a module's builtin and caches its exports. Always leaves an exception +// pending when it returns false. +bool Instantiate(Local context, const Registration& requested) { + Isolate* isolate = v8::Isolate::GetCurrent(); + std::shared_ptr cache = Caches::Get(isolate); + + // A shim reaches its ns: module through the builtin require, so the graph is + // walked while a module is still being built; a cycle would otherwise + // recurse until the stack runs out. + if (cache->BuiltinModulesInProgress.count(requested.specifier) > 0) { + isolate->ThrowException(Exception::Error( + tns::ToV8String(isolate, "Circular require of built-in module: " + + std::string(requested.specifier)))); + return false; + } + cache->BuiltinModulesInProgress.emplace(requested.specifier); + + TryCatch tc(isolate); + Local binding; + Local result; + bool built = BuildBinding(context, requested.builtin).ToLocal(&binding) && + BuiltinLoader::RunBuiltin(context, requested.builtin, binding) + .ToLocal(&result) && + result->IsObject(); + cache->BuiltinModulesInProgress.erase(requested.specifier); + + if (built) { + cache->BuiltinModuleExports[requested.specifier] = + std::make_unique>(isolate, result.As()); + return true; + } + + if (tc.HasCaught()) { + tc.ReThrow(); + return false; + } + isolate->ThrowException(Exception::Error( + tns::ToV8String(isolate, "Failed to initialize built-in module '" + + std::string(requested.specifier) + "'"))); + return false; +} + +// The exports a synthetic module re-exports by name, in the order used both +// when declaring the export names and when populating them. +MaybeLocal ExportKeys(Local context, + Local exports) { + return exports->GetOwnPropertyNames(context, PropertyFilter::ONLY_ENUMERABLE, + KeyConversionMode::kConvertToString); +} + +MaybeLocal NoDependencies(Local context, + Local specifier, + Local import_attributes, + Local referrer) { + // Synthetic modules never request anything. + return MaybeLocal(); +} + +MaybeLocal PopulateSyntheticModule(Local context, + Local module) { + Isolate* isolate = v8::Isolate::GetCurrent(); + // The specifier was handed to CreateSyntheticModule as the module name, + // which is how these steps find the exports object again. + std::string specifier = tns::ToString(isolate, module->GetResourceName()); + + Local exports; + if (!NsBuiltinModules::GetExports(context, specifier).ToLocal(&exports)) { + return MaybeLocal(); + } + + Local defaultName = tns::ToV8String(isolate, "default"); + if (module->SetSyntheticModuleExport(isolate, defaultName, exports) + .IsNothing()) { + return MaybeLocal(); + } + + Local keys; + if (!ExportKeys(context, exports).ToLocal(&keys)) { + return MaybeLocal(); + } + for (uint32_t i = 0; i < keys->Length(); i++) { + Local key; + Local value; + if (!keys->Get(context, i).ToLocal(&key) || !key->IsString()) { + return MaybeLocal(); + } + if (key.As()->StringEquals(defaultName)) { + continue; + } + if (!exports->Get(context, key).ToLocal(&value) || + module->SetSyntheticModuleExport(isolate, key.As(), value) + .IsNothing()) { + return MaybeLocal(); + } + } + + Local resolver; + if (!Promise::Resolver::New(context).ToLocal(&resolver) || + !resolver->Resolve(context, v8::Undefined(isolate)).FromMaybe(false)) { + return MaybeLocal(); + } + return resolver->GetPromise(); +} + +} // namespace + +bool NsBuiltinModules::IsBuiltinScheme(const std::string& specifier) { + return HasPrefix(specifier, kNsPrefix) || HasPrefix(specifier, kNodePrefix); +} + +bool NsBuiltinModules::IsNsScheme(const std::string& specifier) { + return HasPrefix(specifier, kNsPrefix); +} + +bool NsBuiltinModules::IsRegistered(const std::string& specifier) { + return Find(specifier) != nullptr; +} + +std::string NsBuiltinModules::NotFoundMessage(const std::string& specifier) { + return "No such built-in module: " + specifier; +} + +MaybeLocal NsBuiltinModules::GetExports(Local context, + const std::string& specifier) { + const Registration* registration = Find(specifier); + if (registration == nullptr) { + return MaybeLocal(); + } + + Isolate* isolate = v8::Isolate::GetCurrent(); + std::shared_ptr cache = Caches::Get(isolate); + auto it = cache->BuiltinModuleExports.find(specifier); + if (it == cache->BuiltinModuleExports.end()) { + if (!Instantiate(context, *registration)) { + return MaybeLocal(); + } + it = cache->BuiltinModuleExports.find(specifier); + if (it == cache->BuiltinModuleExports.end()) { + return MaybeLocal(); + } + } + return it->second->Get(isolate); +} + +MaybeLocal NsBuiltinModules::GetModule(Local context, + const std::string& specifier) { + Isolate* isolate = v8::Isolate::GetCurrent(); + std::shared_ptr cache = Caches::Get(isolate); + + auto it = cache->BuiltinModules.find(specifier); + if (it != cache->BuiltinModules.end()) { + Local cached = it->second->Get(isolate); + if (!cached.IsEmpty() && cached->GetStatus() != Module::kErrored) { + return cached; + } + cache->BuiltinModules.erase(it); + } + + Local exports; + if (!GetExports(context, specifier).ToLocal(&exports)) { + return MaybeLocal(); + } + + Local defaultName = tns::ToV8String(isolate, "default"); + std::vector> exportNames{defaultName}; + Local keys; + if (!ExportKeys(context, exports).ToLocal(&keys)) { + return MaybeLocal(); + } + for (uint32_t i = 0; i < keys->Length(); i++) { + Local key; + if (!keys->Get(context, i).ToLocal(&key) || !key->IsString()) { + return MaybeLocal(); + } + if (!key.As()->StringEquals(defaultName)) { + exportNames.push_back(key.As()); + } + } + + Local module = Module::CreateSyntheticModule( + isolate, tns::ToV8String(isolate, specifier), + MemorySpan>(exportNames.data(), + exportNames.size()), + PopulateSyntheticModule); + // No dependencies and no user code, so the module can be driven to its final + // state here; importers then only ever see an evaluated module. + if (!module->InstantiateModule(context, &NoDependencies).FromMaybe(false)) { + return MaybeLocal(); + } + if (module->Evaluate(context).IsEmpty()) { + return MaybeLocal(); + } + + cache->BuiltinModules[specifier] = + std::make_unique>(isolate, module); + return module; +} + +Local NsBuiltinModules::GetFormatFunc(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + std::shared_ptr cache = Caches::Get(isolate); + if (cache->FormatFunc != nullptr) { + return cache->FormatFunc->Get(isolate); + } + if (cache->FormatFuncUnavailable) { + return Local(); + } + + TryCatch tc(isolate); + Local exports; + Local format; + if (!GetExports(context, "ns:util").ToLocal(&exports) || + !exports->Get(context, tns::ToV8String(isolate, "format")) + .ToLocal(&format) || + !format->IsFunction()) { + if (tc.HasCaught()) { + tns::LogError(isolate, tc); + } + // One attempt per realm: a broken builtin must not make every log call + // recompile it. + cache->FormatFuncUnavailable = true; + return Local(); + } + + cache->FormatFunc = std::make_unique>( + isolate, format.As()); + return format.As(); +} + +} // namespace tns diff --git a/NativeScript/runtime/NsBuiltinModules.h b/NativeScript/runtime/NsBuiltinModules.h new file mode 100644 index 00000000..da6edb67 --- /dev/null +++ b/NativeScript/runtime/NsBuiltinModules.h @@ -0,0 +1,50 @@ +#ifndef NsBuiltinModules_h +#define NsBuiltinModules_h + +#include + +#include "Common.h" + +namespace tns { + +// The `ns:` builtin modules and their `node:` compatibility shims, as +// specified by docs/ns-builtin-modules.md. Every module is a per-realm +// singleton built from a RuntimeBuiltins source, so a specifier always yields +// the same frozen exports object and the same synthetic ES module. +class NsBuiltinModules { + public: + // Any `ns:`/`node:` specifier, registered or not. The CommonJS resolver + // claims all of them so a prefixed name can never reach the filesystem; + // bare specifiers (`util`) keep resolving through npm untouched. + static bool IsBuiltinScheme(const std::string& specifier); + + // The `ns:` half of the scheme, whose unknown names must fail rather than + // fall through to any legacy handling. + static bool IsNsScheme(const std::string& specifier); + + // Whether a module of that name exists. + static bool IsRegistered(const std::string& specifier); + + // Frozen exports object of a registered specifier. Empty when the specifier + // is not registered (nothing thrown) or when the module failed to build (an + // exception is pending). + static v8::MaybeLocal GetExports(v8::Local context, + const std::string& specifier); + + // Evaluated synthetic module exporting the same values as GetExports under + // their own names plus `default` (the exports object itself). + static v8::MaybeLocal GetModule(v8::Local context, + const std::string& specifier); + + // What every resolver reports for a specifier in the scheme that names no + // module. + static std::string NotFoundMessage(const std::string& specifier); + + // `ns:util`'s format, used by console.* for %-substitution. Empty when the + // module could not be built; callers degrade instead of failing the log. + static v8::Local GetFormatFunc(v8::Local context); +}; + +} // namespace tns + +#endif /* NsBuiltinModules_h */ diff --git a/NativeScript/runtime/js/README.md b/NativeScript/runtime/js/README.md index 95382b6d..7bb8a47f 100644 --- a/NativeScript/runtime/js/README.md +++ b/NativeScript/runtime/js/README.md @@ -9,17 +9,24 @@ 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`, `module`, `binding` and `primordials`: +with the fixed parameters `exports`, `require`, `module`, `binding` and +`primordials`: ```js const { someNative, anotherNative } = binding; const { ArrayPrototypeSlice, ObjectCreate } = primordials; +const { inspect } = require("ns:util"); module.exports = somethingTheCallSiteNeeds; ``` - `binding` is a plain object of natives built by the C++ call site; a file that needs nothing from C++ simply doesn't mention it. +- `require` resolves **builtin specifiers only** (`ns:util`, `node:util`, …), + never a path or a package; an unknown one throws + `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. - `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 @@ -35,6 +42,10 @@ module.exports = somethingTheCallSiteNeeds; internal `__inspect` global): budgeted output, no getter invocation, tamper-immune via primordials. Console routes all object formatting through it. +- `ns-util.js` is the `ns:util` module app code requires and `node-util.js` the + `node:util` shim: one source file per specifier, the shim owning every bit of + Node compatibility. See `docs/ns-builtin-modules.md` for the cross-runtime + contract. - Destructure `binding` and `primordials` once, at the top of the file, so the file's dependencies are visible and greppable. @@ -46,8 +57,8 @@ module.exports = somethingTheCallSiteNeeds; (`URLSearchParams`, …) capture it into a file-level `const`. - No `import`/`export` — these are classic function bodies, not modules. - ESLint (`eslint.config.mjs` at the repo root, run by lint-staged) declares - `exports`, `module`, `binding`, `primordials` and the reachable native - globals; `no-undef` is the typo net. If a builtin starts using a new native + `exports`, `require`, `module`, `binding`, `primordials` and the reachable + native globals; `no-undef` is the typo net. If a builtin starts using a new native global, add it there. `no-restricted-properties` fails the lint on direct use of the captured statics (`JSON.stringify`, `Object.defineProperty`, …). Uncurried instance methods can't be matched that way, so `list.slice()` diff --git a/NativeScript/runtime/js/node-util.js b/NativeScript/runtime/js/node-util.js new file mode 100644 index 00000000..a89c2826 --- /dev/null +++ b/NativeScript/runtime/js/node-util.js @@ -0,0 +1,16 @@ +"use strict"; + +// The `node:util` compatibility shim: a documented subset of Node's util, +// backed by `ns:util` (docs/ns-builtin-modules.md). Compiled the first time +// `node:util` is resolved, so an app that never touches the `node:` scheme +// never pays for it. +// +// Every adaptation to Node's API belongs in this file — argument shapes, +// option names, aliases, anything that has to track Node rather than the +// runtime. The `ns:` modules stay free of compatibility knowledge, so the two +// surfaces can diverge without either one carrying the other's baggage. + +const { ObjectFreeze } = primordials; +const { inspect, format } = require("ns:util"); + +module.exports = ObjectFreeze({ inspect, format }); diff --git a/NativeScript/runtime/js/ns-util.js b/NativeScript/runtime/js/ns-util.js new file mode 100644 index 00000000..edc47327 --- /dev/null +++ b/NativeScript/runtime/js/ns-util.js @@ -0,0 +1,151 @@ +"use strict"; + +// The `ns:util` builtin module. See docs/ns-builtin-modules.md for the +// cross-runtime contract. `inspect` is created once per realm by +// Console::InitInspect and handed in here, so the module and console share one +// formatter instance. + +const { inspect } = binding; +const { + JSONStringify, + Number, + NumberParseFloat, + NumberParseInt, + ObjectFreeze, + ObjectIs, + String, + StringPrototypeCharCodeAt, + StringPrototypeSlice, +} = primordials; + +const CHAR_PERCENT = 37; +const CHAR_UPPERCASE_O = 79; +const CHAR_LOWERCASE_D = 100; +const CHAR_LOWERCASE_F = 102; +const CHAR_LOWERCASE_I = 105; +const CHAR_LOWERCASE_J = 106; +const CHAR_LOWERCASE_O = 111; +const CHAR_LOWERCASE_S = 115; + +function formatNumber(value) { + return ObjectIs(value, -0) ? "-0" : String(value); +} + +// %d and %i keep a bigint's "n" suffix (%f, which parses a float, does not). +// Symbols refuse numeric conversion, so they render as NaN rather than throw. +function formatInteger(value, toNumber) { + if (typeof value === "symbol") { + return "NaN"; + } + if (typeof value === "bigint") { + return String(value) + "n"; + } + return formatNumber(toNumber(value)); +} + +function stringify(value) { + try { + return JSONStringify(value); + } catch (ignored) { + // Circular structures are the common case; anything else JSON refuses + // (a throwing toJSON) is not worth taking the log call down for. + return "[Circular]"; + } +} + +// A value spliced in for %s: strings go in raw, everything else through +// inspect (which already honors custom toString overrides). +function toDisplayString(value) { + return typeof value === "string" ? value : inspect(value); +} + +function format(...args) { + const first = args[0]; + let str = ""; + let a = 0; + let join = ""; + + if (typeof first === "string") { + if (args.length === 1) { + return first; + } + let lastPos = 0; + // A trailing "%" cannot start a substitution, hence length - 1. + for (let i = 0; i < first.length - 1; i++) { + if (StringPrototypeCharCodeAt(first, i) !== CHAR_PERCENT) { + continue; + } + const nextChar = StringPrototypeCharCodeAt(first, ++i); + if (a + 1 === args.length) { + // Out of arguments: only %% still means something. + if (nextChar === CHAR_PERCENT) { + str += StringPrototypeSlice(first, lastPos, i); + lastPos = i + 1; + } + continue; + } + let replacement; + switch (nextChar) { + case CHAR_LOWERCASE_S: + replacement = toDisplayString(args[++a]); + break; + case CHAR_LOWERCASE_D: { + const value = args[++a]; + replacement = formatInteger(value, Number); + break; + } + case CHAR_LOWERCASE_I: { + const value = args[++a]; + replacement = formatInteger(value, NumberParseInt); + break; + } + case CHAR_LOWERCASE_F: { + const value = args[++a]; + replacement = typeof value === "symbol" ? "NaN" : formatNumber(NumberParseFloat(value)); + break; + } + case CHAR_LOWERCASE_J: + replacement = stringify(args[++a]); + break; + case CHAR_LOWERCASE_O: + case CHAR_UPPERCASE_O: + replacement = inspect(args[++a]); + break; + case CHAR_PERCENT: + str += StringPrototypeSlice(first, lastPos, i); + lastPos = i + 1; + continue; + default: + // Not a placeholder: "100%" and friends survive verbatim. + continue; + } + if (lastPos !== i - 1) { + str += StringPrototypeSlice(first, lastPos, i - 1); + } + str += replacement; + lastPos = i + 1; + } + if (lastPos !== 0) { + // The format string consumed argument 0 and set the separator for the + // extras that follow it. + a++; + join = " "; + if (lastPos < first.length) { + str += StringPrototypeSlice(first, lastPos); + } + } + } + + // Leftovers (or every argument, when there was no format string) are + // appended space-separated. + while (a < args.length) { + const value = args[a]; + str += join + toDisplayString(value); + join = " "; + a++; + } + + return str; +} + +module.exports = ObjectFreeze({ inspect, format }); diff --git a/NativeScript/runtime/js/primordials.js b/NativeScript/runtime/js/primordials.js index e8db4f68..368d2a26 100644 --- a/NativeScript/runtime/js/primordials.js +++ b/NativeScript/runtime/js/primordials.js @@ -23,6 +23,7 @@ const intrinsics = { // Constructors and well-known symbols. Error, Map, + Number, Proxy, Set, String, @@ -36,6 +37,8 @@ const intrinsics = { ArrayBufferIsView: ArrayBuffer.isView, ArrayIsArray: Array.isArray, JSONStringify: JSON.stringify, + NumberParseFloat: Number.parseFloat, + NumberParseInt: Number.parseInt, ObjectAssign: Object.assign, ObjectCreate: Object.create, ObjectDefineProperty: Object.defineProperty, @@ -73,6 +76,7 @@ const intrinsics = { SetPrototypeDelete: uncurryThis(Set.prototype.delete), SetPrototypeHas: uncurryThis(Set.prototype.has), SetPrototypeValues: uncurryThis(Set.prototype.values), + StringPrototypeCharCodeAt: uncurryThis(String.prototype.charCodeAt), StringPrototypeIndexOf: uncurryThis(String.prototype.indexOf), StringPrototypeSlice: uncurryThis(String.prototype.slice), StringPrototypeToLowerCase: uncurryThis(String.prototype.toLowerCase), diff --git a/TestRunner/app/tests/NsUtilTests.js b/TestRunner/app/tests/NsUtilTests.js new file mode 100644 index 00000000..3096d14f --- /dev/null +++ b/TestRunner/app/tests/NsUtilTests.js @@ -0,0 +1,176 @@ +describe("ns:util", function () { + var util = require("ns:util"); + + it("exports a frozen inspect/format pair", function () { + expect(Object.isFrozen(util)).toBe(true); + expect(typeof util.inspect).toBe("function"); + expect(typeof util.format).toBe("function"); + expect(util.inspect({ a: 1 })).toBe("{ a: 1 }"); + }); + + it("is a singleton per realm", function () { + expect(require("ns:util")).toBe(util); + }); + + it("throws for an unknown builtin", function () { + expect(function () { + require("ns:definitely-not-a-module"); + }).toThrow(); + + var error; + try { + require("ns:definitely-not-a-module"); + } catch (e) { + error = e; + } + expect(error instanceof Error).toBe(true); + expect(error.message).toBe("No such built-in module: ns:definitely-not-a-module"); + }); + + it("leaves bare specifiers to npm resolution", function () { + var resolved; + try { + resolved = require("util"); + } catch (e) { + resolved = undefined; + } + expect(resolved).not.toBe(util); + expect(resolved && resolved.format).not.toBe(util.format); + }); + + describe("format", function () { + var format = util.format; + + it("substitutes %s, %d, %i and %f", function () { + expect(format("%s", "a")).toBe("a"); + expect(format("%s", 3)).toBe("3"); + expect(format("%s", { a: 1 })).toBe("{ a: 1 }"); + expect(format("%d apples", 3)).toBe("3 apples"); + expect(format("%d", "4.7")).toBe("4.7"); + expect(format("%i", "4.7")).toBe("4"); + expect(format("%f", "4.7")).toBe("4.7"); + }); + + it("substitutes %j, %o and %O", function () { + expect(format("%j", { a: 1 })).toBe('{"a":1}'); + expect(format("%o", { a: 1 })).toBe("{ a: 1 }"); + expect(format("%O", { a: 1 })).toBe("{ a: 1 }"); + }); + + it("renders a circular value for %j as [Circular]", function () { + var circular = {}; + circular.self = circular; + expect(format("%j", circular)).toBe("[Circular]"); + }); + + it("collapses %% and leaves unknown or dangling % verbatim", function () { + expect(format("100%")).toBe("100%"); + expect(format("100%", "x")).toBe("100% x"); + expect(format("%% done", 1)).toBe("% done 1"); + expect(format("a %x b", 1)).toBe("a %x b 1"); + expect(format("trailing %", 1)).toBe("trailing % 1"); + expect(format("%s %s", "a")).toBe("a %s"); + }); + + it("appends extra arguments space-separated", function () { + expect(format("%s", "a", "b", { c: 1 })).toBe("a b { c: 1 }"); + }); + + it("joins every argument when the first is not a format string", function () { + expect(format({ a: 1 }, "x")).toBe("{ a: 1 } x"); + expect(format(1, 2)).toBe("1 2"); + expect(format()).toBe(""); + }); + }); + + it("backs console.* formatting", function () { + expect(function () { + console.log("%d apples", 3); + console.log("100%"); + }).not.toThrow(); + }); + + it("resolves through dynamic import with the same members", function (done) { + import("ns:util").then(function (ns) { + expect(ns.default).toBe(util); + expect(ns.inspect).toBe(util.inspect); + expect(ns.format).toBe(util.format); + // The synthetic module is cached per realm. + return import("ns:util").then(function (again) { + expect(again).toBe(ns); + done(); + }); + }, function (error) { + fail("import('ns:util') should resolve: " + error); + done(); + }); + }); + + it("rejects a dynamic import of an unknown builtin", function (done) { + import("ns:definitely-not-a-module").then(function () { + fail("import of an unknown builtin should reject"); + done(); + }, function (error) { + expect(error instanceof Error).toBe(true); + expect(error.message).toBe("No such built-in module: ns:definitely-not-a-module"); + done(); + }); + }); + + it("resolves through a static import", function (done) { + import("~/tests/esm/ns-util-import.mjs").then(function (module) { + expect(module.nsDefault).toBe(util); + expect(module.inspect).toBe(util.inspect); + expect(module.format).toBe(util.format); + expect(module.nodeDefault).toBe(require("node:util")); + expect(module.nodeInspect).toBe(util.inspect); + done(); + }, function (error) { + fail("static import of ns:util should resolve: " + error); + done(); + }); + }); +}); + +describe("node:util", function () { + var util = require("ns:util"); + var nodeUtil = require("node:util"); + + it("is a distinct module object sharing ns:util's members", function () { + expect(nodeUtil).not.toBe(util); + expect(Object.isFrozen(nodeUtil)).toBe(true); + expect(nodeUtil.inspect).toBe(util.inspect); + expect(nodeUtil.format).toBe(util.format); + }); + + it("is a singleton per realm", function () { + expect(require("node:util")).toBe(nodeUtil); + }); + + it("only exposes what the shim implements", function () { + expect(typeof nodeUtil.promisify).toBe("undefined"); + }); + + it("throws for a node builtin with no shim", function () { + var error; + try { + require("node:fs"); + } catch (e) { + error = e; + } + expect(error instanceof Error).toBe(true); + expect(error.message).toBe("No such built-in module: node:fs"); + }); + + it("resolves through dynamic import with the same members", function (done) { + import("node:util").then(function (ns) { + expect(ns.default).toBe(nodeUtil); + expect(ns.inspect).toBe(util.inspect); + expect(ns.format).toBe(util.format); + done(); + }, function (error) { + fail("import('node:util') should resolve: " + error); + done(); + }); + }); +}); diff --git a/TestRunner/app/tests/esm/ns-util-import.mjs b/TestRunner/app/tests/esm/ns-util-import.mjs new file mode 100644 index 00000000..00cb7b01 --- /dev/null +++ b/TestRunner/app/tests/esm/ns-util-import.mjs @@ -0,0 +1,6 @@ +// Static `import` of the builtins, so the ES module resolve callback is +// exercised too (dynamic import() has its own fast path). +import nsDefault, { inspect, format } from "ns:util"; +import nodeDefault, { inspect as nodeInspect } from "node:util"; + +export { nsDefault, inspect, format, nodeDefault, nodeInspect }; diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 5ad49d89..bc58e816 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -164,6 +164,9 @@ require("./EscapeExceptionTests"); require("./PrimordialsTests"); require("./InspectTests"); +// The ns:/node: builtin modules +require("./NsUtilTests"); + // Tests common for all runtimes (git submodule of NativeScript/common-runtime-tests-app). require("../shared/index").runAllTests(); diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md new file mode 100644 index 00000000..279c66df --- /dev/null +++ b/docs/ns-builtin-modules.md @@ -0,0 +1,139 @@ +# `ns:` builtin modules + +Cross-runtime contract for exposing runtime-provided modules to application +code. This document is the specification both the iOS and Android runtimes +implement; a capability must behave identically on both platforms before it +ships in a stable release. + +## The scheme + +Builtin modules live under the URL-style `ns:` scheme, mirroring Node's +`node:` prefix: + +```js +// CommonJS +const util = require("ns:util"); + +// ES modules +import util, { inspect } from "ns:util"; +const util2 = await import("ns:util"); +``` + +Rules: + +- `ns:` specifiers are resolved by the runtime **before any filesystem or + npm resolution**. They can never be shadowed by a file, a path mapping, or + a package — and conversely, a file named `ns:util` is not reachable. +- Resolution of an unknown builtin fails synchronously with an `Error` whose + message is exactly `No such built-in module: ns:` (matching Node's + wording for familiarity). +- A builtin module is a **singleton per JS realm** (main context and each + worker get their own instance). `require("ns:util")` twice returns the same + object; the CJS exports object and the ESM namespace expose the same + underlying values (ESM additionally provides the exports object as + `default`). +- There is no bare-specifier fallback: `require("util")` is **not** an alias + for `require("ns:util")`. The unprefixed name continues to resolve to npm + packages as it always has. +- Builtin exports are frozen. Apps patch behavior by wrapping, not by + mutating the runtime's module. + +## Modules + +### `ns:util` (v1) + +| export | description | +|---|---| +| `inspect(value[, options])` | Formats any value for human consumption: depth-limited, output-capped, cycle-safe, never invokes getters (except a guarded `error.stack` read and custom `toString` overrides, which are honored). `options.depth` (number) overrides the default depth of 2. Other option keys are reserved. | +| `format(fmt, ...args)` | Node-style printf formatting: `%s`, `%d`, `%i`, `%f`, `%j`, `%o`, `%O`, `%%`. Extra arguments are appended space-separated, objects rendered via `inspect`. When `fmt` is not a string or contains no substitutions, all arguments are formatted and joined with spaces. `console.*` routes its arguments through this, so `console.log("%d apples", 3)` works. | + +**Stability caveat (verbatim from Node's contract):** the output of `inspect` +(and therefore `format`'s object rendering) may change between runtime +versions for readability; it is intended for humans and must not be parsed +programmatically. + +## `node:` compatibility shims + +The same registry serves the `node:` scheme with **compatibility shims** so +npm packages that require Node builtins by their prefixed names can run +unmodified where a shim exists: + +- A shim implements a documented **subset** of the corresponding Node module's + API, backed by `ns:` modules. Unimplemented members are simply absent + (so `typeof util.promisify === "function"` feature-checks behave + correctly); they are never present-but-throwing. +- **One source file per specifier.** A shim is its own module that consumes + the `ns:` module it adapts through the internal require, and it owns *all* + the adaptation — argument shapes, option names, aliases, anything that has + to track Node. A standard `ns:` module never contains compatibility code + and never knows a shim exists. +- Shims are **lazy**: a shim's source is only evaluated when its specifier is + first resolved, so an app that never touches the `node:` scheme never pays + for one. +- `node:` modules with no shim fail with the same error shape: + `No such built-in module: node:`. +- **Bare specifiers are untouched**: `require("util")` resolves through npm + as it always has (many apps bundle the `util` polyfill package). Only the + explicit `node:` prefix reaches the shim registry, so existing apps cannot + break. Bundler-level aliases (webpack/rollup) continue to work and take + precedence at build time. +- A shim is always a **distinct module object** from any `ns:` module, even + when every member is re-exported unchanged. `ns:` modules may grow runtime-specific + members freely; a `node:` shim only ever gains members that track Node's + actual API. This mirrors how Bun (`bun:*`), Deno (`Deno.*`/JSR) and + Cloudflare (`cloudflare:*`) all keep their own surface strictly apart from + their `node:` compat layer. +- Shims ship on both runtimes under the same parity rule as `ns:` modules. + +### v1 shims + +| module | exports | notes | +|---|---|---| +| `node:util` | `inspect`, `format` | Re-exports `ns:util`'s members unchanged (`nodeUtil.inspect === nsUtil.inspect`) from a **distinct, separately frozen module object**. Documented as partial. | + +Candidates for future shims, in rough order of ecosystem demand: +`node:events` (EventEmitter), `node:path` (pure JS), `node:buffer`, +`node:process` (subset). Each requires a spec update here first. + +## The internal require + +Builtin modules reach each other — and only each other — through an internal +`require` the runtime provides to every builtin source: + +- It resolves **builtin specifiers only**. A path, a package name or any other + specifier is not reachable from a builtin; an unregistered builtin name + throws the same `No such built-in module: ` an app sees. +- It materializes the target module on first use and returns the realm's + singleton afterwards, which is what makes shims lazy. +- Requiring a module that is still being built throws rather than recursing, + so a dependency cycle between builtins is a loud error and not a hang. + +This is the mechanism shims are built on, so it is normative: both runtimes +provide it. + +## Adding a builtin module + +- The name is a short, lowercase identifier (`ns:util`, `ns:timers`, ...). +- One module, one source file, one registry entry — including shims. +- New modules and new exports require this document to be updated first and + an implementation on both runtimes before a stable release; a module may + ship on one platform behind a documented "experimental, iOS-only" (or + Android-only) note in between. +- Internal runtime machinery must never be reachable through the scheme: + the registry distinguishes public modules from internal builtins, and only + public ones resolve (Node's `canBeRequiredByUsers` split). + +## iOS implementation notes (non-normative) + +Builtin modules are function-body builtins (`NativeScript/runtime/js/`, +see the README there) compiled via the RuntimeBuiltins table. The `ns:` +resolver intercepts specifiers in the CommonJS require path and in the ES +module resolve/dynamic-import callbacks; ESM consumption is served by a +synthetic module whose exports are populated from the same per-realm exports +object. The internal require is a fixed parameter of the builtin function +wrapper (`exports`, `require`, `module`, `binding`, `primordials`). + +iOS also keeps a pre-registry `node:url` polyfill (`fileURLToPath`, +`pathToFileURL`) that predates this document. It is compiled from module +source inside the ES module resolver and is therefore reachable through +`import` only, not through `require()`. diff --git a/eslint.config.mjs b/eslint.config.mjs index b9715242..5eb59bfa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,8 @@ const capturedStatics = [ ['Array', 'isArray', 'ArrayIsArray'], ['ArrayBuffer', 'isView', 'ArrayBufferIsView'], ['JSON', 'stringify', 'JSONStringify'], + ['Number', 'parseFloat', 'NumberParseFloat'], + ['Number', 'parseInt', 'NumberParseInt'], ['Object', 'assign', 'ObjectAssign'], ['Object', 'create', 'ObjectCreate'], ['Object', 'defineProperty', 'ObjectDefineProperty'], @@ -38,7 +40,7 @@ const capturedStatics = [ // fills in after init). Array.from has no primordial: copying `arguments` goes // through an index loop instead, because Array.from depends on the tamperable // array iterator protocol. -const restrictedGlobals = ['Error', 'Map', 'Proxy', 'Set', 'String', 'TypeError'].map((name) => ({ +const restrictedGlobals = ['Error', 'Map', 'Number', 'Proxy', 'Set', 'String', 'TypeError'].map((name) => ({ name, message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`, })); @@ -60,6 +62,7 @@ export default [ globals: { ...globals.es2021, exports: 'readonly', + require: 'readonly', module: 'readonly', binding: 'readonly', primordials: 'readonly', diff --git a/tools/js2c-inputs.xcfilelist b/tools/js2c-inputs.xcfilelist index 5a6c9072..c52b9428 100644 --- a/tools/js2c-inputs.xcfilelist +++ b/tools/js2c-inputs.xcfilelist @@ -6,6 +6,8 @@ $(SRCROOT)/NativeScript/runtime/js/events.js $(SRCROOT)/NativeScript/runtime/js/inline-functions.js $(SRCROOT)/NativeScript/runtime/js/primordials.js $(SRCROOT)/NativeScript/runtime/js/inspect.js +$(SRCROOT)/NativeScript/runtime/js/node-util.js +$(SRCROOT)/NativeScript/runtime/js/ns-util.js $(SRCROOT)/NativeScript/runtime/js/promise-proxy.js $(SRCROOT)/NativeScript/runtime/js/require-factory.js $(SRCROOT)/NativeScript/runtime/js/ts-helpers.js diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index ca428152..67b56f0a 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -293,6 +293,7 @@ C2DDEB92229EAC8300345BFE /* WeakRef.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB69229EAC8100345BFE /* WeakRef.cpp */; }; 4A5C201A2E2B000100000006 /* BuiltinLoader.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000100000001 /* BuiltinLoader.cpp */; }; 4A5C201A2E2B000100000007 /* RuntimeBuiltins.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000100000003 /* RuntimeBuiltins.cpp */; }; + 4A5C201A2E2B000200000006 /* NsBuiltinModules.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000200000001 /* NsBuiltinModules.cpp */; }; C2DDEB93229EAC8300345BFE /* ArgConverter.mm in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6A229EAC8200345BFE /* ArgConverter.mm */; }; C2DDEB94229EAC8300345BFE /* Console.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6B229EAC8200345BFE /* Console.cpp */; }; C2DDEB95229EAC8300345BFE /* SetTimeout.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6C229EAC8200345BFE /* SetTimeout.cpp */; }; @@ -809,6 +810,8 @@ C2DDEB69229EAC8100345BFE /* WeakRef.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = WeakRef.cpp; sourceTree = ""; }; 4A5C201A2E2B000100000001 /* BuiltinLoader.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = BuiltinLoader.cpp; sourceTree = ""; }; 4A5C201A2E2B000100000002 /* BuiltinLoader.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BuiltinLoader.h; sourceTree = ""; }; + 4A5C201A2E2B000200000001 /* NsBuiltinModules.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = NsBuiltinModules.cpp; sourceTree = ""; }; + 4A5C201A2E2B000200000002 /* NsBuiltinModules.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NsBuiltinModules.h; sourceTree = ""; }; 4A5C201A2E2B000100000003 /* RuntimeBuiltins.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; name = RuntimeBuiltins.cpp; path = generated/RuntimeBuiltins.cpp; sourceTree = ""; }; 4A5C201A2E2B000100000004 /* RuntimeBuiltins.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RuntimeBuiltins.h; path = generated/RuntimeBuiltins.h; sourceTree = ""; }; 4A5C201A2E2B000100000005 /* js */ = {isa = PBXFileReference; lastKnownFileType = folder; path = js; sourceTree = ""; }; @@ -1526,6 +1529,8 @@ C2DDEB69229EAC8100345BFE /* WeakRef.cpp */, 4A5C201A2E2B000100000002 /* BuiltinLoader.h */, 4A5C201A2E2B000100000001 /* BuiltinLoader.cpp */, + 4A5C201A2E2B000200000002 /* NsBuiltinModules.h */, + 4A5C201A2E2B000200000001 /* NsBuiltinModules.cpp */, 4A5C201A2E2B000100000004 /* RuntimeBuiltins.h */, 4A5C201A2E2B000100000003 /* RuntimeBuiltins.cpp */, 4A5C201A2E2B000100000005 /* js */, @@ -2282,6 +2287,7 @@ 6573B9CE291FE29F00B0ED7C /* JSIV8ValueConverter.cpp in Sources */, C2DDEB92229EAC8300345BFE /* WeakRef.cpp in Sources */, 4A5C201A2E2B000100000006 /* BuiltinLoader.cpp in Sources */, + 4A5C201A2E2B000200000006 /* NsBuiltinModules.cpp in Sources */, 4A5C201A2E2B000100000007 /* RuntimeBuiltins.cpp in Sources */, C2A5F86A2359AEB600074AFA /* ExtVector.cpp in Sources */, AA8C47B22E27114300649BF5 /* ModuleInternalCallbacks.mm in Sources */, From dd7115d8613bacfa3ae55841136fd3a2f7a7ff79 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 30 Jul 2026 14:44:20 -0300 Subject: [PATCH 2/2] docs: record the no-source-text-modules decision; js2c rejects non-.js inputs Builtins stay classic function bodies on both runtimes; cross-builtin sharing, if ever needed, starts with generation-time bundling. The js2c guard turns an accidental .mjs into a build error pointing at the spec. --- docs/ns-builtin-modules.md | 9 +++++++++ tools/js2c.mjs | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index 279c66df..de6634c7 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -123,6 +123,15 @@ provide it. the registry distinguishes public modules from internal builtins, and only public ones resolve (Node's `canBeRequiredByUsers` split). +## Source-text modules: deliberately not supported + +Builtins are classic function bodies, not ES modules, on both runtimes. If +cross-builtin code sharing is ever needed, the first answer is bundling at +generation time (author as ESM, emit function bodies); runtime source-text +builtin modules (Node's `kSourceTextModule`) are justified only by a concrete +need for live module semantics (TLA, live bindings, cyclic imports), which no +current or planned builtin has. Revisit here before building either. + ## iOS implementation notes (non-normative) Builtin modules are function-body builtins (`NativeScript/runtime/js/`, diff --git a/tools/js2c.mjs b/tools/js2c.mjs index fbb0dde7..cbe7964f 100644 --- a/tools/js2c.mjs +++ b/tools/js2c.mjs @@ -77,6 +77,11 @@ for (const file of inputs) { console.error(`error: no such file: ${file}`); process.exit(1); } + if (!file.endsWith('.js')) { + console.error(`error: builtins must be .js function bodies, got: ${file}`); + console.error('(.mjs/source-text modules are deliberately unsupported — see docs/ns-builtin-modules.md)'); + process.exit(1); + } const stem = basename(file).replace(/\.js$/, ''); if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(stem)) { console.error(`error: file name must be kebab-case .js: ${file}`);