Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0e3513d
feat(runtime): ESM resolver hardening and async module-graph loader
NathanWalker Aug 13, 2026
5b080c4
feat(runtime): HMR dev-sessions and a hardened HTTP session loader
NathanWalker Aug 13, 2026
d7b20a5
feat(runtime): expose the dev-loader surface as the ns:module builtin
NathanWalker Aug 13, 2026
b19dbe8
fix(worker): surface entry-script load errors and buffer early messages
NathanWalker Aug 13, 2026
d5d180c
test: cover the ESM loader, remote-module security, and worker behavior
NathanWalker Aug 13, 2026
8faf670
refactor(runtime): drop the require() optional-module placeholder
NathanWalker Aug 13, 2026
b6d3bac
refactor(runtime): rename HMRSupport to HttpLoader and fold DevFlags …
NathanWalker Aug 13, 2026
4e58028
fix(runtime): harden HTTP fetch, extend names, and worker drain retries
NathanWalker Aug 13, 2026
5fa23eb
ci: build
NathanWalker Aug 15, 2026
593dc7f
refactor(runtime): move per-isolate loader state onto RuntimeState slots
edusperoni Aug 19, 2026
781816a
test: add an in-app HTTP module fixture server
edusperoni Aug 19, 2026
f125072
perf(runtime): resolve module referrers through an identity-hash index
edusperoni Aug 19, 2026
76321ed
feat(runtime): category-scoped debug logging behind NS_DEBUG
edusperoni Aug 19, 2026
1bf9852
refactor(runtime): make the ESM resolver compile-and-register only
edusperoni Aug 19, 2026
2d7f3fe
feat(runtime): scheme-agnostic module graph discovery
edusperoni Aug 19, 2026
d56e86f
feat(runtime): one response classifier and a web-strict MIME gate for…
edusperoni Aug 19, 2026
13a6dc9
fix(runtime): clear dynamic-import routing state before settling waiters
edusperoni Aug 19, 2026
9fb3559
feat(runtime): extract module evaluation policies; require(esm) refus…
edusperoni Aug 19, 2026
b0d537a
feat(runtime): Node-parity exports interop for require(esm)
edusperoni Aug 19, 2026
062e9be
feat(runtime): createRequire and createPumpingRequire on ns:module, n…
edusperoni Aug 19, 2026
3cccda2
feat(runtime): import-map scopes, validation, and atomic installation
edusperoni Aug 19, 2026
9771032
refactor(runtime): workers copy the loader vocabulary at spawn
edusperoni Aug 19, 2026
73ebaab
refactor(runtime): drop hardcoded client vocabulary from the loader
edusperoni Aug 19, 2026
332c28c
refactor(runtime): derive boot state natively; setDevBootComplete rem…
edusperoni Aug 19, 2026
fd8ceec
fix(worker): enable the message queue on entry settle, like the web
edusperoni Aug 19, 2026
bfd1682
feat(runtime): ES module app entries and a boot backstop that holds f…
edusperoni Aug 19, 2026
52fdeef
fix(runtime): fail cleanly on an unreadable ES module entry
edusperoni Aug 19, 2026
55ca718
test: port the loader suite - HTTP ESM, createRequire, interop, workers
edusperoni Aug 19, 2026
e3ab969
docs: sync ns-builtin-modules.md with the loader overhaul
edusperoni Aug 19, 2026
6711447
refactor(runtime): reserve the g_ prefix for actual process globals
edusperoni Aug 19, 2026
006de1a
fix(runtime): loader input validation, worker entry resolution, revie…
edusperoni Aug 19, 2026
95c8bae
docs: sync ns-builtin-modules with the iOS module-reference rewrite
edusperoni Aug 19, 2026
da6cf99
fix(runtime): never poll the looper from inside one of its fd callbacks
edusperoni Aug 19, 2026
c167db4
perf(runtime): wait on the event loop's fds instead of sleeping in pumps
edusperoni Aug 19, 2026
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
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
Expand Down
762 changes: 690 additions & 72 deletions docs/ns-builtin-modules.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions test-app/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<application
android:requestLegacyExternalStorage="true"
android:name="com.tns.NativeScriptApplication"
android:networkSecurityConfig="@xml/network_security_config"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Exclusive to the microtask-context refusal spec: that refusal happens before
// evaluation, so this fixture must never be evaluated by any other spec or the
// guard would have nothing left to refuse.
await Promise.resolve();

export const value = "ok";
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Top-level await that settles from microtasks alone, so a pumping require can
// finish it while a strict require must still refuse the async graph.
await Promise.resolve();

export const value = "ok";
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// The ESM route into the node:module shim: a static import, resolved by the
// engine's module resolver rather than by require().
import { createRequire } from "node:module";

export const createRequireType = typeof createRequire;

export function requireFrom(filename, specifier) {
return createRequire(filename)(specifier);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
exports.tag = "createrequire-target";
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Exclusive to the deadlineSeconds spec: same never-settling park, so the
// require always reaches its deadline and throws.
export const value = await new Promise(function () {});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Settles only if v8 foreground tasks are pumped while the module evaluation
// promise is pending: the notify wakeup arrives as a platform task.
const i32 = new Int32Array(new SharedArrayBuffer(4));
const wait = Atomics.waitAsync(i32, 0, 0);
Atomics.notify(i32, 0);

export const value = await wait.value;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Exclusive to the onTimeout:"return-pending" spec. Parks on a promise nothing
// ever settles, so the outcome cannot depend on how v8 schedules a wakeup task
// — the deadline is always what ends the wait.
export const value = await new Promise(function () {});
2 changes: 2 additions & 0 deletions test-app/app/src/main/assets/app/esm/fs-fallback.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const ok = true;
export default { ok };
1 change: 1 addition & 0 deletions test-app/app/src/main/assets/app/esm/graph/bg-solo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const name = "bg-solo";
8 changes: 8 additions & 0 deletions test-app/app/src/main/assets/app/esm/graph/cycle-a.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { bValue, describeA } from "./cycle-b.mjs";

export function describeB() {
return "a-saw-" + bValue;
}

export const aValue = "a";
export const roundTrip = describeA();
7 changes: 7 additions & 0 deletions test-app/app/src/main/assets/app/esm/graph/cycle-b.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { aValue } from "./cycle-a.mjs";

export function describeA() {
return "b-saw-" + aValue;
}

export const bValue = "b";
7 changes: 7 additions & 0 deletions test-app/app/src/main/assets/app/esm/graph/diamond-entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { name as leftName } from "./left.mjs";
import { name as rightName } from "./right.mjs";

(globalThis.__nsGraphOrder = globalThis.__nsGraphOrder || []).push("entry");

export const order = globalThis.__nsGraphOrder.slice();
export const names = [leftName, rightName];
6 changes: 6 additions & 0 deletions test-app/app/src/main/assets/app/esm/graph/left.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { name as sharedName } from "./shared.mjs";

(globalThis.__nsGraphOrder = globalThis.__nsGraphOrder || []).push("left");

export const seenShared = sharedName;
export const name = "left";
6 changes: 6 additions & 0 deletions test-app/app/src/main/assets/app/esm/graph/right.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { name as sharedName } from "./shared.mjs";

(globalThis.__nsGraphOrder = globalThis.__nsGraphOrder || []).push("right");

export const seenShared = sharedName;
export const name = "right";
3 changes: 3 additions & 0 deletions test-app/app/src/main/assets/app/esm/graph/shared.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
(globalThis.__nsGraphOrder = globalThis.__nsGraphOrder || []).push("shared");

export const name = "shared";
24 changes: 24 additions & 0 deletions test-app/app/src/main/assets/app/esm/hmr/test-esm-module.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export const testValue = "http-esm-loaded";
export const timestamp = Date.now();

// `import.meta.hot` exists only when a dev server injects a hot context into the
// served source; a disk module must therefore see nothing here.
const hotContext = (typeof import.meta !== "undefined" && import.meta) ? import.meta.hot : undefined;

export function getHotContext() {
return hotContext;
}

export function callInvalidateSafe() {
if (!hotContext || typeof hotContext.invalidate !== "function") {
return false;
}
hotContext.invalidate();
return true;
}

export default function testFunction() {
return "HTTP ESM loader working at " + new Date().toISOString();
}

export { testFunction as namedExport };
4 changes: 4 additions & 0 deletions test-app/app/src/main/assets/app/esm/identity.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "esm-identity-fixture",
"value": 42
}
3 changes: 3 additions & 0 deletions test-app/app/src/main/assets/app/esm/interop/agreement.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default { tag: "agreement" };

export const named = "named";
9 changes: 9 additions & 0 deletions test-app/app/src/main/assets/app/esm/interop/default-live.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Default export through a `let` binding, so the facade's re-export has to
// stay live: a copied value would freeze `default` at 1.
let current = 1;

export { current as default };

export function bump() {
current += 1;
}
3 changes: 3 additions & 0 deletions test-app/app/src/main/assets/app/esm/interop/identity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default { tag: "identity" };

export const named = "named";
10 changes: 10 additions & 0 deletions test-app/app/src/main/assets/app/esm/interop/module-exports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// An arbitrary-string export name claims the whole require() result.
function theExport(a, b) {
return a + b;
}

theExport.marker = "module.exports fixture";

export { theExport as "module.exports" };

export default "ignored when module.exports is present";
5 changes: 5 additions & 0 deletions test-app/app/src/main/assets/app/esm/interop/named-only.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const alpha = "a";

export function beta() {
return "b";
}
4 changes: 4 additions & 0 deletions test-app/app/src/main/assets/app/esm/interop/own-esmodule.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Declaring __esModule opts out of the facade, so this value survives require().
export const __esModule = "mine";

export default { tag: "own-esmodule" };
1 change: 1 addition & 0 deletions test-app/app/src/main/assets/app/esm/meta/nested/child.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const childMeta = import.meta;
4 changes: 4 additions & 0 deletions test-app/app/src/main/assets/app/esm/meta/parent.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { childMeta } from "./nested/child.mjs";

export const parentMeta = import.meta;
export { childMeta };
6 changes: 6 additions & 0 deletions test-app/app/src/main/assets/app/esm/mixed/a-entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { leaf as leafName } from "./a-mid.mjs";

(globalThis.__nsMixedOrdera = globalThis.__nsMixedOrdera || []).push("entry");

export const leaf = leafName;
export const order = globalThis.__nsMixedOrdera.slice();
7 changes: 7 additions & 0 deletions test-app/app/src/main/assets/app/esm/mixed/a-mid.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// The HTTP leaf arrives through a bare specifier the import map points at a
// ModuleTestServer URL, so this file needs no baked-in origin.
import { name as leafName } from "ns-test-leaf-a";

(globalThis.__nsMixedOrdera = globalThis.__nsMixedOrdera || []).push("mid");

export const leaf = leafName;
6 changes: 6 additions & 0 deletions test-app/app/src/main/assets/app/esm/mixed/b-entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { leaf as leafName } from "./b-mid.mjs";

(globalThis.__nsMixedOrderb = globalThis.__nsMixedOrderb || []).push("entry");

export const leaf = leafName;
export const order = globalThis.__nsMixedOrderb.slice();
7 changes: 7 additions & 0 deletions test-app/app/src/main/assets/app/esm/mixed/b-mid.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// The HTTP leaf arrives through a bare specifier the import map points at a
// ModuleTestServer URL, so this file needs no baked-in origin.
import { name as leafName } from "ns-test-leaf-b";

(globalThis.__nsMixedOrderb = globalThis.__nsMixedOrderb || []).push("mid");

export const leaf = leafName;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// A static import is the third route to a built-in module, alongside require()
// and import(); this fixture exists to fail on it.
import "node:path";

export const unreachable = true;
10 changes: 10 additions & 0 deletions test-app/app/src/main/assets/app/esm/relative/dependency.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const relativeValue = "relative-import-success";

export function getDependencyPayload() {
return {
from: "dependency",
timestamp: Date.now(),
};
}

export default relativeValue;
9 changes: 9 additions & 0 deletions test-app/app/src/main/assets/app/esm/relative/entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import defaultValue, { relativeValue, getDependencyPayload } from "./dependency.mjs";

export const viaDefault = defaultValue;
export const viaNamed = relativeValue;

export function readDependencyPayload() {
const payload = getDependencyPayload();
return typeof payload === "object" && payload.from === "dependency";
}
2 changes: 2 additions & 0 deletions test-app/app/src/main/assets/app/esm/relative/meta.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const metaUrl = import.meta.url;
export const metaDirname = import.meta.dirname;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Two scopes match this referrer; the more specific one must win.
import { name as scoped } from "ns-scoped-leaf";

export const leaf = scoped;
7 changes: 7 additions & 0 deletions test-app/app/src/main/assets/app/esm/scoped/inside/mid.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Inside the scope: "ns-scoped-leaf" is overridden there, while
// "ns-scoped-fallthrough" is not and must fall through to top-level imports.
import { name as scoped } from "ns-scoped-leaf";
import { name as fellThrough } from "ns-scoped-fallthrough";

export const leaf = scoped;
export const fallthrough = fellThrough;
4 changes: 4 additions & 0 deletions test-app/app/src/main/assets/app/esm/scoped/outside/mid.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// No scope matches this referrer, so the top-level imports entry applies.
import { name as scoped } from "ns-scoped-leaf";

export const leaf = scoped;
5 changes: 5 additions & 0 deletions test-app/app/src/main/assets/app/esm/scoped/survivor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Resolves only through the map installed before a rejected update, proving
// the rejected update left the previous vocabulary in place.
import { name as scoped } from "ns-survivor";

export const leaf = scoped;
1 change: 1 addition & 0 deletions test-app/app/src/main/assets/app/esm/vocab/leafA.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const name = "vocab-a";
1 change: 1 addition & 0 deletions test-app/app/src/main/assets/app/esm/vocab/leafB.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const name = "vocab-b";
1 change: 1 addition & 0 deletions test-app/app/src/main/assets/app/esm/vocab/leafC.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const name = "vocab-c";
12 changes: 12 additions & 0 deletions test-app/app/src/main/assets/app/mainpage.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ require('./tests/testPrimordials');
require('./tests/testInspect');
// The ns:/node: builtin modules
require('./tests/testNsUtil');
require('./tests/testNsRuntime');
require('./tests/testNsModule');
// Remote-module security gate (debug builds allow remote modules outright)
require('./tests/testRemoteModuleSecurity');
// The ES module loader: require(esm) interop and the createRequire factories
require('./tests/testEsmInterop');
require('./tests/testCreateRequire');
require('./tests/testNodeUrlModule');
require('./tests/testImportMetaResolution');
require('./tests/testWorkerEsmEntry');
// Fetches from the in-app loopback fixture server, so it goes last
require('./tests/testEsmHttpLoader');
// Node-API addon surface
require('./tests/NapiTests');
require('./tests/NapiCoverageTests');
Expand Down
3 changes: 3 additions & 0 deletions test-app/app/src/main/assets/app/tests/esmEntryHelper.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Imported statically by an ES module worker entry, proving a static import in
// an entry module resolves during instantiation.
export const WORKER_TAG = "esm-entry";
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Reached through a relative worker specifier. Module code is strict, so the
// message handler is installed on globalThis rather than by bare assignment.
globalThis.onmessage = function (msg) {
postMessage("relative-entry:" + msg.data);
};
13 changes: 13 additions & 0 deletions test-app/app/src/main/assets/app/tests/esmEntryResolvedWorker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Reached through an extension-less worker specifier: no `.js` sibling exists,
// so only this file can answer. The top-level await parks on a NON-nestable
// foreground task, which the in-place yield window cannot settle, so a message
// posted at construction has to wait on the settle-gated queue.
const i32 = new Int32Array(new SharedArrayBuffer(4));
const wait = Atomics.waitAsync(i32, 0, 0);
Atomics.notify(i32, 0);

const settled = await wait.value;

globalThis.onmessage = function (msg) {
postMessage("resolved-entry:" + settled + ":" + msg.data);
};
7 changes: 7 additions & 0 deletions test-app/app/src/main/assets/app/tests/esmEntrySyncWorker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// A fully synchronous ES module worker entry. Module code is strict, so the
// message handler is installed on globalThis rather than by bare assignment.
import { WORKER_TAG } from "./esmEntryHelper.mjs";

globalThis.onmessage = function (msg) {
postMessage(WORKER_TAG + ":" + msg.data);
};
12 changes: 12 additions & 0 deletions test-app/app/src/main/assets/app/tests/esmEntryTlaWorker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// An ES module worker entry with top-level await, parked on a NON-nestable
// foreground task: the in-place yield window cannot settle it, so the entry
// finishes from the real event loop and the message queue enables on settle.
const i32 = new Int32Array(new SharedArrayBuffer(4));
const wait = Atomics.waitAsync(i32, 0, 0);
Atomics.notify(i32, 0);

const settled = await wait.value;

globalThis.onmessage = function (msg) {
postMessage("tla-entry:" + settled + ":" + msg.data);
};
9 changes: 9 additions & 0 deletions test-app/app/src/main/assets/app/tests/importMapWorker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Resolves whatever bare specifier the parent asks for, through the import map
// this worker inherited at spawn.
onmessage = function (msg) {
import(msg.data).then(function (mod) {
postMessage({ ok: true, name: mod.name });
}).catch(function (error) {
postMessage({ ok: false, error: String((error && error.message) || error) });
});
};
8 changes: 8 additions & 0 deletions test-app/app/src/main/assets/app/tests/lateHandlerWorker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Worker realms carry only the native __ns__-prefixed timers (the friendly
// setTimeout global is installed by userland, e.g. @nativescript/core).
__ns__setTimeout(function () {
onmessage = function (msg) {
postMessage("late:" + msg.data);
};
postMessage("ready");
}, 200);
Loading