diff --git a/build.gradle b/build.gradle
index 221c47de6..cffa741df 100644
--- a/build.gradle
+++ b/build.gradle
@@ -193,6 +193,9 @@ def getAssembleReleaseBuildArguments = { ->
if (onlyX86) {
arguments.add("-PonlyX86")
}
+ if (project.hasProperty("abis")) {
+ arguments.add("-Pabis=${project.property('abis')}")
+ }
if (useCCache) {
arguments.add("-PuseCCache")
}
@@ -462,6 +465,9 @@ def getRunTestsBuildArguments = { taskName ->
if (onlyX86) {
arguments.add("-PonlyX86")
}
+ if (project.hasProperty("abis")) {
+ arguments.add("-Pabis=${project.property('abis')}")
+ }
if (useCCache) {
arguments.add("-PuseCCache")
}
diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md
index 589b42acc..b050ad765 100644
--- a/docs/ns-builtin-modules.md
+++ b/docs/ns-builtin-modules.md
@@ -52,6 +52,58 @@ Rules:
versions for readability; it is intended for humans and must not be parsed
programmatically.
+### `ns:runtime` (v1)
+
+Runtime-level configuration. 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. |
+
+Config keys:
+
+| key | values | scope | default |
+|---|---|---|---|
+| `logScriptLoading` | `true` \| `false` | process-wide (main-isolate writes only; read live by every isolate) | `false`, or the `logScriptLoading` value from nativescript.config / package.json at boot |
+| `httpFetchUrlLog` | `true` \| `false` | process-wide (main-isolate writes only; read live by every isolate) | `false`, or the `httpFetchUrlLog` value from nativescript.config / package.json at boot |
+
+Remote-module security (`security.allowRemoteModules`,
+`security.remoteModuleAllowlist`) is **not** part of this surface. Those
+values are read once from nativescript.config / package.json the first time
+the HTTP loader gates a fetch, and they cannot be inspected or changed
+through `getConfig` / `setConfig`.
+
+iOS additionally registers `releasedObjectPolicy`; Android does not (it has
+no released-native-counterpart machinery).
+
+### `ns:module` (v1)
+
+The module-loader control surface consumed by development tooling
+(`@nativescript/vite`). Mechanism only: every policy concern (boot
+orchestration, `import.meta.hot`, full reload, CSS apply, worker teardown,
+WebSocket protocol) lives in the tooling.
+
+| export | description |
+|---|---|
+| `configureLoader(config)` | Install loader policy before the session imports anything: `importMap` (bare specifier → URL, consulted inside the synchronous resolver), `volatilePatterns` (URL substrings always re-fetched), `canonicalization` (`stripParams`/`forPathPrefixes`/`preserveQueryFor` vocabulary for registry keying). Each present section replaces its state wholesale. |
+| `invalidateModules(urls)` | Evict the given URLs (canonicalized) from the module registry and mark them bust-next-fetch, so the next network fetch bypasses every HTTP cache layer. |
+| `getLoadedModuleUrls()` | URL-like keys currently in the module registry (used to compute full-reload eviction sets). |
+| `setDevBootComplete(value?)` | Flip the dev-boot-complete signal (defaults to `true`); disarms cold-boot-only behaviors. |
+
+Debug builds additionally carry `canonicalizeHttpUrlKey(url)`, a pure test
+diagnostic; release builds omit it. Missing members are simply absent —
+never present-but-throwing — so feature checks work. The module is
+registered in every build; the security boundary for remote module loading
+sits at the network layer (`security.allowRemoteModules` in
+nativescript.config, enforced inside `HttpLoader`), not the module
+registry and not `ns:runtime` getConfig/setConfig.
+
+Note: `ns:module` (loader policy, structured, boot-time) is deliberately
+separate from `ns:runtime` (live key-value runtime flags, `setConfig`/
+`getConfig`).
+
## `node:` compatibility shims
The same registry serves the `node:` scheme with **compatibility shims** so
diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js
index 208901d6e..b599550cf 100644
--- a/test-app/app/src/main/assets/app/mainpage.js
+++ b/test-app/app/src/main/assets/app/mainpage.js
@@ -46,6 +46,7 @@ require("./tests/testGC");
require("./tests/testsMemoryManagement");
require("./tests/testFieldGetSet");
require("./tests/extendedClassesTests");
+require("./tests/testNativeESClasses");
//require("./tests/extendClassNameTests"); // as tests now run with SBG, this test fails the whole build process
require("./tests/testJniReferenceLeak");
require("./tests/testNativeModules");
diff --git a/test-app/app/src/main/assets/app/tests/testNativeESClasses.js b/test-app/app/src/main/assets/app/tests/testNativeESClasses.js
new file mode 100644
index 000000000..fba6012b8
--- /dev/null
+++ b/test-app/app/src/main/assets/app/tests/testNativeESClasses.js
@@ -0,0 +1,549 @@
+describe("Tests native ES class extensions (class X extends NativeType)", function () {
+
+ var appClassLoader = com.tns.Runtime.class.getClassLoader();
+
+ // DummyClass.method2(Object) returns "obj=" + obj.toString(), forcing a Java-side virtual
+ // toString dispatch through the generated proxy. (java.lang.String.valueOf is unusable for
+ // this: accessing `java.lang.String.null` in other suites permanently replaces `valueOf` on
+ // the ctor function with the runtime's null-returning valueOf.)
+ function javaToString(obj) {
+ return new com.tns.tests.DummyClass().method2(obj);
+ }
+
+ it("When_extending_a_class_with_es_class_syntax_instances_should_construct_and_dispatch_overrides", function () {
+ class EsButton extends com.tns.tests.Button1 {
+ getIMAGE_ID_PROP() {
+ return "es class override";
+ }
+ }
+
+ var button = new EsButton();
+
+ expect(button instanceof EsButton).toBe(true);
+ expect(button instanceof com.tns.tests.Button1).toBe(true);
+ expect(button.getIMAGE_ID_PROP()).toBe("es class override");
+ // non-overridden base methods still work
+ expect(button.echo("hello")).toBe("hello");
+ });
+
+ it("When_java_calls_a_virtual_method_it_should_dispatch_to_the_es_class_override", function () {
+ class EchoButton extends com.tns.tests.Button1 {
+ echo(s) {
+ return "es:" + s;
+ }
+ }
+
+ var button = new EchoButton();
+
+ // triggerEcho calls this.echo(s) on the Java side - it must route
+ // through the generated proxy back into the ES class method
+ expect(button.triggerEcho("x")).toBe("es:x");
+ });
+
+ it("When_calling_super_method_from_an_es_class_override_it_should_invoke_the_java_implementation", function () {
+ class SuperEchoButton extends com.tns.tests.Button1 {
+ echo(s) {
+ return "es:" + super.echo(s);
+ }
+ }
+
+ var button = new SuperEchoButton();
+
+ expect(button.echo("y")).toBe("es:y");
+ // round trip: Java triggerEcho -> proxy echo -> JS override -> super.echo -> Java echo
+ expect(button.triggerEcho("z")).toBe("es:z");
+ });
+
+ it("When_the_es_class_constructor_passes_arguments_to_super_the_matching_java_constructor_should_be_used", function () {
+ class CtorButton extends com.tns.tests.Button1 {
+ constructor(value) {
+ super(value); // Button1(int) overload
+ this.value = value;
+ }
+ }
+
+ var button = new CtorButton(5);
+
+ expect(button.value).toBe(5);
+ expect(button instanceof com.tns.tests.Button1).toBe(true);
+ });
+
+ it("When_accessing_the_class_property_before_any_instance_exists_the_class_should_be_registered_lazily", function () {
+ class LazyTouch extends java.lang.Object {
+ toString() {
+ return "lazy touch";
+ }
+ }
+
+ // no `new` has happened - static touch must register the proxy class
+ var clazz = LazyTouch.class;
+
+ expect(clazz).not.toBe(null);
+ expect(clazz.getName()).toContain("LazyTouch");
+
+ // the class is fully functional before any JS-side construction:
+ // Java instantiates it through reflection and dispatches to the JS override
+ var created = clazz.newInstance();
+ expect(javaToString(created)).toBe("obj=lazy touch");
+ });
+
+ it("When_passing_the_es_class_to_a_java_method_expecting_a_class_it_should_marshal_to_java_lang_Class", function () {
+ class MarshalledClass extends com.tns.tests.DummyClass {
+ }
+
+ // Class.isAssignableFrom(Class) - MarshalledClass is registered lazily during marshalling
+ expect(com.tns.tests.DummyClass.class.isAssignableFrom(MarshalledClass)).toBe(true);
+ expect(java.lang.Object.class.isAssignableFrom(MarshalledClass)).toBe(true);
+ });
+
+ it("When_passing_the_es_class_where_java_lang_Object_is_expected_it_should_marshal_to_its_java_lang_Class", function () {
+ class ObjectMarshalledClass extends java.lang.Object {
+ }
+
+ var list = new java.util.ArrayList();
+ list.add(ObjectMarshalledClass);
+
+ var stored = list.get(0);
+ expect(stored.getName()).toBe(ObjectMarshalledClass.class.getName());
+ });
+
+ it("When_extending_an_es_class_that_extends_a_native_class_each_level_should_get_its_own_proxy", function () {
+ class LevelOne extends com.tns.tests.Button1 {
+ getIMAGE_ID_PROP() {
+ return "level one";
+ }
+ echo(s) {
+ return "L1:" + s;
+ }
+ }
+
+ class LevelTwo extends LevelOne {
+ getIMAGE_ID_PROP() {
+ return "level two + " + super.getIMAGE_ID_PROP();
+ }
+ }
+
+ var two = new LevelTwo();
+ expect(two instanceof LevelTwo).toBe(true);
+ expect(two instanceof LevelOne).toBe(true);
+ expect(two instanceof com.tns.tests.Button1).toBe(true);
+ expect(two.getIMAGE_ID_PROP()).toBe("level two + level one");
+ // method defined only on the intermediate level must be part of the proxy overrides
+ expect(two.triggerEcho("q")).toBe("L1:q");
+
+ var one = new LevelOne();
+ expect(one.getIMAGE_ID_PROP()).toBe("level one");
+ expect(one instanceof LevelTwo).toBe(false);
+
+ expect(one.getClass().getName()).not.toBe(two.getClass().getName());
+ });
+
+ it("When_constructing_the_es_class_multiple_times_all_instances_should_share_one_proxy_class", function () {
+ class SharedProxyButton extends com.tns.tests.Button1 {
+ }
+
+ var first = new SharedProxyButton();
+ var second = new SharedProxyButton();
+
+ expect(first.getClass().equals(second.getClass())).toBe(true);
+ expect(first.getClass().equals(SharedProxyButton.class)).toBe(true);
+ });
+
+ it("When_reading_base_class_statics_through_the_es_class_they_should_resolve_to_the_java_members", function () {
+ class StaticsButton extends com.tns.tests.Button1 {
+ static jsStatic = 42;
+ static jsStaticMethod() {
+ return "js static";
+ }
+ }
+
+ // Java statics are reachable through the constructor prototype chain
+ expect(StaticsButton.STATIC_IMAGE_ID).toBe("static image id");
+ expect(StaticsButton.SGetStaticImageId()).toBe("static image id");
+
+ // plain JS statics live on the JS constructor and are untouched
+ expect(StaticsButton.jsStatic).toBe(42);
+ expect(StaticsButton.jsStaticMethod()).toBe("js static");
+ });
+
+ it("When_the_es_class_is_registered_its_proxy_should_be_discoverable_through_the_app_class_loader", function () {
+ class DiscoverableEsClass extends java.lang.Object {
+ toString() {
+ return "discoverable es class";
+ }
+ }
+
+ var instance = new DiscoverableEsClass();
+ var className = instance.getClass().getName();
+
+ var found = java.lang.Class.forName(className, false, appClassLoader);
+
+ expect(found.getName()).toBe(className);
+ expect(found.equals(instance.getClass())).toBe(true);
+ expect(found.equals(DiscoverableEsClass.class)).toBe(true);
+ });
+
+ it("When_implementing_an_interface_with_es_class_syntax_java_should_dispatch_to_the_js_methods", function () {
+ var runCount = 0;
+
+ class EsRunnable extends java.lang.Runnable {
+ run() {
+ runCount++;
+ }
+ }
+
+ var runnable = new EsRunnable();
+ expect(runnable instanceof java.lang.Runnable).toBe(true);
+
+ // Thread.run() (not started) invokes target.run() synchronously on the current thread
+ var thread = new java.lang.Thread(runnable);
+ thread.run();
+
+ expect(runCount).toBe(1);
+ });
+
+ it("When_two_es_classes_implement_the_same_interface_js_instances_should_stay_distinct", function () {
+ var aCount = 0;
+ var bCount = 0;
+
+ class EsRunnableA extends java.lang.Runnable {
+ run() {
+ aCount++;
+ }
+ }
+
+ class EsRunnableB extends java.lang.Runnable {
+ run() {
+ bCount++;
+ }
+ }
+
+ new java.lang.Thread(new EsRunnableA()).run();
+ new java.lang.Thread(new EsRunnableB()).run();
+
+ expect(aCount).toBe(1);
+ expect(bCount).toBe(1);
+ // DexFactory shares one interface proxy; JS identity is per instance.
+ expect(EsRunnableA.class.equals(EsRunnableB.class)).toBe(true);
+ });
+
+ it("When_declaring_static_interfaces_the_proxy_should_implement_them", function () {
+ var ran = { value: false };
+
+ class WithInterfaces extends java.lang.Object {
+ static interfaces = [java.lang.Runnable];
+
+ run() {
+ ran.value = true;
+ }
+ }
+
+ var instance = new WithInterfaces();
+
+ expect(instance instanceof java.lang.Runnable).toBe(true);
+
+ var thread = new java.lang.Thread(instance);
+ thread.run();
+
+ expect(ran.value).toBe(true);
+ });
+
+ it("When_getting_the_class_name_it_should_be_stable_and_descriptive", function () {
+ class StableNameClass extends java.lang.Object {
+ }
+
+ var name = StableNameClass.class.getName();
+
+ // runtime generated proxies are named _es_, mirroring the
+ // legacy ____ scheme (the com.tns.gen prefix is only
+ // present on build-time pre-generated bindings)
+ expect(name).toContain("java.lang.Object_es");
+ expect(name).toContain("StableNameClass");
+ // repeated access resolves to the very same class
+ expect(StableNameClass.class.getName()).toBe(name);
+ });
+
+ it("When_passing_an_es_class_instance_to_java_and_reading_it_back_it_should_be_the_same_object", function () {
+ class RoundTripObject extends java.lang.Object {
+ toString() {
+ return "round trip";
+ }
+ }
+
+ var instance = new RoundTripObject();
+ var list = new java.util.ArrayList();
+ list.add(instance);
+
+ var stored = list.get(0);
+ expect(stored.equals(instance)).toBe(true);
+ expect(javaToString(stored)).toBe("obj=round trip");
+ });
+
+ it("When_calling_extend_on_an_es_class_it_should_throw_a_descriptive_error", function () {
+ class NotExtendable extends com.tns.tests.Button1 {
+ }
+
+ expect(function () {
+ NotExtendable.extend({
+ toString: function () {
+ return "should not work";
+ }
+ });
+ }).toThrow();
+ });
+
+ it("When_extending_a_class_created_with_legacy_extend_the_old_behavior_should_be_preserved", function () {
+ var LegacyButton = com.tns.tests.Button1.extend({
+ getIMAGE_ID_PROP: function () {
+ return "legacy override";
+ }
+ });
+
+ class EsOnLegacy extends LegacyButton {
+ }
+
+ // the legacy proxy is used - ES levels above `.extend()`-created classes get no proxy of their own
+ var instance = new EsOnLegacy();
+ expect(instance instanceof com.tns.tests.Button1).toBe(true);
+ expect(instance.getIMAGE_ID_PROP()).toBe("legacy override");
+ });
+
+ it("When_a_plain_js_class_hierarchy_is_used_the_runtime_should_not_interfere", function () {
+ class PlainBase {
+ value() {
+ return 1;
+ }
+ }
+
+ class PlainDerived extends PlainBase {
+ value() {
+ return super.value() + 1;
+ }
+ }
+
+ var plain = new PlainDerived();
+ expect(plain.value()).toBe(2);
+ expect(function () {
+ return plain instanceof java.lang.Object;
+ }).not.toThrow();
+ });
+
+ it("When_java_instantiates_an_es_class_the_js_constructor_and_fields_should_run", function () {
+ var constructorRuns = 0;
+
+ class ESAllocCtorObject extends java.lang.Object {
+ field = 42;
+
+ constructor() {
+ super();
+ constructorRuns++;
+ this.initializedFromJs = true;
+ }
+ }
+
+ // Objects Java allocates — Class.newInstance here, but equally view
+ // inflation or framework construction — are adopted into a real ES
+ // construct so class fields and the constructor body run on both paths.
+ var allocated = ESAllocCtorObject.class.newInstance();
+ expect(constructorRuns).toBe(1);
+ expect(allocated.field).toBe(42);
+ expect(allocated.initializedFromJs).toBe(true);
+ expect(allocated instanceof ESAllocCtorObject).toBe(true);
+
+ var constructed = new ESAllocCtorObject();
+ expect(constructorRuns).toBe(2);
+ expect(constructed.field).toBe(42);
+ expect(constructed.initializedFromJs).toBe(true);
+ });
+
+ it("When_java_instantiates_an_es_class_private_fields_should_be_readable", function () {
+ class ESPrivateAllocObject extends java.lang.Object {
+ #a = 1;
+
+ constructor() {
+ super();
+ }
+
+ someMethod() {
+ return this.#a;
+ }
+ }
+
+ expect(new ESPrivateAllocObject().someMethod()).toBe(1);
+ expect(ESPrivateAllocObject.class.newInstance().someMethod()).toBe(1);
+ });
+
+ it("When_java_instantiates_an_es_class_nested_native_construction_should_not_steal_adopt", function () {
+ class ESNestedAdoptObject extends java.lang.Object {
+ constructor() {
+ // Valid before super(): must not consume the pending adopt id
+ // that belongs to ESNestedAdoptObject.
+ var list = new java.util.ArrayList();
+ list.add("nested");
+ super();
+ this.list = list;
+ }
+ }
+
+ var allocated = ESNestedAdoptObject.class.newInstance();
+ expect(allocated instanceof ESNestedAdoptObject).toBe(true);
+ expect(allocated.list instanceof java.util.ArrayList).toBe(true);
+ expect(allocated.list.size()).toBe(1);
+ expect(allocated.list.get(0)).toBe("nested");
+ expect(allocated.getClass().getName()).toContain("ESNestedAdoptObject");
+
+ var constructed = new ESNestedAdoptObject();
+ expect(constructed.list.get(0)).toBe("nested");
+ expect(constructed.getClass().equals(allocated.getClass())).toBe(true);
+ });
+
+ it("When_java_instantiates_an_es_class_super_args_should_not_construct_again", function () {
+ class ESAdoptOnceObject extends com.tns.tests.DummyClass {
+ constructor() {
+ super("from-super");
+ }
+ }
+
+ // Java already called the no-arg DummyClass ctor (nameField = "dummy").
+ // Adopt must not run DummyClass(String).
+ var allocated = ESAdoptOnceObject.class.newInstance();
+ expect(allocated.nameField).toBe("dummy");
+ expect(allocated instanceof ESAdoptOnceObject).toBe(true);
+
+ var constructed = new ESAdoptOnceObject();
+ expect(constructed.nameField).toBe("from-super");
+ expect(constructed instanceof ESAdoptOnceObject).toBe(true);
+ });
+
+ it("When_an_es_class_constructor_throws_both_paths_should_surface_the_error", function () {
+ class ESThrowingCtorObject extends java.lang.Object {
+ constructor() {
+ super();
+ throw new Error("adopt construct failed");
+ }
+ }
+
+ var threw = false;
+ try {
+ ESThrowingCtorObject.class.newInstance();
+ } catch (e) {
+ threw = true;
+ }
+ expect(threw).toBe(true);
+
+ threw = false;
+ try {
+ new ESThrowingCtorObject();
+ } catch (e) {
+ threw = true;
+ }
+ expect(threw).toBe(true);
+ });
+
+ it("When_the_NativeClass_decorator_is_applied_it_should_apply_android_options", function () {
+ expect(typeof global.NativeClass).toBe("function");
+
+ const ESDecoratedPlain = global.NativeClass(class ESDecoratedPlainObject extends com.tns.tests.Button1 {
+ getIMAGE_ID_PROP() {
+ return "decorated";
+ }
+ });
+
+ var button = new ESDecoratedPlain();
+ expect(button instanceof ESDecoratedPlain).toBe(true);
+ expect(button.getIMAGE_ID_PROP()).toBe("decorated");
+
+ var ran = { value: false };
+ const ESDecoratedInterfaces = global.NativeClass({
+ android: {
+ interfaces: [java.lang.Runnable]
+ }
+ })(
+ class ESDecoratedInterfacesObject extends java.lang.Object {
+ run() {
+ ran.value = true;
+ }
+ }
+ );
+
+ var instance = new ESDecoratedInterfaces();
+ expect(instance instanceof java.lang.Runnable).toBe(true);
+
+ var thread = new java.lang.Thread(instance);
+ thread.run();
+ expect(ran.value).toBe(true);
+ });
+
+ it("When_NativeClass_sets_an_android_name_the_proxy_should_register_immediately", function () {
+ const ESEagerNamed = global.NativeClass({
+ android: {
+ name: "com.tns.gen.ESEagerNamedObject"
+ }
+ })(class UnusedJsNameForEager extends java.lang.Object {
+ });
+
+ expect(ESEagerNamed.class.getName()).toBe("com.tns.gen.ESEagerNamedObject");
+ expect(java.lang.Class.forName("com.tns.gen.ESEagerNamedObject", false, appClassLoader).equals(ESEagerNamed.class)).toBe(true);
+ expect(new ESEagerNamed() instanceof ESEagerNamed).toBe(true);
+ });
+
+ it("When_NativeClass_sets_an_unqualified_android_name_it_should_throw", function () {
+ expect(function () {
+ global.NativeClass({
+ android: {
+ name: "UnqualifiedName"
+ }
+ })(class UnqualifiedNativeClass extends java.lang.Object {
+ });
+ }).toThrow();
+ });
+
+ it("When_NativeClass_runs_on_a_worker_it_should_be_a_noop", function (done) {
+ var worker = new Worker("../shared/Workers/EvalWorker.js");
+ worker.onmessage = function (msg) {
+ expect(msg.data.isFunction).toBe(true);
+ expect(msg.data.isWorker).toBe(true);
+ expect(msg.data.hasName).toBe(false);
+ expect(msg.data.found).toBe(false);
+ worker.terminate();
+ done();
+ };
+ worker.onerror = function (error) {
+ fail("worker failed: " + error.message);
+ worker.terminate();
+ done();
+ };
+ worker.postMessage({
+ eval: "var C = NativeClass({ android: { name: 'com.tns.gen.TNSWorkerNativeClassName' } })(class TNSWorkerNativeClass extends java.lang.Object {}); " +
+ "var found = false; " +
+ "try { java.lang.Class.forName('com.tns.gen.TNSWorkerNativeClassName'); found = true; } catch (e) {} " +
+ "postMessage({ isFunction: typeof NativeClass === 'function', isWorker: !!__ns__worker, hasName: C.nativeClassName === 'com.tns.gen.TNSWorkerNativeClassName', found: found });"
+ });
+ });
+
+ it("When_anonymous_es_classes_extend_native_types_each_should_get_a_distinct_proxy", function () {
+ // Array-literal class expressions stay anonymous (no inferred name),
+ // so both hash as ESClass and exercise the _2 suffix collision path.
+ var classes = [
+ class extends java.lang.Object {
+ toString() {
+ return "first anonymous";
+ }
+ },
+ class extends java.lang.Object {
+ toString() {
+ return "second anonymous";
+ }
+ }
+ ];
+ var First = classes[0];
+ var Second = classes[1];
+
+ var firstInstance = new First();
+ var secondInstance = new Second();
+
+ expect(javaToString(firstInstance)).toBe("obj=first anonymous");
+ expect(javaToString(secondInstance)).toBe("obj=second anonymous");
+ expect(firstInstance.getClass().equals(secondInstance.getClass())).toBe(false);
+ });
+});
diff --git a/test-app/app/src/main/assets/app/tests/testNsModule.js b/test-app/app/src/main/assets/app/tests/testNsModule.js
new file mode 100644
index 000000000..6c33b9b2b
--- /dev/null
+++ b/test-app/app/src/main/assets/app/tests/testNsModule.js
@@ -0,0 +1,105 @@
+describe("ns:module", function () {
+ it("should expose the dev-loader primitives via the ns:module builtin", function () {
+ var nsModule = require("ns:module");
+ expect(Object.isFrozen(nsModule)).toBe(true);
+ expect(typeof nsModule.configureLoader).toBe("function");
+ expect(typeof nsModule.invalidateModules).toBe("function");
+ expect(typeof nsModule.getLoadedModuleUrls).toBe("function");
+ expect(typeof nsModule.setDevBootComplete).toBe("function");
+ expect(nsModule.terminateAllWorkers).toBeUndefined();
+ expect(global.__NS_DEV__).toBeUndefined();
+ });
+
+ it("exposes exactly the declared surface", function () {
+ var nsModule = require("ns:module");
+ var expected = ["configureLoader", "getLoadedModuleUrls", "invalidateModules", "setDevBootComplete"];
+ if (typeof nsModule.canonicalizeHttpUrlKey === "function") {
+ expected.push("canonicalizeHttpUrlKey");
+ }
+ expect(Object.keys(nsModule).sort()).toEqual(expected.sort());
+ });
+
+ it("resolves ns:module to the same members for require and import()", function (done) {
+ var nsModule = require("ns:module");
+ import("ns:module").then(function (ns) {
+ expect(ns.default).toBe(nsModule);
+ expect(ns.invalidateModules).toBe(nsModule.invalidateModules);
+ expect(ns.configureLoader).toBe(nsModule.configureLoader);
+ done();
+ }).catch(function (error) {
+ fail("import('ns:module') rejected: " + error.message);
+ done();
+ });
+ });
+
+ it("setDevBootComplete flips the JS-visible boot-complete global", function () {
+ var nsModule = require("ns:module");
+ nsModule.setDevBootComplete(true);
+ expect(global.__NS_HMR_BOOT_COMPLETE__).toBe(true);
+ nsModule.setDevBootComplete(false);
+ expect(global.__NS_HMR_BOOT_COMPLETE__).toBe(false);
+ nsModule.setDevBootComplete();
+ expect(global.__NS_HMR_BOOT_COMPLETE__).toBe(true);
+ nsModule.setDevBootComplete(false);
+ });
+});
+
+describe("HTTP canonical key (ns:module canonicalizeHttpUrlKey)", function () {
+ function getCanon() {
+ return require("ns:module").canonicalizeHttpUrlKey;
+ }
+
+ function checkKey(input, expected) {
+ var canon = getCanon();
+ if (typeof canon !== "function") {
+ pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)");
+ return;
+ }
+ expect(canon(input)).toBe(expected);
+ }
+
+ it("is exposed as a function in debug builds", function () {
+ var canon = getCanon();
+ if (typeof canon !== "function") {
+ pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)");
+ return;
+ }
+ expect(typeof canon).toBe("function");
+ });
+
+ it("drops dev cache-busters (t/v/import) but keeps real query params", function () {
+ checkKey("http://h/ns/core?p=x&t=123&v=9&import=1", "http://h/ns/core?p=x");
+ });
+
+ it("leaves public (non-dev, non-volatile) URLs untouched", function () {
+ checkKey("https://cdn.example.com/lib.js?token=abc", "https://cdn.example.com/lib.js?token=abc");
+ });
+
+ it("treats module identity as literally the URL — no path-tag collapses", function () {
+ checkKey("http://h/ns/m/foo.js", "http://h/ns/m/foo.js");
+ checkKey("http://h/ns/rt", "http://h/ns/rt");
+ checkKey("http://h/ns/core", "http://h/ns/core");
+ });
+
+ it("ignores URL fragments for dev endpoints", function () {
+ checkKey("http://h/ns/m/foo.js#frag", "http://h/ns/m/foo.js");
+ });
+
+ it("honors a client-supplied canonicalization vocabulary via configureLoader", function () {
+ var canon = getCanon();
+ if (typeof canon !== "function") {
+ pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)");
+ return;
+ }
+ require("ns:module").configureLoader({
+ canonicalization: {
+ stripParams: ["t", "v", "import"],
+ forPathPrefixes: ["/ns/", "/node_modules/.vite/", "/@id/", "/@fs/"],
+ preserveQueryFor: ["/@ng/component"],
+ },
+ });
+ expect(canon("http://h/ns/core?p=x&t=123&v=9&import=1")).toBe("http://h/ns/core?p=x");
+ expect(canon("http://h/ns/m/comp/@ng/component?c=a&t=42")).toBe("http://h/ns/m/comp/@ng/component?c=a&t=42");
+ expect(canon("https://cdn.example.com/lib.js?token=abc")).toBe("https://cdn.example.com/lib.js?token=abc");
+ });
+});
diff --git a/test-app/app/src/main/assets/app/tests/testNsRuntime.js b/test-app/app/src/main/assets/app/tests/testNsRuntime.js
new file mode 100644
index 000000000..dd9984815
--- /dev/null
+++ b/test-app/app/src/main/assets/app/tests/testNsRuntime.js
@@ -0,0 +1,67 @@
+describe("ns:runtime", function () {
+ var runtime = require("ns:runtime");
+
+ it("exposes frozen exports", function () {
+ expect(Object.isFrozen(runtime)).toBe(true);
+ expect(typeof runtime.setConfig).toBe("function");
+ expect(typeof runtime.getConfig).toBe("function");
+ });
+
+ it("exposes exactly the declared surface", function () {
+ expect(Object.keys(runtime).sort()).toEqual(["getConfig", "setConfig"]);
+ });
+
+ it("rejects unknown keys", function () {
+ expect(function () {
+ runtime.setConfig("noSuchKey", 1);
+ }).toThrowError(TypeError, /Unknown runtime config key/);
+ expect(function () {
+ runtime.getConfig("noSuchKey");
+ }).toThrowError(TypeError, /Unknown runtime config key/);
+ });
+
+ it("defaults logScriptLoading and httpFetchUrlLog from app config", function () {
+ expect(runtime.getConfig("logScriptLoading")).toBe(false);
+ expect(runtime.getConfig("httpFetchUrlLog")).toBe(false);
+ });
+
+ it("round-trips logScriptLoading and httpFetchUrlLog", function () {
+ runtime.setConfig("logScriptLoading", true);
+ expect(runtime.getConfig("logScriptLoading")).toBe(true);
+ runtime.setConfig("logScriptLoading", false);
+ expect(runtime.getConfig("logScriptLoading")).toBe(false);
+
+ runtime.setConfig("httpFetchUrlLog", true);
+ expect(runtime.getConfig("httpFetchUrlLog")).toBe(true);
+ runtime.setConfig("httpFetchUrlLog", false);
+ expect(runtime.getConfig("httpFetchUrlLog")).toBe(false);
+ });
+
+ it("rejects non-boolean log flag values and keeps the current one", function () {
+ expect(function () {
+ runtime.setConfig("logScriptLoading", "yes");
+ }).toThrowError(TypeError, /must be a boolean/);
+ expect(runtime.getConfig("logScriptLoading")).toBe(false);
+ expect(function () {
+ runtime.setConfig("httpFetchUrlLog", 1);
+ }).toThrowError(TypeError, /must be a boolean/);
+ expect(runtime.getConfig("httpFetchUrlLog")).toBe(false);
+ });
+
+ it("does not expose remote-module security through getConfig or setConfig", function () {
+ ["security", "allowRemoteModules", "remoteModuleAllowlist"].forEach(function (key) {
+ expect(function () {
+ runtime.getConfig(key);
+ }).toThrowError(TypeError, /Unknown runtime config key/);
+ expect(function () {
+ runtime.setConfig(key, true);
+ }).toThrowError(TypeError, /Unknown runtime config key/);
+ });
+ });
+
+ it("does not expose releasedObjectPolicy (iOS-only)", function () {
+ expect(function () {
+ runtime.getConfig("releasedObjectPolicy");
+ }).toThrowError(TypeError, /Unknown runtime config key/);
+ });
+});
diff --git a/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js b/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js
index 0398634b3..62d9153a6 100644
--- a/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js
+++ b/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js
@@ -142,6 +142,15 @@ describe("Remote Module Security", function() {
// In debug mode, this returns true because debug bypasses allowlist
expect(isAllowed).toBe(true);
});
+
+ it("should refuse lookalike-host prefixes at a URL-component boundary (Java helper)", function() {
+ // The Java helper is the production-path twin of the native gate.
+ // Debug still short-circuits to true, so this only asserts the
+ // helper exists and debug bypass still holds; production matching
+ // is covered by the native RemoteUrlMatchesAllowlistEntry logic.
+ expect(typeof com.tns.Runtime.isRemoteUrlAllowed).toBe("function");
+ expect(com.tns.Runtime.isRemoteUrlAllowed("https://cdn.example.com.attacker.com/x.js")).toBe(true);
+ });
});
describe("Static Import HTTP Loading", function() {
diff --git a/test-app/app/src/main/assets/internal/ts_helpers.js b/test-app/app/src/main/assets/internal/ts_helpers.js
index d1860dc47..c152afac7 100644
--- a/test-app/app/src/main/assets/internal/ts_helpers.js
+++ b/test-app/app/src/main/assets/internal/ts_helpers.js
@@ -166,6 +166,46 @@
}
}
+ function applyNativeClassOptions(target, options) {
+ // Workers must not mint or rename process-global native classes.
+ if (global.__ns__worker) {
+ return target;
+ }
+ // This runtime implements `android`; `ios` is accepted and ignored.
+ var android = options && options.android;
+ var interfaces = (android && android.interfaces) || (options && options.interfaces);
+ var name = android && android.name;
+
+ if (interfaces && interfaces.length > 0) {
+ var merged = (target.interfaces && target.interfaces instanceof Array ? target.interfaces.concat(interfaces) : interfaces.slice());
+ target.interfaces = merged;
+ // Legacy `.extend()` reads interfaces from the implementation object
+ // (the prototype). Keep both so downleveled ES5 targets still work.
+ if (target.prototype) {
+ target.prototype.interfaces = merged;
+ }
+ }
+ if (name) {
+ if (name.indexOf(".") === -1) {
+ throw new Error("NativeClass android.name must be a fully qualified Java class name.");
+ }
+ target.nativeClassName = name;
+ // Accessing `.class` lazily registers the proxy under the explicit name.
+ void target.class;
+ }
+ return target;
+ }
+
+ function NativeClass(arg) {
+ if (typeof arg === "function") {
+ return applyNativeClassOptions(arg, {});
+ }
+ var options = arg || {};
+ return function (target) {
+ return applyNativeClassOptions(target, options);
+ };
+ }
+
Object.defineProperty(global, "__native", { value: __native });
Object.defineProperty(global, "__extends", { value: __extends });
Object.defineProperty(global, "__decorate", { value: __decorate });
@@ -174,4 +214,5 @@
global.JavaProxy = JavaProxy;
}
global.Interfaces = Interfaces;
+ global.NativeClass = NativeClass;
})()
\ No newline at end of file
diff --git a/test-app/app/src/main/java/com/tns/NativeScriptRuntime.java b/test-app/app/src/main/java/com/tns/NativeScriptRuntime.java
new file mode 100644
index 000000000..be805e72b
--- /dev/null
+++ b/test-app/app/src/main/java/com/tns/NativeScriptRuntime.java
@@ -0,0 +1,14 @@
+package com.tns;
+
+public final class NativeScriptRuntime {
+ private NativeScriptRuntime() {
+ }
+
+ public static boolean reloadApplication() {
+ return RuntimeHelper.reloadApplication();
+ }
+
+ public static boolean reloadApplication(String baseDir) {
+ return RuntimeHelper.reloadApplication(baseDir);
+ }
+}
diff --git a/test-app/app/src/main/java/com/tns/RuntimeHelper.java b/test-app/app/src/main/java/com/tns/RuntimeHelper.java
index fa1542966..cb82c774c 100644
--- a/test-app/app/src/main/java/com/tns/RuntimeHelper.java
+++ b/test-app/app/src/main/java/com/tns/RuntimeHelper.java
@@ -8,6 +8,8 @@
import android.content.SharedPreferences;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
import android.preference.PreferenceManager;
import android.util.Log;
@@ -24,6 +26,9 @@ private RuntimeHelper() {
}
private static AndroidJsV8Inspector v8Inspector;
+ private static Context applicationContext;
+ private static BroadcastReceiver timezoneChangedReceiver;
+ private static boolean reloadScheduled;
// hasErrorIntent tells you if there was an event (with an uncaught
// exception) raised from ErrorReport
@@ -59,6 +64,9 @@ private static boolean hasErrorIntent(Context context) {
}
public static Runtime initRuntime(Context context) {
+ Context appContext = context.getApplicationContext();
+ applicationContext = appContext != null ? appContext : context;
+
if (Runtime.isInitialized()) {
return Runtime.getCurrentRuntime();
}
@@ -237,6 +245,45 @@ public static Runtime initRuntime(Context context) {
}
}
+ // The overload is kept for API parity with ios, but not needed with android.
+ public static synchronized boolean reloadApplication(String baseDir) {
+ return reloadApplication();
+ }
+
+ public static synchronized boolean reloadApplication() {
+ final Context context = applicationContext;
+ if (context == null || reloadScheduled) {
+ return false;
+ }
+
+ reloadScheduled = true;
+
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ Runtime.destroyMainRuntime();
+
+ Runtime runtime = initRuntime(context);
+ if (runtime == null) {
+ throw new IllegalStateException("NativeScript runtime reload failed to initialize a new runtime.");
+ }
+
+ runtime.run();
+ } catch (Throwable e) {
+ Log.e(logTag, "NativeScript runtime reload failed.", e);
+ throw new RuntimeException("NativeScript runtime reload failed.", e);
+ } finally {
+ synchronized (RuntimeHelper.class) {
+ reloadScheduled = false;
+ }
+ }
+ }
+ });
+
+ return true;
+ }
+
private static void waitForLiveSync(Context context) {
boolean needToWait = false;
@@ -265,6 +312,12 @@ private static void waitForLiveSync(Context context) {
}
private static void registerTimezoneChangedListener(Context context, final Runtime runtime) {
+ // Register/unregister against the application context so the same Context
+ // instance is used across initial launch and reload. Using the passed-in
+ // context (which may be an Activity on first launch but applicationContext
+ // on reload) would make unregisterReceiver fail and leak the old receiver.
+ final Context receiverContext = applicationContext != null ? applicationContext : context;
+
IntentFilter timezoneFilter = new IntentFilter(Intent.ACTION_TIMEZONE_CHANGED);
BroadcastReceiver timezoneReceiver = new BroadcastReceiver() {
@@ -295,7 +348,16 @@ public void onReceive(Context context, Intent intent) {
}
};
- context.registerReceiver(timezoneReceiver, timezoneFilter);
+ if (timezoneChangedReceiver != null) {
+ try {
+ receiverContext.unregisterReceiver(timezoneChangedReceiver);
+ } catch (IllegalArgumentException e) {
+ // Already unregistered.
+ }
+ }
+
+ timezoneChangedReceiver = timezoneReceiver;
+ receiverContext.registerReceiver(timezoneChangedReceiver, timezoneFilter);
}
public static void initLiveSync(Application app) {
diff --git a/test-app/runtests.gradle b/test-app/runtests.gradle
index 9cc19e6ff..aeb6f4951 100644
--- a/test-app/runtests.gradle
+++ b/test-app/runtests.gradle
@@ -35,6 +35,9 @@ def getBuildArguments = { ->
if (onlyX86) {
arguments.add("-PonlyX86")
}
+ if (project.hasProperty("abis")) {
+ arguments.add("-Pabis=${project.property('abis')}")
+ }
if (useCCache) {
arguments.add("-PuseCCache")
}
@@ -68,13 +71,14 @@ task runAdbAsRoot(type: Exec) {
}
task deletePreviousResultXml(type: Exec) {
+ ignoreExitValue = true
doFirst {
println "Removing previous android_unit_test_results.xml"
if (isWinOs) {
- commandLine "cmd", "/c", "adb", runOnDeviceOrEmulator, "-e", "shell", "rm", "-rf", "/data/data/com.tns.testapplication/android_unit_test_results.xml"
+ commandLine "cmd", "/c", "adb", runOnDeviceOrEmulator, "-e", "shell", "run-as", "com.tns.testapplication", "rm", "-f", "android_unit_test_results.xml"
} else {
- commandLine "adb", runOnDeviceOrEmulator, "-e", "shell", "rm", "-rf", "/data/data/com.tns.testapplication/android_unit_test_results.xml"
+ commandLine "adb", runOnDeviceOrEmulator, "-e", "shell", "run-as", "com.tns.testapplication", "rm", "-f", "android_unit_test_results.xml"
}
}
}
diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt
index 5e5a05f6e..d4a7e90eb 100644
--- a/test-app/runtime/CMakeLists.txt
+++ b/test-app/runtime/CMakeLists.txt
@@ -74,6 +74,8 @@ set(RUNTIME_BUILTIN_JS
${RUNTIME_BUILTIN_JS_DIR}/inspect.js
${RUNTIME_BUILTIN_JS_DIR}/json-helper.js
${RUNTIME_BUILTIN_JS_DIR}/node-util.js
+ ${RUNTIME_BUILTIN_JS_DIR}/ns-module.js
+ ${RUNTIME_BUILTIN_JS_DIR}/ns-runtime.js
${RUNTIME_BUILTIN_JS_DIR}/ns-util.js
${RUNTIME_BUILTIN_JS_DIR}/performance.js
${RUNTIME_BUILTIN_JS_DIR}/primordials.js
@@ -227,8 +229,7 @@ add_library(
src/main/cpp/URLImpl.cpp
src/main/cpp/URLSearchParamsImpl.cpp
src/main/cpp/URLPatternImpl.cpp
- src/main/cpp/HMRSupport.cpp
- src/main/cpp/DevFlags.cpp
+ src/main/cpp/HttpLoader.cpp
# Node-API: vendored upstream implementation plus the embedder half
# (env lifecycle, module registry, async work, threadsafe functions)
diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp
index 800f9a6fe..9cc8e564a 100644
--- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp
+++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp
@@ -94,6 +94,18 @@ bool CallbackHandlers::RegisterInstance(Isolate *isolate, const Local