Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 77 additions & 12 deletions docs/ns-builtin-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,22 @@ Node's single-quoted style.

### `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 runtime 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(x)` | Returns an `Error` branded so that, when it is thrown back across a native callback boundary, the native caller receives the original native exception `x` carries rather than the runtime's wrapper exception. Idempotent: a branded error is returned unchanged. Throws `TypeError` when called with no argument. The global `interop.escapeException` is this same function (see [Legacy globals](#legacy-globals)). |

*Platform note (Android):* the original exception is the Java throwable `x`
wraps, or its `nativeException`; a Java `catch` of the concrete type then
matches instead of receiving a `com.tns.NativeScriptException`. A branded error
with no underlying throwable escapes as a `com.tns.NativeScriptException`
carrying the JS message and stack, as an unbranded one does, but bypasses
`discardUncaughtJsExceptions`.

Config keys:

Expand Down Expand Up @@ -170,6 +179,53 @@ a release build that cannot be diagnosed.
`TNS.esm`, `TNS.fetch`, `TNS.registry` — so `adb logcat -s TNS.esm` can filter
them without matching message text.

### `ns:interop`

The cross-platform interop surface: working with native values, as opposed to
finding native symbols. It exists on every platform with whatever members that
platform supports, so a feature check (`"Pointer" in interop`) is how portable
code asks; a member a platform lacks is absent, never present-but-throwing.

On Android the namespace is **empty**: Android has none of the native-memory
members iOS exports here (`Pointer`, `Reference`, `sizeof`, …). It is still a
frozen module, so `import * as interop from "ns:interop"` resolves everywhere.

### `ns:interop/android`

Java-specific interop helpers. Android only: on other platforms the specifier
is not registered and fails like any unknown builtin.

| export | description |
|---|---|
| `long(x)`, `byte(x)`, `short(x)`, `char(x)`, `float(x)`, `double(x)` | Mark a JS value as the named Java primitive, for overload resolution and argument conversion: `method(long("123456789012"))` picks the `long` overload and passes the full 64-bit value. Each takes exactly one argument. |
| `newArray(type, length)` | Creates a Java array of `length` elements. `type` is a primitive type name (`"int"`, `"char"`, `"boolean"`, …), a fully qualified Java class name (`"java.lang.String"`), or a Java class constructor (`java.lang.String`). Throws on any other arity, a non-integer or negative length, or a class name that does not resolve. |

```js
import { long, newArray } from "ns:interop/android";

const bytes = newArray("byte", 1024); // byte[1024]
const names = newArray(java.lang.String, 3); // String[3]
someJavaObject.setTimestamp(long(Date.now()));
```

### Legacy globals

Before these modules existed the same functions were reachable only as
globals, and app code and `@nativescript/core` read them at module top level.
They remain, installed before any app code runs, as **aliases of the module
exports** — the very same function objects, per realm:

| global | module export |
|---|---|
| `interop.escapeException` | `ns:runtime`'s `escapeException` |
| `long`, `byte`, `short`, `char`, `float`, `double` (Android) | `ns:interop/android`'s members of those names |
| `Array.create` (Android) | `ns:interop/android`'s `newArray` |

The global `interop` object, the cast globals and `Array.create` are ordinary
writable, enumerable, configurable data properties. On Android the global
`interop` has exactly one member, `escapeException`; it is a plain object, not
the `ns:interop` namespace. New code should import from the modules.

### `ns:module`

The module-loader control surface: import-map vocabulary, registry
Expand Down Expand Up @@ -729,16 +785,19 @@ 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.
Seven rows are public today: `ns:module`, `ns:runtime`, `ns:util`,
`node:module`, `node:url`, `node:util`, `node:worker_threads`.
Nine rows are public today: `ns:interop`, `ns:interop/android`, `ns:module`,
`ns:runtime`, `ns:util`, `node:module`, `node:url`, `node:util`,
`node:worker_threads`.
- The **internal require** builtins receive (previous section) is the only
thing that can name an internal-only row. Six rows are marked that way:
thing that can name an internal-only row. Seven rows are marked that way:
`internal/broadcast-channel`, `internal/dom-exception`, `internal/events`,
`internal/message-channel`, `internal/message-event`,
`internal/worker-events`. Their exports carry capabilities app code must not
hold — listener-accounting hook keys, the error-reporter setter, the key a
worker's rebuilt error travels under, base classes that must be the
runtime's own rather than whatever a global currently names.
`internal/interop-globals`, `internal/message-channel`,
`internal/message-event`, `internal/worker-events`. Their exports carry
capabilities app code must not hold — listener-accounting hook keys, the
error-reporter setter, the key a worker's rebuilt error travels under, base
classes that must be the runtime's own rather than whatever a global
currently names — or, for `internal/interop-globals`, install the legacy
globals.
- 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.
Expand Down Expand Up @@ -777,8 +836,14 @@ internal require is a fixed parameter of the builtin function wrapper
The files under `js/` that are *not* in the public registry — `primordials.js`,
`require-factory.js`, `inspect.js`, `json-helper.js`, `events.js`,
`error-events.js`, `structured-clone.js`, `blob-url.js`, `performance.js`,
`weak-ref.js` — are the internal builtins: each is run from its own native call
site and none is named in the registry table.
`weak-ref.js`, `interop-globals.js` — are the internal builtins: each is run
from its own native call site and none is named in the public registry.

`interop-globals.js` is the [legacy globals](#legacy-globals) layer. It runs
eagerly from `Runtime::PrepareV8Runtime` in every isolate, requires
`ns:runtime` and `ns:interop/android` through the internal require and assigns
their functions to the globals, so the compat knowledge lives only in that
consumer and neither module refers to it.

Per-realm builtin state — the exports objects, the synthetic modules, the
in-progress set that produces the circular-require error, the cached `format`,
Expand Down
1 change: 1 addition & 0 deletions test-app/app/src/main/assets/app/mainpage.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ require('./tests/testInspect');
// The ns:/node: builtin modules
require('./tests/testNsUtil');
require('./tests/testNsRuntime');
require('./tests/testNsInterop');
require('./tests/testNsModule');
// Remote-module security gate (debug builds allow remote modules outright)
require('./tests/testRemoteModuleSecurity');
Expand Down
7 changes: 7 additions & 0 deletions test-app/app/src/main/assets/app/testNsInteropImport.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Static `import` of the interop builtins, so the ES module resolve callback
// handles a specifier with a path segment (`ns:interop/android`).
import interopDefault from "ns:interop";
import androidDefault, { long, newArray } from "ns:interop/android";
import { escapeException } from "ns:runtime";

export { interopDefault, androidDefault, long, newArray, escapeException };
14 changes: 14 additions & 0 deletions test-app/app/src/main/assets/app/tests/nsInteropWorker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// The compat globals are assembled per isolate, so a worker's globals must be
// its own module instance's functions.
onmessage = function () {
var android = require("ns:interop/android");
var runtime = require("ns:runtime");
postMessage({
casts: ["long", "byte", "short", "char", "float", "double"].every(function (name) {
return globalThis[name] === android[name];
}),
newArray: Array.create === android.newArray,
escapeException: interop.escapeException === runtime.escapeException,
arrayLength: android.newArray("int", 4).length,
});
};
150 changes: 150 additions & 0 deletions test-app/app/src/main/assets/app/tests/testNsInterop.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
var CAST_NAMES = ["long", "byte", "short", "char", "float", "double"];

describe("ns:interop", function () {
var nsInterop = require("ns:interop");

// Android has none of the cross-platform interop members iOS exports.
it("is an empty frozen namespace", function () {
expect(Object.isFrozen(nsInterop)).toBe(true);
expect(Object.keys(nsInterop)).toEqual([]);
});

it("is a singleton across require calls", function () {
expect(require("ns:interop")).toBe(nsInterop);
});
});

describe("ns:interop/android", function () {
var android = require("ns:interop/android");

it("exposes exactly the declared surface, frozen", function () {
expect(Object.isFrozen(android)).toBe(true);
expect(Object.keys(android).sort()).toEqual(
["byte", "char", "double", "float", "long", "newArray", "short"]);
Object.keys(android).forEach(function (name) {
expect(typeof android[name]).toBe("function");
});
});

it("is a singleton across require calls", function () {
expect(require("ns:interop/android")).toBe(android);
});

it("backs the cast globals with the same function objects", function () {
CAST_NAMES.forEach(function (name) {
expect(globalThis[name]).toBe(android[name]);
});
});

it("backs Array.create with newArray", function () {
expect(Array.create).toBe(android.newArray);
});

it("keeps the globals ordinary writable, enumerable, configurable properties", function () {
CAST_NAMES.forEach(function (name) {
var descriptor = Object.getOwnPropertyDescriptor(globalThis, name);
expect(descriptor.writable).toBe(true);
expect(descriptor.enumerable).toBe(true);
expect(descriptor.configurable).toBe(true);
});
var create = Object.getOwnPropertyDescriptor(Array, "create");
expect(create.writable).toBe(true);
expect(create.enumerable).toBe(true);
expect(create.configurable).toBe(true);
});

it("marks numbers for Java overload resolution", function () {
var n = new com.tns.tests.NumericConversionTest();
expect(n.method5(android.long("123456789012"))).toBe("long=123456789012");
expect(n.method5(android.float(1.23))).toBe("float=1.23");
expect(n.method5(android.double(1))).toBe("double=1.0");
expect(n.method2(android.double(65536 + 2))).toBe("double=65538.0");
expect(n.method2(android.byte(65536 + 2))).toBe("float=2.0");
expect(n.method2(android.short(65536 + 2))).toBe("float=2.0");
});

it("rejects a cast with the wrong arity", function () {
expect(function () {
android.long();
}).toThrow();
});

it("creates Java arrays from a primitive type name or a class", function () {
var ints = android.newArray("int", 3);
expect(ints.length).toBe(3);
expect(ints.getClass().getName()).toBe("[I");

var strings = android.newArray(java.lang.String, 2);
expect(strings.length).toBe(2);
expect(strings.getClass().getName()).toBe("[Ljava.lang.String;");
});

it("rejects bad newArray arguments", function () {
expect(function () {
android.newArray("int");
}).toThrow();
expect(function () {
android.newArray("int", -1);
}).toThrow();
});

it("resolves through a static import", function (done) {
import("~/testNsInteropImport.mjs").then(function (module) {
expect(module.interopDefault).toBe(require("ns:interop"));
expect(module.androidDefault).toBe(android);
expect(module.long).toBe(android.long);
expect(module.newArray).toBe(android.newArray);
expect(module.escapeException).toBe(require("ns:runtime").escapeException);
done();
}, function (error) {
fail("static import of ns:interop/android should resolve: " + error);
done();
});
});

it("resolves through a dynamic import", function (done) {
import("ns:interop/android").then(function (namespace) {
expect(namespace.default).toBe(android);
expect(namespace.long).toBe(android.long);
expect(namespace.newArray).toBe(android.newArray);
done();
}, function (error) {
fail("dynamic import of ns:interop/android should resolve: " + error);
done();
});
});

it("gives a worker its own instance behind its own globals", function (done) {
var worker = new Worker("./nsInteropWorker.js");
worker.onmessage = function (msg) {
expect(msg.data).toEqual({
casts: true,
newArray: true,
escapeException: true,
arrayLength: 4,
});
worker.terminate();
done();
};
worker.onerror = function (error) {
fail("worker failed: " + error.message);
worker.terminate();
done();
};
worker.postMessage("go");
});
});

describe("interop global", function () {
it("holds exactly ns:runtime's escapeException", function () {
expect(Object.keys(interop)).toEqual(["escapeException"]);
expect(interop.escapeException).toBe(require("ns:runtime").escapeException);
});

it("is an ordinary writable, enumerable, configurable global", function () {
var descriptor = Object.getOwnPropertyDescriptor(globalThis, "interop");
expect(descriptor.writable).toBe(true);
expect(descriptor.enumerable).toBe(true);
expect(descriptor.configurable).toBe(true);
});
});
3 changes: 2 additions & 1 deletion test-app/app/src/main/assets/app/tests/testNsRuntime.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ describe("ns:runtime", function () {
expect(Object.isFrozen(runtime)).toBe(true);
expect(typeof runtime.setConfig).toBe("function");
expect(typeof runtime.getConfig).toBe("function");
expect(typeof runtime.escapeException).toBe("function");
});

// The export set is public API, declared alongside docs/ns-builtin-modules.md
// — all of them 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", "setConfig"]);
});

it("rejects unknown keys", function () {
Expand Down
3 changes: 3 additions & 0 deletions test-app/runtime/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,16 @@ set(RUNTIME_BUILTIN_JS
${RUNTIME_BUILTIN_JS_DIR}/error-events.js
${RUNTIME_BUILTIN_JS_DIR}/events.js
${RUNTIME_BUILTIN_JS_DIR}/inspect.js
${RUNTIME_BUILTIN_JS_DIR}/interop-globals.js
${RUNTIME_BUILTIN_JS_DIR}/json-helper.js
${RUNTIME_BUILTIN_JS_DIR}/message-channel.js
${RUNTIME_BUILTIN_JS_DIR}/message-event.js
${RUNTIME_BUILTIN_JS_DIR}/node-module.js
${RUNTIME_BUILTIN_JS_DIR}/node-url.js
${RUNTIME_BUILTIN_JS_DIR}/node-util.js
${RUNTIME_BUILTIN_JS_DIR}/node-worker-threads.js
${RUNTIME_BUILTIN_JS_DIR}/ns-interop-android.js
${RUNTIME_BUILTIN_JS_DIR}/ns-interop.js
${RUNTIME_BUILTIN_JS_DIR}/ns-module.js
${RUNTIME_BUILTIN_JS_DIR}/ns-runtime.js
${RUNTIME_BUILTIN_JS_DIR}/ns-util.js
Expand Down
21 changes: 9 additions & 12 deletions test-app/runtime/src/main/cpp/ArrayHelper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include "ArgConverter.h"
#include "NativeScriptException.h"
#include "Runtime.h"
#include <mutex>
#include <sstream>

using namespace v8;
Expand All @@ -12,27 +13,23 @@ using namespace tns;
ArrayHelper::ArrayHelper() {
}

void ArrayHelper::Init(const Local<Context>& context) {
void ArrayHelper::ResolveRuntimeMethods() {
JEnv env;

RUNTIME_CLASS = env.FindClass("com/tns/Runtime");
NS_CHECK(RUNTIME_CLASS != nullptr);

CREATE_ARRAY_HELPER = env.GetStaticMethodID(RUNTIME_CLASS, "createArrayHelper", "(Ljava/lang/String;I)Ljava/lang/Object;");
NS_CHECK(CREATE_ARRAY_HELPER != nullptr);
}

MaybeLocal<Function> ArrayHelper::CreateNewArray(Local<Context> context) {
// Every isolate's binding gets here, workers on their own threads.
static std::once_flag resolved;
std::call_once(resolved, ResolveRuntimeMethods);

auto isolate = v8::Isolate::GetCurrent();
auto global = context->Global();
auto arr = global->Get(context, ArgConverter::ConvertToV8String(isolate, "Array"));

if (!arr.IsEmpty()) {
Local<Value> arrVal;
auto success = arr.ToLocal(&arrVal);
if (success) {
auto arrayObj = arrVal.As<Object>();
arrayObj->Set(context, ArgConverter::ConvertToV8String(isolate, "create"), FunctionTemplate::New(isolate, CreateJavaArrayCallback)->GetFunction(context).ToLocalChecked());
}
}
return FunctionTemplate::New(isolate, CreateJavaArrayCallback)->GetFunction(context);
}

void ArrayHelper::CreateJavaArrayCallback(const FunctionCallbackInfo<Value>& info) {
Expand Down
Loading
Loading