From ffb9f172ce653a3efac4772a2ab4f444ff355a4f Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 23 Oct 2026 14:37:33 -0300 Subject: [PATCH 1/3] feat(runtime): ns:interop builtin modules, global interop as compat ns:interop carries the cross-platform native memory and value API (Pointer, Reference, FunctionReference, sizeof, alloc, free, handleof, stringFromCString, the C primitive types); ns:interop/ios carries adopt, bufferFromData and the Objective-C types, with ns:interop/visionos and ns:interop/macos as aliases canonicalized before every cache, so each spelling yields the same exports object and the same ES module. ns:runtime gains escapeException, setExternalSize and getExternalSize. The global interop becomes a lazy global (interop-global.js) assembled from the three modules through the builtin require: same key set, every member and all twenty types identical to the module exports, still mutable and enumerable. Workers get it through the same global template instead of a second registration. The primitive type objects are created natively on first use, by the module bindings or by native marshalling (GetInteropType), so pointer and C-string results keep their types even when no app code reads interop. inline-functions.js takes the void type from ns:interop when a decorator runs rather than from the global. --- NativeScript/runtime/Interop.h | 27 ++- NativeScript/runtime/InteropTypes.mm | 226 +++++++++--------- NativeScript/runtime/LazyGlobals.cpp | 6 +- NativeScript/runtime/NsBuiltinModules.cpp | 35 ++- NativeScript/runtime/Runtime.mm | 7 - NativeScript/runtime/js/README.md | 10 +- NativeScript/runtime/js/inline-functions.js | 10 +- NativeScript/runtime/js/interop-global.js | 51 ++++ NativeScript/runtime/js/ns-interop-ios.js | 13 + NativeScript/runtime/js/ns-interop.js | 28 +++ NativeScript/runtime/js/ns-runtime.js | 22 +- TestRunner/app/tests/NsInteropTests.js | 204 ++++++++++++++++ TestRunner/app/tests/NsRuntimeTests.js | 8 +- .../app/tests/esm/ns-interop-import.mjs | 9 + TestRunner/app/tests/index.js | 1 + .../app/tests/nsInteropIdentityWorker.js | 38 +++ eslint.config.mjs | 1 - tools/js2c-inputs.xcfilelist | 3 + 18 files changed, 557 insertions(+), 142 deletions(-) create mode 100644 NativeScript/runtime/js/interop-global.js create mode 100644 NativeScript/runtime/js/ns-interop-ios.js create mode 100644 NativeScript/runtime/js/ns-interop.js create mode 100644 TestRunner/app/tests/NsInteropTests.js create mode 100644 TestRunner/app/tests/esm/ns-interop-import.mjs create mode 100644 TestRunner/app/tests/nsInteropIdentityWorker.js diff --git a/NativeScript/runtime/Interop.h b/NativeScript/runtime/Interop.h index f514f0103..085bb416f 100644 --- a/NativeScript/runtime/Interop.h +++ b/NativeScript/runtime/Interop.h @@ -76,7 +76,17 @@ struct ObjCMethodCall : public MethodCall { class Interop { public: - static void RegisterInteropTypes(v8::Local context); + // The natives behind ns:interop and ns:interop/ios (runtime/js/ns-interop.js, + // ns-interop-ios.js), built when the module is first resolved. + static v8::MaybeLocal NsInteropBinding( + v8::Local context); + static v8::MaybeLocal NsInteropIosBinding( + v8::Local context); + // ns:runtime members; `target` is that module's binding. + static void RegisterExternalSizeFunctions(v8::Local context, + v8::Local target); + static void RegisterEscapeExceptionFunction(v8::Local context, + v8::Local target); static IMP CreateMethod(const uint8_t initialParamIndex, const uint8_t argsCount, const TypeEncoding* typeEncoding, @@ -135,10 +145,13 @@ class Interop { const std::vector& fields, v8::Local inititalizer, ptrdiff_t& position); - static void RegisterInteropType(v8::Local context, - v8::Local types, std::string name, - PrimitiveDataWrapper* wrapper, - bool autoDelete = true); + static bool GetPrimitiveTypes(v8::Local context, + v8::Local* cTypes, + v8::Local* appleTypes); + static v8::Local CreateInteropType(v8::Local context, + const std::string& name, + PrimitiveDataWrapper* wrapper, + bool autoDelete = true); static void RegisterBufferFromDataFunction(v8::Local context, v8::Local interop); static void RegisterStringFromCString(v8::Local context, @@ -151,12 +164,8 @@ class Interop { v8::Local interop); static void RegisterAdoptFunction(v8::Local context, v8::Local interop); - static void RegisterExternalSizeFunctions(v8::Local context, - v8::Local interop); static void RegisterSizeOfFunction(v8::Local context, v8::Local interop); - static void RegisterEscapeExceptionFunction(v8::Local context, - v8::Local interop); static void SetFFIParams(v8::Local context, const TypeEncoding* typeEncoding, FFICall* call, const int argsCount, const int initialParameterIndex, diff --git a/NativeScript/runtime/InteropTypes.mm b/NativeScript/runtime/InteropTypes.mm index 568d90766..f226036e3 100644 --- a/NativeScript/runtime/InteropTypes.mm +++ b/NativeScript/runtime/InteropTypes.mm @@ -16,117 +16,126 @@ namespace tns { -void Interop::RegisterInteropTypes(Local context) { +namespace { + +// One set of `types` objects per isolate. Native marshalling reads them too +// (GetInteropType), so they exist whether or not app code loads ns:interop. +struct InteropTypesState { + Global cTypes; + Global appleTypes; +}; + +} // namespace + +bool Interop::GetPrimitiveTypes(Local context, Local* cTypes, + Local* appleTypes) { Isolate* isolate = v8::Isolate::GetCurrent(); - Local global = context->Global(); - - Local interop = Object::New(isolate); - Local types = Object::New(isolate); - - Reference::Register(context, interop); - Pointer::Register(context, interop); - FunctionReference::Register(context, interop); - RegisterBufferFromDataFunction(context, interop); - RegisterStringFromCString(context, interop); - RegisterHandleOfFunction(context, interop); - RegisterAllocFunction(context, interop); - RegisterFreeFunction(context, interop); - RegisterAdoptFunction(context, interop); - RegisterExternalSizeFunctions(context, interop); - RegisterSizeOfFunction(context, interop); - RegisterEscapeExceptionFunction(context, interop); - - RegisterInteropType( - context, types, "noop", - new PrimitiveDataWrapper(ffi_type_pointer.size, - CreateEncoding(BinaryTypeEncodingType::VoidEncoding), true)); - RegisterInteropType( - context, types, "void", - new PrimitiveDataWrapper(0, CreateEncoding(BinaryTypeEncodingType::VoidEncoding), true)); - RegisterInteropType( - context, types, "bool", - new PrimitiveDataWrapper(sizeof(bool), CreateEncoding(BinaryTypeEncodingType::BoolEncoding), - true)); - RegisterInteropType( - context, types, "uint8", - new PrimitiveDataWrapper(ffi_type_uint8.size, - CreateEncoding(BinaryTypeEncodingType::UCharEncoding), true)); - RegisterInteropType( - context, types, "int8", - new PrimitiveDataWrapper(ffi_type_sint8.size, - CreateEncoding(BinaryTypeEncodingType::CharEncoding), true)); - RegisterInteropType( - context, types, "uint16", - new PrimitiveDataWrapper(ffi_type_uint16.size, - CreateEncoding(BinaryTypeEncodingType::UShortEncoding), true)); - RegisterInteropType( - context, types, "int16", - new PrimitiveDataWrapper(ffi_type_sint16.size, - CreateEncoding(BinaryTypeEncodingType::ShortEncoding), true)); - RegisterInteropType( - context, types, "uint32", - new PrimitiveDataWrapper(ffi_type_uint32.size, - CreateEncoding(BinaryTypeEncodingType::UIntEncoding), true)); - RegisterInteropType( - context, types, "int32", - new PrimitiveDataWrapper(ffi_type_sint32.size, - CreateEncoding(BinaryTypeEncodingType::IntEncoding), true)); - RegisterInteropType( - context, types, "uint64", - new PrimitiveDataWrapper(ffi_type_uint64.size, - CreateEncoding(BinaryTypeEncodingType::ULongEncoding), true)); - RegisterInteropType( - context, types, "int64", - new PrimitiveDataWrapper(ffi_type_sint64.size, - CreateEncoding(BinaryTypeEncodingType::LongEncoding), true)); - RegisterInteropType( - context, types, "ulong", - new PrimitiveDataWrapper(ffi_type_ulong.size, - CreateEncoding(BinaryTypeEncodingType::ULongLongEncoding), true)); - RegisterInteropType( - context, types, "slong", - new PrimitiveDataWrapper(ffi_type_slong.size, - CreateEncoding(BinaryTypeEncodingType::LongLongEncoding), true)); - RegisterInteropType( - context, types, "float", - new PrimitiveDataWrapper(ffi_type_float.size, - CreateEncoding(BinaryTypeEncodingType::FloatEncoding), true)); - RegisterInteropType( - context, types, "double", - new PrimitiveDataWrapper(ffi_type_double.size, - CreateEncoding(BinaryTypeEncodingType::DoubleEncoding), true)); - - RegisterInteropType(context, types, "id", - new PrimitiveDataWrapper( - sizeof(void*), CreateEncoding(BinaryTypeEncodingType::IdEncoding), true)); - // RegisterInteropType(context, types, "UTF8CString", new PrimitiveDataWrapper(sizeof(void*), - // CreateEncoding(BinaryTypeEncodingType::VoidEncoding))); - RegisterInteropType( - context, types, "unichar", - new PrimitiveDataWrapper(ffi_type_ushort.size, - CreateEncoding(BinaryTypeEncodingType::UnicharEncoding), true)); - RegisterInteropType( - context, types, "protocol", - new PrimitiveDataWrapper(sizeof(void*), - CreateEncoding(BinaryTypeEncodingType::ProtocolEncoding), true)); - RegisterInteropType( - context, types, "class", - new PrimitiveDataWrapper(sizeof(void*), CreateEncoding(BinaryTypeEncodingType::ClassEncoding), - true)); - RegisterInteropType( - context, types, "selector", - new PrimitiveDataWrapper(sizeof(void*), - CreateEncoding(BinaryTypeEncodingType::SelectorEncoding), true)); - - bool success = interop->Set(context, tns::ToV8String(isolate, "types"), types).FromMaybe(false); - tns::Assert(success, isolate); + auto* state = Caches::StateFor(isolate); + if (state == nullptr) { + return false; + } - success = global->Set(context, tns::ToV8String(isolate, "interop"), interop).FromMaybe(false); - tns::Assert(success, isolate); + if (state->cTypes.IsEmpty()) { + struct TypeRow { + const char* name; + size_t size; + BinaryTypeEncodingType encoding; + bool apple; + }; + // Where names share an encoding (noop and void), the first row is the one + // GetInteropType hands out for it. + const TypeRow rows[] = { + {"noop", ffi_type_pointer.size, BinaryTypeEncodingType::VoidEncoding, true}, + {"void", 0, BinaryTypeEncodingType::VoidEncoding, false}, + {"bool", sizeof(bool), BinaryTypeEncodingType::BoolEncoding, false}, + {"uint8", ffi_type_uint8.size, BinaryTypeEncodingType::UCharEncoding, false}, + {"int8", ffi_type_sint8.size, BinaryTypeEncodingType::CharEncoding, false}, + {"uint16", ffi_type_uint16.size, BinaryTypeEncodingType::UShortEncoding, false}, + {"int16", ffi_type_sint16.size, BinaryTypeEncodingType::ShortEncoding, false}, + {"uint32", ffi_type_uint32.size, BinaryTypeEncodingType::UIntEncoding, false}, + {"int32", ffi_type_sint32.size, BinaryTypeEncodingType::IntEncoding, false}, + {"uint64", ffi_type_uint64.size, BinaryTypeEncodingType::ULongEncoding, false}, + {"int64", ffi_type_sint64.size, BinaryTypeEncodingType::LongEncoding, false}, + {"ulong", ffi_type_ulong.size, BinaryTypeEncodingType::ULongLongEncoding, false}, + {"slong", ffi_type_slong.size, BinaryTypeEncodingType::LongLongEncoding, false}, + {"float", ffi_type_float.size, BinaryTypeEncodingType::FloatEncoding, false}, + {"double", ffi_type_double.size, BinaryTypeEncodingType::DoubleEncoding, false}, + {"id", sizeof(void*), BinaryTypeEncodingType::IdEncoding, true}, + {"unichar", ffi_type_ushort.size, BinaryTypeEncodingType::UnicharEncoding, true}, + {"protocol", sizeof(void*), BinaryTypeEncodingType::ProtocolEncoding, true}, + {"class", sizeof(void*), BinaryTypeEncodingType::ClassEncoding, true}, + {"selector", sizeof(void*), BinaryTypeEncodingType::SelectorEncoding, true}, + }; + + Local c = Object::New(isolate); + Local apple = Object::New(isolate); + for (const TypeRow& row : rows) { + Local type = + CreateInteropType(context, row.name, + new PrimitiveDataWrapper(row.size, CreateEncoding(row.encoding), true)); + if (!(row.apple ? apple : c) + ->Set(context, tns::ToV8String(isolate, row.name), type) + .FromMaybe(false)) { + return false; + } + } + state->cTypes.Reset(isolate, c); + state->appleTypes.Reset(isolate, apple); + } + + *cTypes = state->cTypes.Get(isolate); + *appleTypes = state->appleTypes.Get(isolate); + return true; +} + +MaybeLocal Interop::NsInteropBinding(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local cTypes; + Local appleTypes; + if (!GetPrimitiveTypes(context, &cTypes, &appleTypes)) { + return MaybeLocal(); + } + + Local binding = Object::New(isolate); + Pointer::Register(context, binding); + Reference::Register(context, binding); + FunctionReference::Register(context, binding); + RegisterSizeOfFunction(context, binding); + RegisterAllocFunction(context, binding); + RegisterFreeFunction(context, binding); + RegisterHandleOfFunction(context, binding); + RegisterStringFromCString(context, binding); + if (!binding->Set(context, tns::ToV8String(isolate, "types"), cTypes).FromMaybe(false)) { + return MaybeLocal(); + } + return binding; +} + +MaybeLocal Interop::NsInteropIosBinding(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local cTypes; + Local appleTypes; + if (!GetPrimitiveTypes(context, &cTypes, &appleTypes)) { + return MaybeLocal(); + } + + Local binding = Object::New(isolate); + RegisterAdoptFunction(context, binding); + RegisterBufferFromDataFunction(context, binding); + if (!binding->Set(context, tns::ToV8String(isolate, "types"), appleTypes).FromMaybe(false)) { + return MaybeLocal(); + } + return binding; } Local Interop::GetInteropType(Local context, BinaryTypeEncodingType type) { Isolate* isolate = v8::Isolate::GetCurrent(); + Local cTypes; + Local appleTypes; + if (!GetPrimitiveTypes(context, &cTypes, &appleTypes)) { + return Local(); + } + std::shared_ptr cache = Caches::Get(isolate); auto it = cache->PrimitiveInteropTypes.find(type); if (it != cache->PrimitiveInteropTypes.end()) { @@ -136,8 +145,8 @@ new PrimitiveDataWrapper(sizeof(void*), return Local(); } -void Interop::RegisterInteropType(Local context, Local types, std::string name, - PrimitiveDataWrapper* wrapper, bool autoDelete) { +Local Interop::CreateInteropType(Local context, const std::string& name, + PrimitiveDataWrapper* wrapper, bool autoDelete) { Isolate* isolate = v8::Isolate::GetCurrent(); Local ctorFuncTemplate = FunctionTemplate::New(isolate, nullptr); ctorFuncTemplate->SetClassName(tns::ToV8String(isolate, name)); @@ -156,7 +165,6 @@ new PrimitiveDataWrapper(sizeof(void*), Local result = value.As(); tns::SetValue(isolate, result, wrapper); - bool success = types->Set(context, tns::ToV8String(isolate, name), result).FromMaybe(false); BinaryTypeEncodingType type = wrapper->TypeEncoding()->type; std::shared_ptr cache = Caches::Get(isolate); @@ -173,7 +181,7 @@ new PrimitiveDataWrapper(sizeof(void*), cache->registerCacheBoundObject(wrapper); } - tns::Assert(success, isolate); + return result; } void Interop::RegisterBufferFromDataFunction(Local context, Local interop) { diff --git a/NativeScript/runtime/LazyGlobals.cpp b/NativeScript/runtime/LazyGlobals.cpp index 81ac7dd94..e99e20fd9 100644 --- a/NativeScript/runtime/LazyGlobals.cpp +++ b/NativeScript/runtime/LazyGlobals.cpp @@ -22,6 +22,8 @@ struct LazyGlobalEntry { const char* name; const char* exportName; // key of `name` in the builtin's module.exports ExportsAccessor exports; + // WebIDL globals are non-enumerable; `interop` is not one and is enumerable. + PropertyAttribute attributes = PropertyAttribute::DontEnum; }; // Exports accessor for a builtin with no natives of its own; modules with a @@ -45,6 +47,8 @@ constexpr LazyGlobalEntry kLazyGlobals[] = { {"MessageChannel", "MessageChannel", messaging::GetMessageChannelExports}, {"BroadcastChannel", "BroadcastChannel", messaging::GetBroadcastChannelExports}, + {"interop", "interop", BuiltinExports, + PropertyAttribute::None}, }; void LazyGlobalGetter(Local property, @@ -80,7 +84,7 @@ void LazyGlobals::Init(Isolate* isolate, Local globalTemplate) { // assignment landing before the first read replaces the global as well. globalTemplate->SetLazyDataProperty(tns::ToV8String(isolate, entry.name), LazyGlobalGetter, data, - PropertyAttribute::DontEnum); + entry.attributes); } } diff --git a/NativeScript/runtime/NsBuiltinModules.cpp b/NativeScript/runtime/NsBuiltinModules.cpp index 711244407..902626c93 100644 --- a/NativeScript/runtime/NsBuiltinModules.cpp +++ b/NativeScript/runtime/NsBuiltinModules.cpp @@ -6,6 +6,7 @@ #include "Caches.h" #include "Console.h" #include "Helpers.h" +#include "Interop.h" #include "Messaging.h" #include "ModuleInternalCallbacks.h" #include "Runtime.h" @@ -48,6 +49,8 @@ struct Registration { // 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:interop", BuiltinId::kNsInterop, Interop::NsInteropBinding}, + {"ns:interop/ios", BuiltinId::kNsInteropIos, Interop::NsInteropIosBinding}, {"ns:module", BuiltinId::kNsModule, NsModuleBinding}, {"ns:runtime", BuiltinId::kNsRuntime, NsRuntimeBinding}, {"ns:url", BuiltinId::kNsUrl, NsUrlBinding}, @@ -69,6 +72,28 @@ constexpr Registration kRegistry[] = { {"internal/worker-events", BuiltinId::kWorkerEvents, nullptr, true}, }; +// Other spellings of a registered specifier. Every entry point resolves them +// to the canonical row before touching a cache, so each spelling yields the +// same exports object and the same ES module (one namespace object). +struct Alias { + const char* specifier; + const char* canonical; +}; + +constexpr Alias kAliases[] = { + {"ns:interop/macos", "ns:interop/ios"}, + {"ns:interop/visionos", "ns:interop/ios"}, +}; + +std::string Canonicalize(const std::string& specifier) { + for (const Alias& alias : kAliases) { + if (specifier == alias.specifier) { + return alias.canonical; + } + } + return specifier; +} + // ns:runtime config keys. Each key defines its value domain and scope here; // process-wide keys (see Helpers.h) affect every isolate, so writes are // restricted to the main isolate. Remote-module security @@ -202,6 +227,8 @@ MaybeLocal NsRuntimeBinding(Local context) { .FromMaybe(false)) { return MaybeLocal(); } + Interop::RegisterEscapeExceptionFunction(context, binding); + Interop::RegisterExternalSizeFunctions(context, binding); return binding; } @@ -361,7 +388,7 @@ bool NsBuiltinModules::IsNsScheme(const std::string& specifier) { bool NsBuiltinModules::IsRegistered(const std::string& specifier, bool includeInternal) { - const Registration* registration = Find(specifier); + const Registration* registration = Find(Canonicalize(specifier)); return registration != nullptr && (includeInternal || !registration->internalOnly); } @@ -371,7 +398,8 @@ std::string NsBuiltinModules::NotFoundMessage(const std::string& specifier) { } MaybeLocal NsBuiltinModules::GetExports(Local context, - const std::string& specifier) { + const std::string& requested) { + const std::string specifier = Canonicalize(requested); const Registration* registration = Find(specifier); if (registration == nullptr) { return MaybeLocal(); @@ -410,7 +438,8 @@ MaybeLocal NsBuiltinModules::GetExports(Local context, } MaybeLocal NsBuiltinModules::GetModule(Local context, - const std::string& specifier) { + const std::string& requested) { + const std::string specifier = Canonicalize(requested); // The module system is the app-facing entry point; the internal tier only // exists behind the require builtins receive. const Registration* registration = Find(specifier); diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index 3e15883fa..60f18ec78 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -484,7 +484,6 @@ void DisposeIsolateWhenPossible(Isolate* isolate, int gateId) { this->moduleInternal_ = std::make_unique(context); ArgConverter::Init(context); - Interop::RegisterInteropTypes(context); ClassBuilder::RegisterBaseTypeScriptExtendsFunction( context); // Register the __extends function to the global object @@ -647,12 +646,6 @@ void DisposeIsolateWhenPossible(Isolate* isolate, int gateId) { .FromMaybe(false)) { tns::Assert(false, isolate); } - - if (isWorker) { - // Register proper interop types for worker context - // Worker bundles need full interop functionality, not just simple stubs - tns::Interop::RegisterInteropTypes(context); - } } void Runtime::DefineCollectFunction(Local context) { diff --git a/NativeScript/runtime/js/README.md b/NativeScript/runtime/js/README.md index 90f964787..3f00ec73d 100644 --- a/NativeScript/runtime/js/README.md +++ b/NativeScript/runtime/js/README.md @@ -82,9 +82,13 @@ 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`), `base64.js` (`atob`/`btoa`), `dom-exception.js` (`DOMException`), `message-event.js` (`MessageEvent`), -`message-channel.js` (`MessagePort`/`MessageChannel`) and -`broadcast-channel.js` (`BroadcastChannel`) are the current ones; new globals -join by adding a row to `kLazyGlobals`. +`message-channel.js` (`MessagePort`/`MessageChannel`), +`broadcast-channel.js` (`BroadcastChannel`) and `interop-global.js` +(`interop`) are the current ones; new globals join by adding a row to +`kLazyGlobals`. `interop-global.js` is the compat-layer shape of the tier: it +owns no implementation, it assembles the global out of the `ns:interop`, +`ns:interop/ios` and `ns:runtime` exports it requires, so the standard +modules never know the global exists. Two neighbours of that set are deliberately not in it. `worker-events.js` is **eager**: it defines the handler attributes on `Worker.prototype` and the diff --git a/NativeScript/runtime/js/inline-functions.js b/NativeScript/runtime/js/inline-functions.js index ea98b3d87..262fe4fd6 100644 --- a/NativeScript/runtime/js/inline-functions.js +++ b/NativeScript/runtime/js/inline-functions.js @@ -7,6 +7,12 @@ const { ObjectKeys, } = primordials; +// Read when a decorator runs rather than here: this file runs at init, and +// requiring ns:interop at the top would build that module for every app. +function voidType() { + return require("ns:interop").types.void; +} + ObjectAssign(global, { CGPointMake(x, y) { return new CGPoint({ x, y }); @@ -60,7 +66,7 @@ ObjectAssign(global, { if (!target.constructor.ObjCExposedMethods[propertyKey]) { target.constructor.ObjCExposedMethods[propertyKey] = {}; } - target.constructor.ObjCExposedMethods[propertyKey].returns = returnType || interop.types.void; + target.constructor.ObjCExposedMethods[propertyKey].returns = returnType || voidType(); if (hasName && name !== propertyKey) { target.constructor.ObjCExposedMethods[name] = target.constructor.ObjCExposedMethods[propertyKey]; @@ -98,7 +104,7 @@ ObjectAssign(global, { if (!exposedMethod.params) { exposedMethod.params = []; } - exposedMethod.params[parameterIndex] = type || interop.types.void; + exposedMethod.params[parameterIndex] = type || voidType(); }; }, diff --git a/NativeScript/runtime/js/interop-global.js b/NativeScript/runtime/js/interop-global.js new file mode 100644 index 000000000..12d856634 --- /dev/null +++ b/NativeScript/runtime/js/interop-global.js @@ -0,0 +1,51 @@ +"use strict"; + +// The global `interop`: a compatibility layer assembled from ns:interop, +// ns:interop/ios and ns:runtime, so every member is the module's own object. +// Unlike the module exports it is not frozen; apps have always been able to +// add to it. A lazy global (LazyGlobals.cpp): it runs on the first read of the +// name. + +const interop = require("ns:interop"); +const apple = require("ns:interop/ios"); +const runtime = require("ns:runtime"); + +const types = { + noop: apple.types.noop, + void: interop.types.void, + bool: interop.types.bool, + uint8: interop.types.uint8, + int8: interop.types.int8, + uint16: interop.types.uint16, + int16: interop.types.int16, + uint32: interop.types.uint32, + int32: interop.types.int32, + uint64: interop.types.uint64, + int64: interop.types.int64, + ulong: interop.types.ulong, + slong: interop.types.slong, + float: interop.types.float, + double: interop.types.double, + id: apple.types.id, + unichar: apple.types.unichar, + protocol: apple.types.protocol, + class: apple.types.class, + selector: apple.types.selector, +}; + +exports.interop = { + Reference: interop.Reference, + Pointer: interop.Pointer, + FunctionReference: interop.FunctionReference, + bufferFromData: apple.bufferFromData, + stringFromCString: interop.stringFromCString, + handleof: interop.handleof, + alloc: interop.alloc, + free: interop.free, + adopt: apple.adopt, + setExternalSize: runtime.setExternalSize, + getExternalSize: runtime.getExternalSize, + sizeof: interop.sizeof, + escapeException: runtime.escapeException, + types, +}; diff --git a/NativeScript/runtime/js/ns-interop-ios.js b/NativeScript/runtime/js/ns-interop-ios.js new file mode 100644 index 000000000..7be4f2554 --- /dev/null +++ b/NativeScript/runtime/js/ns-interop-ios.js @@ -0,0 +1,13 @@ +"use strict"; + +// The `ns:interop/ios` builtin module (also reachable as `ns:interop/visionos` +// and `ns:interop/macos`): the Apple-only interop helpers and the Objective-C +// types. See docs/ns-builtin-modules.md. + +const { adopt, bufferFromData, types } = binding; +const { ObjectFreeze } = primordials; + +exports.adopt = adopt; +exports.bufferFromData = bufferFromData; +exports.types = ObjectFreeze(types); +ObjectFreeze(exports); diff --git a/NativeScript/runtime/js/ns-interop.js b/NativeScript/runtime/js/ns-interop.js new file mode 100644 index 000000000..7478c1d93 --- /dev/null +++ b/NativeScript/runtime/js/ns-interop.js @@ -0,0 +1,28 @@ +"use strict"; + +// The `ns:interop` builtin module: native memory and C values, the part of +// interop every platform shares. See docs/ns-builtin-modules.md. + +const { + Pointer, + Reference, + FunctionReference, + sizeof, + alloc, + free, + handleof, + stringFromCString, + types, +} = binding; +const { ObjectFreeze } = primordials; + +exports.Pointer = Pointer; +exports.Reference = Reference; +exports.FunctionReference = FunctionReference; +exports.sizeof = sizeof; +exports.alloc = alloc; +exports.free = free; +exports.handleof = handleof; +exports.stringFromCString = stringFromCString; +exports.types = ObjectFreeze(types); +ObjectFreeze(exports); diff --git a/NativeScript/runtime/js/ns-runtime.js b/NativeScript/runtime/js/ns-runtime.js index fc026722e..03abfd7aa 100644 --- a/NativeScript/runtime/js/ns-runtime.js +++ b/NativeScript/runtime/js/ns-runtime.js @@ -1,14 +1,24 @@ "use strict"; -// The `ns:runtime` builtin module: runtime-level configuration and (future) -// runtime introspection. See docs/ns-builtin-modules.md for the contract and -// the key registry — keys, their value domains, and their scope (process-wide -// vs per-isolate) are defined and validated on the native side, so this file -// stays a thin, frozen surface. +// The `ns:runtime` builtin module: runtime-level configuration, native +// exception escaping and external-memory accounting. See +// docs/ns-builtin-modules.md for the contract and the key registry — keys, +// their value domains, and their scope (process-wide vs per-isolate) are +// defined and validated on the native side, so this file stays a thin, frozen +// surface. -const { setConfig, getConfig } = binding; +const { + setConfig, + getConfig, + escapeException, + setExternalSize, + getExternalSize, +} = binding; const { ObjectFreeze } = primordials; exports.setConfig = setConfig; exports.getConfig = getConfig; +exports.escapeException = escapeException; +exports.setExternalSize = setExternalSize; +exports.getExternalSize = getExternalSize; ObjectFreeze(exports); diff --git a/TestRunner/app/tests/NsInteropTests.js b/TestRunner/app/tests/NsInteropTests.js new file mode 100644 index 000000000..06c1e0e0f --- /dev/null +++ b/TestRunner/app/tests/NsInteropTests.js @@ -0,0 +1,204 @@ +// The global `interop` is a compat layer over ns:interop, ns:interop/ios and +// ns:runtime. The export sets are public API, declared in types/ns-interop.d.ts, +// types/ns-interop-ios.d.ts and types/ns-runtime.d.ts and documented in +// docs/ns-builtin-modules.md — all of them must change together. + +var C_TYPES = ["bool", "double", "float", "int16", "int32", "int64", "int8", "slong", + "uint16", "uint32", "uint64", "uint8", "ulong", "void"]; +var APPLE_TYPES = ["class", "id", "noop", "protocol", "selector", "unichar"]; +var GLOBAL_KEYS = ["FunctionReference", "Pointer", "Reference", "adopt", "alloc", + "bufferFromData", "escapeException", "free", "getExternalSize", "handleof", + "setExternalSize", "sizeof", "stringFromCString", "types"]; + +describe("ns:interop", function () { + var nsInterop = require("ns:interop"); + + it("exposes exactly the declared surface", function () { + expect(Object.keys(nsInterop).sort()).toEqual(["FunctionReference", "Pointer", + "Reference", "alloc", "free", "handleof", "sizeof", "stringFromCString", "types"]); + expect(Object.keys(nsInterop.types).sort()).toEqual(C_TYPES); + }); + + it("freezes its exports and its types", function () { + expect(Object.isFrozen(nsInterop)).toBe(true); + expect(Object.isFrozen(nsInterop.types)).toBe(true); + }); + + it("is a singleton per realm", function () { + expect(require("ns:interop")).toBe(nsInterop); + }); + + it("hands out working natives", function () { + var types = nsInterop.types; + expect(nsInterop.sizeof(types.int32)).toBe(4); + expect(nsInterop.sizeof(types.double)).toBe(8); + + var ref = new nsInterop.Reference(types.int32, 42); + expect(ref.value).toBe(42); + expect(nsInterop.handleof(ref) instanceof nsInterop.Pointer).toBe(true); + + var pointer = nsInterop.alloc(4); + expect(pointer instanceof nsInterop.Pointer).toBe(true); + var view = new nsInterop.Reference(types.int32, pointer); + view.value = 7; + expect(view.value).toBe(7); + }); + + it("reads a C string through its own types", function () { + var cString = new nsInterop.Reference(nsInterop.types.uint8, nsInterop.alloc(4)); + cString[0] = 104; + cString[1] = 105; + cString[2] = 0; + expect(nsInterop.stringFromCString(cString)).toBe("hi"); + }); +}); + +describe("ns:interop/ios", function () { + var nsInteropIos = require("ns:interop/ios"); + + it("exposes exactly the declared surface", function () { + expect(Object.keys(nsInteropIos).sort()).toEqual(["adopt", "bufferFromData", "types"]); + expect(Object.keys(nsInteropIos.types).sort()).toEqual(APPLE_TYPES); + }); + + it("freezes its exports and its types", function () { + expect(Object.isFrozen(nsInteropIos)).toBe(true); + expect(Object.isFrozen(nsInteropIos.types)).toBe(true); + }); + + it("is the module behind every Apple spelling", function () { + expect(require("ns:interop/ios")).toBe(nsInteropIos); + expect(require("ns:interop/visionos")).toBe(nsInteropIos); + expect(require("ns:interop/macos")).toBe(nsInteropIos); + }); + + it("does not resolve other platform spellings", function () { + expect(function () { + require("ns:interop/android"); + }).toThrowError("No such built-in module: ns:interop/android"); + }); + + it("hands out working natives", function () { + var data = NSString.stringWithString("abc").dataUsingEncoding(NSUTF8StringEncoding); + var buffer = nsInteropIos.bufferFromData(data); + expect(buffer instanceof ArrayBuffer).toBe(true); + expect(Array.prototype.slice.call(new Uint8Array(buffer))).toEqual([97, 98, 99]); + + var nsInterop = require("ns:interop"); + expect(nsInterop.sizeof(nsInteropIos.types.id)).toBe(nsInterop.sizeof(nsInterop.types.uint64)); + }); +}); + +describe("ns:interop ES modules", function () { + var nsInterop = require("ns:interop"); + var nsInteropIos = require("ns:interop/ios"); + + it("resolves through dynamic import with the same members", function (done) { + Promise.all([import("ns:interop"), import("ns:interop/ios")]).then(function (namespaces) { + expect(namespaces[0].default).toBe(nsInterop); + expect(namespaces[0].Pointer).toBe(nsInterop.Pointer); + expect(namespaces[0].types).toBe(nsInterop.types); + expect(namespaces[1].default).toBe(nsInteropIos); + expect(namespaces[1].adopt).toBe(nsInteropIos.adopt); + done(); + }, function (error) { + fail("import of ns:interop should resolve: " + error); + done(); + }); + }); + + it("gives every Apple spelling one namespace object", function (done) { + Promise.all([ + import("ns:interop/ios"), + import("ns:interop/visionos"), + import("ns:interop/macos"), + ]).then(function (namespaces) { + expect(namespaces[1]).toBe(namespaces[0]); + expect(namespaces[2]).toBe(namespaces[0]); + done(); + }, function (error) { + fail("import of an ns:interop/ios alias should resolve: " + error); + done(); + }); + }); + + it("resolves through a static import", function (done) { + import("~/tests/esm/ns-interop-import.mjs").then(function (module) { + expect(module.Pointer).toBe(nsInterop.Pointer); + expect(module.types).toBe(nsInterop.types); + expect(module.interopDefault).toBe(nsInterop); + expect(module.adopt).toBe(nsInteropIos.adopt); + expect(module.appleTypes).toBe(nsInteropIos.types); + expect(module.visionosDefault).toBe(nsInteropIos); + expect(module.macosNamespace).toBe(module.iosNamespace); + done(); + }, function (error) { + fail("static import of ns:interop should resolve: " + error); + done(); + }); + }); +}); + +describe("global interop", function () { + var nsInterop = require("ns:interop"); + var nsInteropIos = require("ns:interop/ios"); + var runtime = require("ns:runtime"); + + it("keeps its key set", function () { + expect(Object.keys(interop).sort()).toEqual(GLOBAL_KEYS); + expect(Object.keys(interop.types).sort()).toEqual(C_TYPES.concat(APPLE_TYPES).sort()); + }); + + it("holds the modules' own objects", function () { + ["Pointer", "Reference", "FunctionReference", "sizeof", "alloc", "free", "handleof", + "stringFromCString"].forEach(function (name) { + expect(interop[name]).toBe(nsInterop[name]); + }); + expect(interop.adopt).toBe(nsInteropIos.adopt); + expect(interop.bufferFromData).toBe(nsInteropIos.bufferFromData); + expect(interop.escapeException).toBe(runtime.escapeException); + expect(interop.setExternalSize).toBe(runtime.setExternalSize); + expect(interop.getExternalSize).toBe(runtime.getExternalSize); + }); + + it("merges both modules' types into one object", function () { + expect(interop.types).not.toBe(nsInterop.types); + expect(interop.types).not.toBe(nsInteropIos.types); + C_TYPES.forEach(function (name) { + expect(interop.types[name]).toBe(nsInterop.types[name]); + }); + APPLE_TYPES.forEach(function (name) { + expect(interop.types[name]).toBe(nsInteropIos.types[name]); + }); + }); + + it("stays a mutable, enumerable global", function () { + expect(Object.isFrozen(interop)).toBe(false); + expect(Object.isExtensible(interop)).toBe(true); + expect(Object.isFrozen(interop.types)).toBe(false); + var descriptor = Object.getOwnPropertyDescriptor(globalThis, "interop"); + expect(descriptor.enumerable).toBe(true); + expect(descriptor.writable).toBe(true); + expect(descriptor.configurable).toBe(true); + }); + + it("is assembled the same way in a worker", function (done) { + var worker = new Worker("./nsInteropIdentityWorker.js"); + worker.onmessage = function (event) { + expect(event.data).toEqual({ + keys: GLOBAL_KEYS, + typeKeys: C_TYPES.concat(APPLE_TYPES).sort(), + mismatches: [], + frozen: true, + aliased: true, + }); + worker.terminate(); + done(); + }; + worker.onerror = function (event) { + worker.terminate(); + fail("worker error: " + event.message); + done(); + }; + }); +}); diff --git a/TestRunner/app/tests/NsRuntimeTests.js b/TestRunner/app/tests/NsRuntimeTests.js index 6ad0152c0..1b1b28687 100644 --- a/TestRunner/app/tests/NsRuntimeTests.js +++ b/TestRunner/app/tests/NsRuntimeTests.js @@ -10,7 +10,13 @@ describe("ns:runtime", function () { // The export set is public API, declared in types/ns-runtime.d.ts and // docs/ns-builtin-modules.md — all three must change together. it("exposes exactly the declared surface", function () { - expect(Object.keys(runtime).sort()).toEqual(["getConfig", "setConfig"]); + expect(Object.keys(runtime).sort()).toEqual([ + "escapeException", + "getConfig", + "getExternalSize", + "setConfig", + "setExternalSize", + ]); }); // Registered before GCFinalizerTests (which toggles the policy and diff --git a/TestRunner/app/tests/esm/ns-interop-import.mjs b/TestRunner/app/tests/esm/ns-interop-import.mjs new file mode 100644 index 000000000..793dfba2b --- /dev/null +++ b/TestRunner/app/tests/esm/ns-interop-import.mjs @@ -0,0 +1,9 @@ +// Static `import` of the builtins, so the ES module resolve callback is +// exercised too (dynamic import() has its own fast path). +import interopDefault, { Pointer, types } from "ns:interop"; +import { adopt, types as appleTypes } from "ns:interop/ios"; +import visionosDefault from "ns:interop/visionos"; +import * as iosNamespace from "ns:interop/ios"; +import * as macosNamespace from "ns:interop/macos"; + +export { interopDefault, Pointer, types, adopt, appleTypes, visionosDefault, iosNamespace, macosNamespace }; diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index c8d657702..5c64e5762 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -132,6 +132,7 @@ require("./ProxyReceiverTests"); require("./NsRuntimeTests"); require("./NsWorkerThreadsTests"); require("./NsUrlTests"); +require("./NsInteropTests"); require("./GCFinalizerTests"); require("./BlockCacheRaceTests"); require("./WorkerConcurrentStartupTests"); diff --git a/TestRunner/app/tests/nsInteropIdentityWorker.js b/TestRunner/app/tests/nsInteropIdentityWorker.js new file mode 100644 index 000000000..752122298 --- /dev/null +++ b/TestRunner/app/tests/nsInteropIdentityWorker.js @@ -0,0 +1,38 @@ +// Reads the global before anything in this realm has loaded the modules, so +// the compat layer is what builds them here. +var keys = Object.keys(interop).sort(); +var typeKeys = Object.keys(interop.types).sort(); +var nsInterop = require("ns:interop"); +var nsInteropIos = require("ns:interop/ios"); +var runtime = require("ns:runtime"); + +var mismatches = []; +function check(name, actual, expected) { + if (actual !== expected) { + mismatches.push(name); + } +} +Object.keys(nsInterop).forEach(function (name) { + if (name !== "types") { + check(name, interop[name], nsInterop[name]); + } +}); +Object.keys(nsInterop.types).forEach(function (name) { + check("types." + name, interop.types[name], nsInterop.types[name]); +}); +Object.keys(nsInteropIos.types).forEach(function (name) { + check("types." + name, interop.types[name], nsInteropIos.types[name]); +}); +check("adopt", interop.adopt, nsInteropIos.adopt); +check("bufferFromData", interop.bufferFromData, nsInteropIos.bufferFromData); +["escapeException", "setExternalSize", "getExternalSize"].forEach(function (name) { + check(name, interop[name], runtime[name]); +}); + +postMessage({ + keys: keys, + typeKeys: typeKeys, + mismatches: mismatches, + frozen: Object.isFrozen(nsInterop) && Object.isFrozen(nsInteropIos), + aliased: require("ns:interop/visionos") === nsInteropIos, +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 69435e482..48db11a1a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -76,7 +76,6 @@ export default [ Blob: 'readonly', File: 'readonly', // Native globals resolved through the metadata interceptor at runtime: - interop: 'readonly', NSUUID: 'readonly', CGPoint: 'readonly', CGRect: 'readonly', diff --git a/tools/js2c-inputs.xcfilelist b/tools/js2c-inputs.xcfilelist index af0d35894..dd339c974 100644 --- a/tools/js2c-inputs.xcfilelist +++ b/tools/js2c-inputs.xcfilelist @@ -10,12 +10,15 @@ $(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/interop-global.js $(SRCROOT)/NativeScript/runtime/js/message-channel.js $(SRCROOT)/NativeScript/runtime/js/message-event.js $(SRCROOT)/NativeScript/runtime/js/node-module.js $(SRCROOT)/NativeScript/runtime/js/node-url.js $(SRCROOT)/NativeScript/runtime/js/node-util.js $(SRCROOT)/NativeScript/runtime/js/node-worker-threads.js +$(SRCROOT)/NativeScript/runtime/js/ns-interop.js +$(SRCROOT)/NativeScript/runtime/js/ns-interop-ios.js $(SRCROOT)/NativeScript/runtime/js/ns-module.js $(SRCROOT)/NativeScript/runtime/js/ns-runtime.js $(SRCROOT)/NativeScript/runtime/js/ns-url.js From 19b267a7a482607c71ba70289a7569a5dfb7e15c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 23 Oct 2026 14:37:35 -0300 Subject: [PATCH 2/3] feat(types): declare ns:interop, ns:interop/ios and the ns:runtime additions Pointer, Reference, FunctionReference, Type, StructType, Unmanaged and AdoptedPointer are module exports, so declarations can `import type * as interop from "ns:interop"`. bufferFromData takes `NSData & object` against an empty global NSData interface, which merges into @nativescript/types-ios's class when present and accepts any object otherwise. --- types/index.d.ts | 5 +- types/ns-interop-ios.d.ts | 39 ++++++++++ types/ns-interop.d.ts | 157 ++++++++++++++++++++++++++++++++++++++ types/ns-runtime.d.ts | 26 +++++++ 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 types/ns-interop-ios.d.ts create mode 100644 types/ns-interop.d.ts diff --git a/types/index.d.ts b/types/index.d.ts index 94256051a..0bef9864c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -2,7 +2,8 @@ // .d.ts per module, mirroring @types/node's layout and the "one module, one // source file" rule in docs/ns-builtin-modules.md. The declared surfaces must // stay in sync with that document (NsRuntimeTests.js / NsUtilTests.js / -// HttpEsmLoaderTests.js assert the export sets at runtime). +// NsInteropTests.js / HttpEsmLoaderTests.js assert the export sets at +// runtime). // // `ns:` is not a resolvable package specifier, so these are ambient // declarations: they apply program-wide once this file is in the TypeScript @@ -25,6 +26,8 @@ // in docs/ns-builtin-modules.md, not something to restate in types that would // then conflict. +/// +/// /// /// /// diff --git a/types/ns-interop-ios.d.ts b/types/ns-interop-ios.d.ts new file mode 100644 index 000000000..dc587034a --- /dev/null +++ b/types/ns-interop-ios.d.ts @@ -0,0 +1,39 @@ +declare module "ns:interop/ios" { + import type { AdoptedPointer, Pointer, Type } from "ns:interop"; + + /** + * Makes `ptr` own the memory it points to, so it is freed when the pointer + * is collected. Returns `ptr` itself. + */ + export function adopt(ptr: Pointer): AdoptedPointer; + + /** + * An ArrayBuffer over the bytes of `data`, without copying them. The + * buffer keeps `data` alive for as long as it is reachable. + */ + export function bufferFromData(data: NSData & object): ArrayBuffer; + + /** The Objective-C types, frozen. */ + export const types: Readonly<{ + id: Type; + class: Type; + selector: Type; + protocol: Type; + unichar: Type; + /** Pointer-sized, with `void`'s encoding. */ + noop: Type; + }>; +} + +declare module "ns:interop/visionos" { + export * from "ns:interop/ios"; +} + +declare module "ns:interop/macos" { + export * from "ns:interop/ios"; +} + +// Merges into the class @nativescript/types-ios declares, so `bufferFromData` +// takes a real NSData there and any object in a program without those types. +// Declared empty on purpose: a member here would be added to that class. +interface NSData {} diff --git a/types/ns-interop.d.ts b/types/ns-interop.d.ts new file mode 100644 index 000000000..e2a72243c --- /dev/null +++ b/types/ns-interop.d.ts @@ -0,0 +1,157 @@ +declare module "ns:interop" { + /** A native `void*`. */ + export interface Pointer { + /** A new pointer `offset` bytes past this one. */ + add(offset: number): Pointer; + /** A new pointer `offset` bytes before this one. */ + subtract(offset: number): Pointer; + /** + * The address as a number. Addresses wider than 53 bits lose precision; + * `toBigInt` does not. + */ + toNumber(): number; + toBigInt(): bigint; + /** The address in hexadecimal, `0x`-prefixed. */ + toHexString(): string; + /** The address in decimal. */ + toDecimalString(): string; + toString(): string; + } + + /** + * Pointers are interned per address: constructing one for an address that + * already has a live `Pointer` returns that object. + */ + export const Pointer: { + readonly prototype: Pointer; + /** A pointer to `address`, or the null pointer when omitted. */ + new (address?: number | bigint): Pointer; + }; + + /** + * A pointer that owns the memory it points to and frees it when it is + * garbage collected — what `alloc` returns and `adopt` produces. + */ + export interface AdoptedPointer extends Pointer {} + + /** + * A native type: the C primitives in `types`, a struct constructor, an + * Objective-C class or protocol. Calling one reads a value of that type at + * a pointer. + */ + export interface Type { + (ptr: Pointer): T; + } + + /** The constructor of a C struct, which doubles as its `Type`. */ + export interface StructType extends Type { + /** A zero-initialized struct. */ + new (): T; + /** A struct initialized from the fields of `obj`. */ + new (obj: T): T; + /** A struct copied from the memory at `obj`. */ + new (obj: Pointer): T; + /** Whether two structs hold the same bytes. */ + equals(left: T, right: T): boolean; + } + + /** + * An object reference whose ownership the native side did not express (a + * Core Foundation function without an audited ownership convention). The + * caller decides whether the retain it carries is consumed. + */ + export interface Unmanaged { + /** The value, consuming an unbalanced retain of it. */ + takeRetainedValue(): T; + /** The value, leaving its retain count alone. */ + takeUnretainedValue(): T; + } + + /** + * A typed slot in native memory, used for out parameters and pointer + * arithmetic. Indexing reads and writes the elements that follow it. + */ + export interface Reference { + value: T; + [index: number]: any; + } + + export const Reference: { + readonly prototype: Reference; + /** + * A reference holding `value`; its native type is fixed the first time + * it is marshalled. + */ + new (value?: T): Reference; + /** A view of the memory at `data`, read and written as `type`. */ + new (type: Type, data: Pointer): Reference; + /** A reference of type `type` initialized with `value`. */ + new (type: Type, value: any): Reference; + }; + + /** + * A JS function marshalled as a native function pointer. The pointer stays + * valid only while this object is reachable from JS. + */ + export interface FunctionReference { + (...params: any[]): any; + } + + export const FunctionReference: { + readonly prototype: FunctionReference; + new (func: T): FunctionReference; + }; + + /** + * The size in bytes of a type or of the value behind an instance: a type + * from `types`, a struct type or instance, a class or protocol, a native + * object, reference, pointer, block or C function. Throws for anything else. + */ + export function sizeof(type: any): number; + + /** `size` bytes of zeroed memory, freed when the pointer is collected. */ + export function alloc(size: number): AdoptedPointer; + + /** + * Frees memory a pointer does not own. Does nothing for an adopted + * pointer, whose memory is freed when it is collected. + */ + export function free(ptr: Pointer): void; + + /** + * A pointer to the native counterpart of `instance`: a native object, + * class or protocol, a struct, reference, pointer, block, function + * reference, C function or ArrayBuffer view. `null` for `null`; throws for + * anything else. + */ + export function handleof(instance: any): Pointer; + + /** + * Decodes UTF-8 from `cString`: `lengthInBytes` bytes, or up to the first + * NUL when omitted. + */ + export function stringFromCString( + cString: Pointer | Reference, + lengthInBytes?: number + ): string; + + /** The C primitive types, frozen. */ + export const types: Readonly<{ + void: Type; + bool: Type; + int8: Type; + uint8: Type; + int16: Type; + uint16: Type; + int32: Type; + uint32: Type; + int64: Type; + uint64: Type; + /** C `unsigned long`. */ + ulong: Type; + /** C `long`. */ + slong: Type; + float: Type; + double: Type; + }>; +} diff --git a/types/ns-runtime.d.ts b/types/ns-runtime.d.ts index ca77dfa5b..37a74c68e 100644 --- a/types/ns-runtime.d.ts +++ b/types/ns-runtime.d.ts @@ -55,4 +55,30 @@ declare module "ns:runtime" { export function getConfig( key: K ): K extends keyof RuntimeConfig ? RuntimeConfig[K] : unknown; + + /** + * Marks `error` to cross the next JS-to-native boundary as a native + * exception instead of being reported in JS: `throw escapeException(e)` + * inside a native callback reaches the native caller's `@try/@catch`, with + * the original `NSException` when `e` carries one. Returns an `Error` + * (message and stack copied), so the throw also behaves normally in pure-JS + * paths. An already-escaped value is returned unchanged; calling with no + * argument throws `TypeError`. + */ + export function escapeException(error: unknown): Error; + + /** + * Reports `bytes` of native memory held by `target` (a native object, + * pointer, reference, block or function reference) to the garbage + * collector, replacing any earlier size; `0` clears it. The charge is + * returned when `target` is collected or released. Throws `TypeError` for + * any other value and `RangeError` for a byte count outside `0`–`2^34`. + */ + export function setExternalSize(target: object, bytes: number): void; + + /** + * The size last reported for `target` through `setExternalSize` or by the + * runtime itself, `0` when there is none. + */ + export function getExternalSize(target: unknown): number; } From 9f1ca57bc324430be161418cd6c6139956095377 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 23 Oct 2026 14:37:35 -0300 Subject: [PATCH 3/3] docs: ns:interop modules and the global interop compat layer --- docs/README.md | 3 +- docs/ns-builtin-modules.md | 102 ++++++++++++++++++++++++++++++++++--- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/docs/README.md b/docs/README.md index eb0e6448f..ad0060295 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,8 @@ # Runtime documentation - [Builtin modules](ns-builtin-modules.md) — the `ns:`/`node:` builtin-module - reference: resolution rules, `ns:util`/`ns:runtime`/`ns:module` and the + reference: resolution rules, `ns:util`/`ns:runtime`/`ns:module`, + `ns:interop`/`ns:interop/ios` and the global `interop` over them, the `node:` compatibility shims, import maps and scopes, `createRequire` and the pumping variant, the module-response (MIME) contract, and app-entry bootstraps. The cross-runtime contract both iOS and Android implement. diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index e3b3ff30f..060b5eff9 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -93,13 +93,20 @@ programmatically. ### `ns:runtime` -Runtime-level configuration. Keys, value domains, and scope are defined and -validated natively; the module surface is a thin frozen wrapper. +Runtime-level configuration and plumbing. Keys, value domains, and scope are +defined and validated natively; the module surface is a thin frozen wrapper. | export | description | |---|---| | `setConfig(key, value)` | Sets a runtime config key. Throws `TypeError` on an unknown key, an invalid value, or (for process-wide keys) when called from a worker isolate. | | `getConfig(key)` | Returns the current value of a config key. Throws `TypeError` on an unknown key. Readable from any isolate. | +| `escapeException(error)` | Returns an `Error` branded to cross the next JS-to-native boundary as a native exception rather than being reported in JS (see [error-handling.md](error-handling.md#forwarding-a-throw-to-native-interopescapeexception)). An already-branded value is returned unchanged; no argument throws `TypeError`. | +| `setExternalSize(target, bytes)` | Reports `bytes` of native memory held by a native object, pointer, reference, block or function reference to the garbage collector, replacing any earlier size (`0` clears it). Throws `TypeError` for any other value, `RangeError` for a byte count outside 0–2^34. | +| `getExternalSize(target)` | The size currently charged to `target`, `0` when there is none. | + +The last three are also members of the global `interop` (see +[The global `interop`](#the-global-interop)); the module and the global hold +the same function objects. Config keys: @@ -480,6 +487,82 @@ declares the global `URL` and `URLSearchParams` the way `@types/node` declares its own globals: a program with a DOM lib keeps the lib's declarations, and a program without one gets the module's constructors as the globals. +### `ns:interop` + +Native memory and C values — the part of interop that is not tied to one +platform's object model. + +Every runtime provides `ns:interop`, exporting the members it supports. The +table below is the iOS set. The Android runtime exports none of them today +(its Java helpers are in `ns:interop/android`). + +| export | description | +|---|---| +| `Pointer` | The `void*` wrapper constructor. `new Pointer(address)` takes a number or a BigInt; pointers are interned per address. | +| `Reference` | A typed slot in native memory: `new Reference(value)`, `new Reference(type, value)`, or `new Reference(type, pointer)` for a view of existing memory. | +| `FunctionReference` | Wraps a JS function so it can be passed as a C function pointer. | +| `sizeof(type)` | Size in bytes of a type, or of the native value behind an instance. | +| `alloc(size)` | Zeroed memory, returned as a pointer that frees it when collected. | +| `free(pointer)` | Frees memory a pointer does not own. | +| `handleof(instance)` | A `Pointer` to the native counterpart of a wrapper, struct, reference, block, function or ArrayBuffer view. | +| `stringFromCString(pointer[, length])` | Decodes UTF-8 from a C string. | +| `types` | Frozen object of the C primitive types: `void`, `bool`, `int8`, `uint8`, `int16`, `uint16`, `int32`, `uint32`, `int64`, `uint64`, `ulong`, `slong`, `float`, `double`. | + +```js +import { Reference, alloc, sizeof, types } from "ns:interop"; + +const buffer = alloc(sizeof(types.int32)); +const slot = new Reference(types.int32, buffer); +slot.value = 42; +``` + +`types/ns-interop.d.ts` also declares the type-only names `Type`, +`StructType`, `Unmanaged` and `AdoptedPointer`, so declarations can +write `import type * as interop from "ns:interop"` and keep referring to +`interop.Pointer` or `interop.Reference`. + +### `ns:interop/ios` + +The Apple-only interop helpers and the Objective-C types. **iOS-only**: the +module exists only on the Apple runtimes. `ns:interop/visionos` and +`ns:interop/macos` are aliases resolving to the very same module — one exports +object for `require()`, one module and namespace object for `import` — so code +can name the platform it targets without the runtime keeping three copies. + +| export | description | +|---|---| +| `adopt(pointer)` | Makes `pointer` own its memory, freeing it when collected; returns `pointer`. | +| `bufferFromData(data)` | An `ArrayBuffer` over an `NSData`'s bytes, without copying; the buffer keeps the data alive. | +| `types` | Frozen object of the Objective-C types: `id`, `class`, `selector`, `protocol`, `unichar`, `noop`. | + +`types/ns-interop-ios.d.ts` types `bufferFromData`'s argument as the global +`NSData` without requiring `@nativescript/types-ios`: it declares an empty +global `interface NSData`, which merges into that package's class when it is +in the program and accepts any object otherwise. + +### The global `interop` + +The global `interop` is a compatibility layer over the three modules above, +kept so existing code keeps working: + +- Every member is the module's own object: + `interop.Pointer === require("ns:interop").Pointer`, + `interop.adopt === require("ns:interop/ios").adopt`, + `interop.escapeException === require("ns:runtime").escapeException`. +- `interop.types` is one object merging both modules' types, so each of its + twenty members is the module's own type object + (`interop.types.int32 === require("ns:interop").types.int32`, + `interop.types.id === require("ns:interop/ios").types.id`). +- Unlike the modules it is **not frozen**, and neither is `interop.types`; + it remains an ordinary, enumerable, writable property of the global. +- It carries no member the modules lack. + +New code should import from the modules. + +*Platform note (iOS):* the global is materialized on its first read, which is +also what first builds the three modules in a realm that has not required +them. + ### `node:` compatibility shims The same registry serves the `node:` scheme with **compatibility shims** so @@ -794,6 +877,9 @@ 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`, ...). + A platform-specific counterpart of a cross-platform module takes the + platform as a sub-path (`ns:interop/ios`), and aliases of one platform + family resolve to the same registry row. - 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 @@ -807,9 +893,11 @@ resolvers read the same table differently: - The **`ns:`/`node:` resolver** — the app-facing one, behind `require()`, `import` and `import()` — serves only rows *not* marked internal-only. An internal-only specifier fails exactly as a name absent from the table does. - Nine rows are public today: `ns:module`, `ns:runtime`, `ns:url`, `ns:util`, - `ns:worker_threads`, `node:module`, `node:url`, `node:util`, - `node:worker_threads`. + Eleven rows are public today: `ns:interop`, `ns:interop/ios`, `ns:module`, + `ns:runtime`, `ns:url`, `ns:util`, `ns:worker_threads`, `node:module`, + `node:url`, `node:util`, `node:worker_threads`. `ns:interop/visionos` and + `ns:interop/macos` are aliases, canonicalized to `ns:interop/ios` before any + lookup, so they have no row of their own. - The **internal require** builtins receive (previous section) is the only thing that can name an internal-only row. Six rows are marked that way: `internal/broadcast-channel`, `internal/dom-exception`, `internal/events`, @@ -819,8 +907,8 @@ resolvers read the same table differently: worker's rebuilt error travels under, base classes that must be the runtime's own rather than whatever a global currently names. - Builtins with **no row at all** (the intrinsics snapshot, the require - factory, the console formatter) are invoked straight from their native call - sites. There is no specifier that could reach them and nothing to mark. + factory, the console formatter, the global `interop` compat layer) are + invoked straight from their native call sites. There is no specifier that could reach them and nothing to mark. So a builtin is unreachable from app code unless a registry row says otherwise, and exposing one means editing that row's tier — which is also the